diff --git a/.env.example b/.env.example index 550c8be..e092682 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index ff66d0d..6b19dd2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index df1369b..2cf776e 100644 --- a/README.md +++ b/README.md @@ -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`. | diff --git a/app/Agent/KitAgent.php b/app/Agent/KitAgent.php index 0bfded4..11af12c 100644 --- a/app/Agent/KitAgent.php +++ b/app/Agent/KitAgent.php @@ -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; @@ -27,6 +28,7 @@ public function tools(): iterable return [ new CatalogRead, new HeartbeatRead, + new BoardWrite, new LookReport, new YouTubeTranscript, new MemorySearch, diff --git a/app/Console/Commands/BoardCommand.php b/app/Console/Commands/BoardCommand.php new file mode 100644 index 0000000..eff946c --- /dev/null +++ b/app/Console/Commands/BoardCommand.php @@ -0,0 +1,48 @@ + (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; + } +} diff --git a/app/Factory/Board.php b/app/Factory/Board.php new file mode 100644 index 0000000..3ea4950 --- /dev/null +++ b/app/Factory/Board.php @@ -0,0 +1,186 @@ + */ + 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>} + */ + 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 $fields + * @return array + */ + 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>} + */ + 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>} + */ + 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 $base + * @param array $fields + * @return array + */ + 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; + } +} diff --git a/app/Factory/Snapshot.php b/app/Factory/Snapshot.php index e8de83f..b896c18 100644 --- a/app/Factory/Snapshot.php +++ b/app/Factory/Snapshot.php @@ -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); @@ -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'); diff --git a/app/Tools/BoardWrite.php b/app/Tools/BoardWrite.php new file mode 100644 index 0000000..7eeeb24 --- /dev/null +++ b/app/Tools/BoardWrite.php @@ -0,0 +1,63 @@ + $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) ?: '{}'; + } +} diff --git a/bin/board-write b/bin/board-write new file mode 100755 index 0000000..0edbb82 --- /dev/null +++ b/bin/board-write @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Grok / factory sessions: flock-write the canonical board. Not a Mattermost mouth. +# Usage: bin/board-write [--lifecycle=cut] [--owner=kit] [--issue=4] [--pr=URL] [--note=...] +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +exec php "$ROOT/artisan" kit:board "$@" diff --git a/config/kit.php b/config/kit.php index 314ca7e..98abf80 100644 --- a/config/kit.php +++ b/config/kit.php @@ -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'), diff --git a/docs/org.md b/docs/org.md index a8ac3b3..339f174 100644 --- a/docs/org.md +++ b/docs/org.md @@ -4,6 +4,10 @@ Kit chairs the factory. Lexi chairs the company. Neither clones the other. Jordan’s lock: ~10 specialists on Loki that collaborate. Kit does not implement new game dynamics. When stuck, Mattermost. Works when he is away. +## Board + +Live lock is `storage/app/kit/board.json` (`BoardWrite` / `bin/board-write`, flock). `state` is a free string. Optional `lifecycle` is queued|wip|cut|pr|live|blocked. Snapshot wins over chat. Heartbeat slims that file. Grok sessions write the board; they do not speak as `@kit`. + ## Company shape The product is **photo → catalog-ready GLB** (look report included). bikes-v2 is customer zero and the Steam demo. Agents are the shop floor, not the SKU. Do not sell an asset HTTP API until the factory is boring. diff --git a/tests/Feature/BoardWriteTest.php b/tests/Feature/BoardWriteTest.php new file mode 100644 index 0000000..c1700b1 --- /dev/null +++ b/tests/Feature/BoardWriteTest.php @@ -0,0 +1,90 @@ + $path]); + $this->boardPath = $path; +}); + +afterEach(function () { + @unlink($this->boardPath); +}); + +test('upserts a row and slims id state lifecycle', function () { + $out = (string) (new BoardWrite)->handle(new Request([ + 'id' => 'ranch-7620', + 'state' => 'cut', + 'lifecycle' => 'cut', + 'note' => 'chain on +X', + ])); + + expect($out)->toContain('"id": "ranch-7620"')->toContain('"state": "cut"'); + + $slim = app(Board::class)->slim(); + expect($slim['items'])->toHaveCount(1) + ->and($slim['items'][0])->toMatchArray([ + 'id' => 'ranch-7620', + 'state' => 'cut', + 'lifecycle' => 'cut', + ]); +}); + +test('state stays a free string', function () { + (new BoardWrite)->handle(new Request([ + 'id' => 'next', + 'state' => 'hero-ebike', + ])); + + expect(app(Board::class)->read()['items'][0]['state'])->toBe('hero-ebike'); +}); + +test('rejects a closed-enum lifecycle typo', function () { + $out = (string) (new BoardWrite)->handle(new Request([ + 'id' => 'ranch-7620', + 'state' => 'cut', + 'lifecycle' => 'shipping', + ])); + + expect($out)->toContain('lifecycle must be'); +}); + +test('heartbeat slim replica sees a write', function () { + app(Board::class)->upsert('blender', ['state' => 'cli']); + $slim = app(Board::class)->slim(); + + expect(json_encode($slim))->toContain('"id":"blender"')->toContain('"state":"cli"'); +}); + +test('two processes writing different ids do not tear the json', function () { + $path = $this->boardPath; + $artisan = base_path('artisan'); + $php = PHP_BINARY; + putenv('KIT_BOARD_PATH='.$path); + $_ENV['KIT_BOARD_PATH'] = $path; + $procs = []; + foreach (['alpha' => 'one', 'beta' => 'two'] as $id => $state) { + $cmd = sprintf( + '%s %s kit:board %s %s', + escapeshellarg($php), + escapeshellarg($artisan), + escapeshellarg($id), + escapeshellarg($state), + ); + $procs[] = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, base_path()); + } + foreach ($procs as $proc) { + if (is_resource($proc)) { + proc_close($proc); + } + } + + $data = json_decode((string) file_get_contents($path), true); + $ids = array_column($data['items'] ?? [], 'id'); + sort($ids); + expect($ids)->toBe(['alpha', 'beta']); +}); diff --git a/tests/Feature/PromptBuilderTest.php b/tests/Feature/PromptBuilderTest.php index c7e5799..e8d987e 100644 --- a/tests/Feature/PromptBuilderTest.php +++ b/tests/Feature/PromptBuilderTest.php @@ -7,6 +7,7 @@ 'kit.catalog_path' => base_path('tests/fixtures/catalog.json'), 'kit.look_report' => base_path('tests/fixtures/missing-look.json'), 'kit.status_path' => base_path('tests/fixtures/missing-status.json'), + 'kit.board_path' => base_path('tests/fixtures/board.json'), 'kit.bikes_v2' => base_path(), 'kit.kitd_health' => 'http://127.0.0.1:9/health', 'kit.memory_path' => storage_path('framework/testing/empty-memory.json'), @@ -23,5 +24,7 @@ ->toContain('catalog ids: rider, hero-ebike') ->toContain('East Jan repair guy') ->toContain('focus: hero-ebike') - ->toContain('not a Lexi citizen'); + ->toContain('not a Lexi citizen') + ->toContain('board: ranch-7620=cut') + ->toContain('Snapshot is truth'); }); diff --git a/tests/fixtures/board.json b/tests/fixtures/board.json new file mode 100644 index 0000000..fbfa20a --- /dev/null +++ b/tests/fixtures/board.json @@ -0,0 +1,7 @@ +{ + "updated": "2026-08-18T08:22:00-07:00", + "items": [ + {"id": "ranch-7620", "state": "cut", "lifecycle": "cut"}, + {"id": "rider", "state": "m1"} + ] +}