Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ KIT_BIKES_V2=/home/jordan/projects/bikes-v2
KIT_CATALOG_PATH=/home/jordan/projects/bikes-v2/public/models/catalog.json
KIT_LOOK_REPORT=/home/jordan/projects/bikes-v2/tmp/rider-look/report.json
KIT_STATUS_PATH=/home/jordan/.cache/kit/status.json
KIT_BOARD_PATH=/home/jordan/projects/kit/storage/app/kit/board.json
KIT_KITD_HEALTH=http://127.0.0.1:8787/health
KIT_YTDLP=/home/jordan/.local/bin/yt-dlp
KIT_ASK_LEXI=/home/jordan/.grok/kit/ask-lexi
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
/database/*.sqlite
/database/*.sqlite-journal
/storage/app/kit/memory.json
/storage/app/kit/board.json
/storage/app/kit/conversations
/storage/app/kit/transcripts
_ide_helper.php
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ This is Lexi's `instructions()` idea (identity + domain + boot + scratch + missi
|---|---|
| `CatalogRead` | Read `catalog.json`. List or one id. |
| `HeartbeatRead` | kitd / vite / blender stamp. |
| `BoardWrite` | Flock-upsert `board.json`. Grok sessions use `bin/board-write`. |
| `LookReport` | Last Playwright `report.json`. Does not run the suite. |
| `YouTubeTranscript` | Full captions via `yt-dlp` (timestamps). Not Lexi's 8k clip. |
| `MemoryStore` / `MemorySearch` | File-backed `knowledge_kilt`. |
Expand Down
2 changes: 2 additions & 0 deletions app/Agent/KitAgent.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Agent;

use App\Tools\AskLexi;
use App\Tools\BoardWrite;
use App\Tools\CatalogRead;
use App\Tools\HeartbeatRead;
use App\Tools\LookReport;
Expand All @@ -27,6 +28,7 @@ public function tools(): iterable
return [
new CatalogRead,
new HeartbeatRead,
new BoardWrite,
new LookReport,
new YouTubeTranscript,
new MemorySearch,
Expand Down
48 changes: 48 additions & 0 deletions app/Console/Commands/BoardCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace App\Console\Commands;

use App\Factory\Board;
use Illuminate\Console\Command;
use RuntimeException;

class BoardCommand extends Command
{
protected $signature = 'kit:board
{id : Row id (ranch-7620, blender, next)}
{state : Short free string}
{--lifecycle= : queued|wip|cut|pr|live|blocked}
{--owner= : kit|feel|bench|lexi|jordan}
{--issue= : bikes-v2 issue number}
{--pr= : PR URL}
{--note= : One line}';

protected $description = 'Upsert a factory board row (flock). Grok sessions use bin/board-write.';

public function handle(Board $board): int
{
$fields = ['state' => (string) $this->argument('state')];
foreach (['lifecycle', 'owner', 'pr', 'note'] as $key) {
$val = $this->option($key);
if ($val !== null && $val !== '') {
$fields[$key] = $val;
}
}
$issue = $this->option('issue');
if ($issue !== null && $issue !== '') {
$fields['issue'] = $issue;
}

try {
$item = $board->upsert((string) $this->argument('id'), $fields);
} catch (RuntimeException $e) {
$this->error($e->getMessage());

return self::FAILURE;
}

$this->line(json_encode($item, JSON_UNESCAPED_SLASHES) ?: '{}');

return self::SUCCESS;
}
}
186 changes: 186 additions & 0 deletions app/Factory/Board.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

namespace App\Factory;

use RuntimeException;

class Board
{
/** @var list<string> */
public const LIFECYCLES = ['queued', 'wip', 'cut', 'pr', 'live', 'blocked'];

public function path(): string
{
$path = (string) config('kit.board_path');
if ($path === '') {
throw new RuntimeException('kit.board_path empty');
}

return $path;
}

/**
* @return array{updated: string, items: list<array<string, mixed>>}
*/
public function read(): array
{
return $this->locked(function ($fh): array {
return $this->decode($this->readHandle($fh));
}, LOCK_SH);
}

/**
* Merge one row by id. state is a free string. lifecycle if set must be known.
*
* @param array<string, mixed> $fields
* @return array<string, mixed>
*/
public function upsert(string $id, array $fields): array
{
$id = trim($id);
if ($id === '') {
throw new RuntimeException('board id required');
}

return $this->locked(function ($fh) use ($id, $fields): array {
$data = $this->decode($this->readHandle($fh));
$items = $data['items'];
$idx = null;
foreach ($items as $i => $row) {
if (($row['id'] ?? '') === $id) {
$idx = $i;
break;
}
}
$base = $idx === null ? ['id' => $id] : $items[$idx];
$item = $this->merge($base, $fields);
if ($idx === null) {
$items[] = $item;
} else {
$items[$idx] = $item;
}
$payload = [
'updated' => now('America/Phoenix')->toIso8601String(),
'items' => array_values($items),
];
$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n";
rewind($fh);
ftruncate($fh, 0);
fwrite($fh, $json);

return $item;
}, LOCK_EX);
}

/**
* Heartbeat slim: id + state + lifecycle if present.
*
* @return array{updated: string, items: list<array<string, mixed>>}
*/
public function slim(): array
{
$data = $this->read();
$items = [];
foreach ($data['items'] as $row) {
if (! is_array($row) || ! isset($row['id'])) {
continue;
}
$slim = ['id' => $row['id'], 'state' => $row['state'] ?? ''];
if (isset($row['lifecycle']) && $row['lifecycle'] !== null && $row['lifecycle'] !== '') {
$slim['lifecycle'] = $row['lifecycle'];
}
$items[] = $slim;
}

return ['updated' => $data['updated'], 'items' => $items];
}

/**
* @template T
*
* @param callable(resource): T $fn
* @return T
*/
private function locked(callable $fn, int $lock)
{
$path = $this->path();
$dir = dirname($path);
if (! is_dir($dir) && ! mkdir($dir, 0755, true) && ! is_dir($dir)) {
throw new RuntimeException('cannot create '.$dir);
}
$fh = fopen($path, 'c+');
if ($fh === false) {
throw new RuntimeException('cannot open '.$path);
}
try {
if (! flock($fh, $lock)) {
throw new RuntimeException('board flock failed');
}

return $fn($fh);
} finally {
flock($fh, LOCK_UN);
fclose($fh);
}
}

/** @param resource $fh */
private function readHandle($fh): string
{
rewind($fh);
$raw = stream_get_contents($fh);

return $raw === false ? '' : $raw;
}

/**
* @return array{updated: string, items: list<array<string, mixed>>}
*/
private function decode(string $raw): array
{
$data = $raw === '' ? [] : json_decode($raw, true);
$items = is_array($data) ? ($data['items'] ?? []) : [];
if (! is_array($items)) {
$items = [];
}

return [
'updated' => is_array($data) ? (string) ($data['updated'] ?? '') : '',
'items' => array_values(array_filter($items, 'is_array')),
];
}

/**
* @param array<string, mixed> $base
* @param array<string, mixed> $fields
* @return array<string, mixed>
*/
private function merge(array $base, array $fields): array
{
foreach (['state', 'owner', 'pr', 'note'] as $key) {
if (array_key_exists($key, $fields) && $fields[$key] !== null) {
$base[$key] = is_string($fields[$key]) ? trim($fields[$key]) : $fields[$key];
}
}
if (array_key_exists('lifecycle', $fields)) {
$life = $fields['lifecycle'];
if ($life === null || $life === '') {
$base['lifecycle'] = null;
} else {
$life = trim((string) $life);
if (! in_array($life, self::LIFECYCLES, true)) {
throw new RuntimeException('lifecycle must be queued|wip|cut|pr|live|blocked');
}
$base['lifecycle'] = $life;
}
}
if (array_key_exists('issue', $fields) && $fields['issue'] !== null && $fields['issue'] !== '') {
$base['issue'] = (int) $fields['issue'];
}
if (array_key_exists('hops', $fields) && $fields['hops'] !== null && $fields['hops'] !== '') {
$base['hops'] = (int) $fields['hops'];
}

return $base;
}
}
26 changes: 26 additions & 0 deletions app/Factory/Snapshot.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ public function render(): string
$this->catalogLine(),
$this->lookLine(),
$this->heartbeatLine(),
$this->boardLine(),
$this->gitLine(),
'Snapshot is truth. Older chat or memory that disagrees is stale.',
];

return implode("\n", $lines);
Expand Down Expand Up @@ -75,6 +77,30 @@ private function heartbeatLine(): string
return '- kitd: unknown';
}

private function boardLine(): string
{
$path = (string) config('kit.board_path');
if ($path === '' || ! is_file($path)) {
return '- board: (none)';
}

$data = json_decode((string) File::get($path), true);
$items = is_array($data) ? ($data['items'] ?? []) : [];
$bits = [];
foreach ($items as $row) {
if (! is_array($row) || ! isset($row['id'])) {
continue;
}
$bit = $row['id'].'='.($row['state'] ?? '');
if (! empty($row['lifecycle'])) {
$bit .= '/'.$row['lifecycle'];
}
$bits[] = $bit;
}

return '- board: '.($bits === [] ? '(empty)' : implode('; ', $bits));
}

private function gitLine(): string
{
$root = (string) config('kit.bikes_v2');
Expand Down
63 changes: 63 additions & 0 deletions app/Tools/BoardWrite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace App\Tools;

use App\Factory\Board;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use RuntimeException;
use Stringable;

readonly class BoardWrite implements Tool
{
public function description(): Stringable|string
{
return 'Upsert one factory board row (flock on board.json). '
.'id is a catalog id or token (ranch-7620, blender, next). '
.'state is a short free string. lifecycle is optional queued|wip|cut|pr|live|blocked. '
.'Snapshot wins over chat. Do not invent a catalog URL.';
}

public function schema(JsonSchema $schema): array
{
return [
'id' => $schema->string()->description('Board row id.')->required(),
'state' => $schema->string()->description('Short free string (cut, m1, hero-ebike, cli).')->required(),
'lifecycle' => $schema->string()->description('Optional queued|wip|cut|pr|live|blocked.'),
'owner' => $schema->string()->description('Optional kit|feel|bench|lexi|jordan.'),
'issue' => $schema->integer()->description('Optional bikes-v2 issue number.'),
'pr' => $schema->string()->description('Optional PR URL.'),
'note' => $schema->string()->description('Optional one-line note.'),
];
}

public function handle(Request $request): Stringable|string
{
$id = trim((string) ($request['id'] ?? ''));
$state = trim((string) ($request['state'] ?? ''));
if ($id === '' || $state === '') {
return 'id and state are required';
}

$fields = ['state' => $state];
foreach (['lifecycle', 'owner', 'pr', 'note'] as $key) {
$val = $request[$key] ?? null;
if ($val !== null && $val !== '') {
$fields[$key] = $val;
}
}
$issue = $request['issue'] ?? null;
if ($issue !== null && $issue !== '') {
$fields['issue'] = $issue;
}

try {
$item = app(Board::class)->upsert($id, $fields);
} catch (RuntimeException $e) {
return 'board: '.$e->getMessage();
}

return json_encode($item, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{}';
}
}
6 changes: 6 additions & 0 deletions bin/board-write
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Grok / factory sessions: flock-write the canonical board. Not a Mattermost mouth.
# Usage: bin/board-write <id> <state> [--lifecycle=cut] [--owner=kit] [--issue=4] [--pr=URL] [--note=...]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
exec php "$ROOT/artisan" kit:board "$@"
1 change: 1 addition & 0 deletions config/kit.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
'catalog_path' => env('KIT_CATALOG_PATH', '/home/jordan/projects/bikes-v2/public/models/catalog.json'),
'look_report' => env('KIT_LOOK_REPORT', '/home/jordan/projects/bikes-v2/tmp/rider-look/report.json'),
'status_path' => env('KIT_STATUS_PATH', '/home/jordan/.cache/kit/status.json'),
'board_path' => env('KIT_BOARD_PATH', storage_path('app/kit/board.json')),
'memory_path' => env('KIT_MEMORY_PATH', storage_path('app/kit/memory.json')),
'conversations_path' => env('KIT_CONVERSATIONS_PATH', storage_path('app/kit/conversations')),
'kitd_health' => env('KIT_KITD_HEALTH', 'http://127.0.0.1:8787/health'),
Expand Down
Loading
Loading