From efa3ee89847f23c32f1708e828402c46b6106cc3 Mon Sep 17 00:00:00 2001 From: Adi Levinshtein Date: Fri, 17 Apr 2026 17:51:15 -0700 Subject: [PATCH 1/2] Revert "fix: preserve linked tracker edits during overlay reload" This reverts commit a69b6075daedb65707f470b6f18634f8ec45325c. --- OPEN_SOURCE_READINESS.md | 4 +- hub/runtime-overlay.js | 112 +-------------------------------------- hub/server.js | 108 ++++++++++++++++--------------------- hub/store.js | 16 ++---- 4 files changed, 54 insertions(+), 186 deletions(-) diff --git a/OPEN_SOURCE_READINESS.md b/OPEN_SOURCE_READINESS.md index 0d33627..c2d4698 100644 --- a/OPEN_SOURCE_READINESS.md +++ b/OPEN_SOURCE_READINESS.md @@ -6,7 +6,7 @@ audit can be checked off in one read. Final snapshot: -- Tests: **191 passing** (`npm test`, ~35s) +- Tests: **179 passing** (`npm test`, ~37s) - License: Apache-2.0; `NOTICE` now present and shipped - CI: Ubuntu × Node 18/20/22 + macOS/Windows smoke + `npm pack --dry-run` - Community files: `SECURITY.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, @@ -176,7 +176,7 @@ Apache-2.0 §4(d) is triggered. `NOTICE` added to `package.json:files`. - [x] Healthz endpoint (Dockerfile reverted — see §14) - [x] UI a11y minimal pass - [x] First-run download hint -- [x] `npm test` passes (191/191) +- [x] `npm test` passes (179/179) - [ ] Run `npm pack && npm install -g ./llm-tracker-*.tgz` on a clean environment and walk through the quickstart one more time - [ ] Tag a release (release-please drives the version on next merge to `main`) diff --git a/hub/runtime-overlay.js b/hub/runtime-overlay.js index 506b81c..338cb95 100644 --- a/hub/runtime-overlay.js +++ b/hub/runtime-overlay.js @@ -5,7 +5,6 @@ import { realpathSync, readFileSync, renameSync, - statSync, unlinkSync, writeFileSync } from "node:fs"; @@ -25,10 +24,6 @@ function atomicWriteJson(file, data) { renameSync(tmp, file); } -function sameValue(a, b) { - return JSON.stringify(a) === JSON.stringify(b); -} - function ensureOverlayDir(workspace) { const dir = join(runtimeDir(workspace), "overlays"); mkdirSync(dir, { recursive: true }); @@ -74,17 +69,6 @@ export function readRuntimeOverlay(workspace, slug) { } } -function overlayFileIsStaleComparedToTarget(workspace, slug, trackerPath) { - const overlayFile = runtimeOverlayPath(workspace, slug); - if (!existsSync(overlayFile)) return false; - try { - const targetFile = durableWritePath(trackerPath, true); - return statSync(targetFile).mtimeMs > statSync(overlayFile).mtimeMs; - } catch { - return false; - } -} - export function clearRuntimeOverlay(workspace, slug) { const file = runtimeOverlayPath(workspace, slug); if (!existsSync(file)) return; @@ -119,107 +103,15 @@ export function applyRuntimeOverlay(baseProject, overlay) { return data; } -function applyBaseRuntimeOverrides(baseProject, overlayAppliedProject, overlay) { - const data = clone(overlayAppliedProject); - if (!overlay || !data) return data; - - if (data.meta && baseProject?.meta) { - for (const field of META_RUNTIME_FIELDS) { - const baseHas = field in baseProject.meta; - const overlayHas = !!overlay.meta && field in overlay.meta; - if (!overlayHas) continue; - if (baseHas && !sameValue(baseProject.meta[field], overlay.meta[field])) { - data.meta[field] = clone(baseProject.meta[field]); - } else if (!baseHas) { - delete data.meta[field]; - } - } - } - - if (Array.isArray(data.tasks) && Array.isArray(baseProject?.tasks)) { - const baseById = new Map(baseProject.tasks.map((task) => [task.id, task])); - data.tasks = data.tasks.map((task) => { - const baseTask = baseById.get(task.id); - const overlayTask = overlay.tasks?.[task.id]; - if (!baseTask || !overlayTask) return task; - const next = { ...task }; - for (const field of TASK_RUNTIME_FIELDS) { - const baseHas = field in baseTask; - const overlayHas = field in overlayTask; - if (!overlayHas) continue; - if (baseHas && !sameValue(baseTask[field], overlayTask[field])) { - next[field] = clone(baseTask[field]); - } else if (!baseHas) { - delete next[field]; - } - } - return next; - }); - } - - return data; -} - -function applyChangedBaseRuntimeFields(baseProject, overlayAppliedProject, previousBaseProject) { - const data = clone(overlayAppliedProject); - if (!previousBaseProject || !data) return data; - - if (data.meta && baseProject?.meta && previousBaseProject?.meta) { - for (const field of META_RUNTIME_FIELDS) { - if (sameValue(baseProject.meta[field], previousBaseProject.meta[field])) continue; - if (field in baseProject.meta) { - data.meta[field] = clone(baseProject.meta[field]); - } else { - delete data.meta[field]; - } - } - } - - if (Array.isArray(data.tasks) && Array.isArray(baseProject?.tasks) && Array.isArray(previousBaseProject?.tasks)) { - const baseById = new Map(baseProject.tasks.map((task) => [task.id, task])); - const previousById = new Map(previousBaseProject.tasks.map((task) => [task.id, task])); - data.tasks = data.tasks.map((task) => { - const baseTask = baseById.get(task.id); - const previousTask = previousById.get(task.id); - if (!baseTask || !previousTask) return task; - const next = { ...task }; - for (const field of TASK_RUNTIME_FIELDS) { - if (sameValue(baseTask[field], previousTask[field])) continue; - if (field in baseTask) { - next[field] = clone(baseTask[field]); - } else { - delete next[field]; - } - } - return next; - }); - } - - return data; -} - -export function loadProjectWithRuntimeOverlay({ - workspace, - slug, - trackerPath, - baseProject, - previousBaseProject = null -}) { +export function loadProjectWithRuntimeOverlay({ workspace, slug, trackerPath, baseProject }) { const overlayEnabled = isOverlayBackedTracker(trackerPath); const base = clone(baseProject); if (!overlayEnabled) { return { base, data: clone(baseProject), overlayEnabled }; } - const overlay = readRuntimeOverlay(workspace, slug); - const preferBaseRuntime = overlayFileIsStaleComparedToTarget(workspace, slug, trackerPath); - const overlaid = applyRuntimeOverlay(baseProject, overlay); return { base, - data: previousBaseProject - ? applyChangedBaseRuntimeFields(baseProject, overlaid, previousBaseProject) - : preferBaseRuntime - ? applyBaseRuntimeOverrides(baseProject, overlaid, overlay) - : overlaid, + data: applyRuntimeOverlay(baseProject, readRuntimeOverlay(workspace, slug)), overlayEnabled }; } diff --git a/hub/server.js b/hub/server.js index 48a14b5..f34a3f3 100644 --- a/hub/server.js +++ b/hub/server.js @@ -1,15 +1,6 @@ import { createServer } from "node:http"; import { randomBytes } from "node:crypto"; -import { - lstatSync, - readFileSync, - existsSync, - writeFileSync, - renameSync, - readdirSync, - realpathSync, - statSync -} from "node:fs"; +import { lstatSync, readFileSync, existsSync, writeFileSync, renameSync, readdirSync, realpathSync } from "node:fs"; import { join, extname, dirname } from "node:path"; import { createRequire } from "node:module"; import express from "express"; @@ -151,15 +142,6 @@ function projectPayload(slug, entry) { }; } -function isLinkedTrackerPath(filePath) { - if (!filePath) return false; - try { - return lstatSync(filePath).isSymbolicLink(); - } catch { - return false; - } -} - function snapshot(store) { const projects = {}; for (const { slug } of store.list()) { @@ -363,14 +345,9 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { next(); }); - app.get("/api/projects/:slug", async (req, res) => { - let entry = store.get(req.params.slug); + app.get("/api/projects/:slug", (req, res) => { + const entry = store.get(req.params.slug); if (!entry) return res.status(404).json({ error: "not found" }); - if (isLinkedTrackerPath(entry.path)) { - await reloadProject(req.params.slug, { broadcastUpdate: true }); - entry = store.get(req.params.slug); - if (!entry) return res.status(404).json({ error: "not found" }); - } res.json(projectPayload(req.params.slug, entry)); }); registerIntelligenceRoutes(app, { workspace, store }); @@ -756,10 +733,19 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { // (Option C registration). Native fsevents does not forward changes from // outside the watched tree, so we poll only the individual target file — // not its parent directory — to keep CPU cost bounded. - // slug → { target, mtimeMs, size } for linked tracker files outside the - // workspace. Polling exact files is more reliable here than asking chokidar - // to bootstrap a second dynamic polling tree. + const linkedTargetsWatcher = chokidar.watch([], { + ignoreInitial: true, + followSymlinks: false, + usePolling: true, + interval: 300, + binaryInterval: 500, + ignored: WATCHER_IGNORED, + awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 40 } + }); + + // slug → absolute target path currently polled via linkedTargetsWatcher const linkedTargetsBySlug = new Map(); + const linkedSlugByTarget = new Map(); const trackLinkedTarget = (slug) => { const trackerFile = join(trackersDir, `${slug}.json`); @@ -772,25 +758,23 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { return; } if (!real || real === trackerFile) return; - let stat; - try { - stat = statSync(real); - } catch { - return; - } const existing = linkedTargetsBySlug.get(slug); - if (existing?.target === real && existing.mtimeMs === stat.mtimeMs && existing.size === stat.size) { - return; + if (existing === real) return; + if (existing) { + linkedTargetsWatcher.unwatch(existing); + linkedSlugByTarget.delete(existing); } - linkedTargetsBySlug.set(slug, { - target: real, - mtimeMs: stat.mtimeMs, - size: stat.size - }); + linkedTargetsWatcher.add(real); + linkedTargetsBySlug.set(slug, real); + linkedSlugByTarget.set(real, slug); }; const untrackLinkedTarget = (slug) => { + const target = linkedTargetsBySlug.get(slug); + if (!target) return; + linkedTargetsWatcher.unwatch(target); linkedTargetsBySlug.delete(slug); + linkedSlugByTarget.delete(target); }; // Patch-file watcher (bash-less write path): any file dropped into patches/ @@ -960,36 +944,32 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { if (r) broadcast({ type: "REMOVE", slug: r.slug }); }); - const linkedTargetsPollTimer = setInterval(() => { - for (const [slug, tracked] of linkedTargetsBySlug) { - let stat; - try { - stat = statSync(tracked.target); - } catch { - // Target deleted out from under us — clear tracking; the symlink in - // trackers/ is now dangling and the next read will surface that. - untrackLinkedTarget(slug); - continue; - } - if (stat.mtimeMs === tracked.mtimeMs && stat.size === tracked.size) continue; - tracked.mtimeMs = stat.mtimeMs; - tracked.size = stat.size; - const trackerFile = join(trackersDir, `${slug}.json`); - if (existsSync(trackerFile)) ingestTrackerFile(trackerFile); - } - }, 300); - linkedTargetsPollTimer.unref?.(); + linkedTargetsWatcher.on("change", (targetPath) => { + const slug = linkedSlugByTarget.get(targetPath); + if (!slug) return; + const trackerFile = join(trackersDir, `${slug}.json`); + if (existsSync(trackerFile)) ingestTrackerFile(trackerFile); + }); + linkedTargetsWatcher.on("unlink", (targetPath) => { + const slug = linkedSlugByTarget.get(targetPath); + if (!slug) return; + // Target deleted out from under us — clear tracking; the symlink in + // trackers/ is now dangling and the next read will surface that. + untrackLinkedTarget(slug); + }); const shutdown = async () => { if (shuttingDown) return; shuttingDown = true; clearInterval(uiSessionSweepTimer); - clearInterval(linkedTargetsPollTimer); try { await watcher.close(); } catch {} + try { + await linkedTargetsWatcher.close(); + } catch {} try { await patchesWatcher.close(); } catch {} @@ -1031,10 +1011,12 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { await new Promise((resolve, reject) => { const onError = async (err) => { httpServer.off("listening", onListening); - clearInterval(linkedTargetsPollTimer); try { await watcher.close(); } catch {} + try { + await linkedTargetsWatcher.close(); + } catch {} try { await patchesWatcher.close(); } catch {} diff --git a/hub/store.js b/hub/store.js index 900c8da..d3567ec 100644 --- a/hub/store.js +++ b/hub/store.js @@ -168,7 +168,7 @@ export class Store { }); } - _loadProjectState(slug, filePath, rawContents = null, notes = null, previousBaseProject = null) { + _loadProjectState(slug, filePath, rawContents = null, notes = null) { const raw = typeof rawContents === "string" ? rawContents : readFileSync(filePath, "utf-8"); const parsed = JSON.parse(raw); const normalized = normalizeProjectStatuses(parsed, notes).data; @@ -176,8 +176,7 @@ export class Store { workspace: this.workspace, slug, trackerPath: filePath, - baseProject: normalized, - previousBaseProject + baseProject: normalized }); } @@ -265,20 +264,15 @@ export class Store { const slug = slugFromFile(filePath); if (!slug) return { ok: false, reason: "not-a-tracker" }; - let prev = this.projects.get(slug); let loadedIncoming; try { const normalizationNotes = { warnings: [] }; - loadedIncoming = this._loadProjectState( - slug, - filePath, - rawContents, - normalizationNotes, - prev?.base || null - ); + loadedIncoming = this._loadProjectState(slug, filePath, rawContents, normalizationNotes); let incoming = normalizeProjectStatuses(loadedIncoming.data, normalizationNotes).data; const incomingBase = loadedIncoming.base; const overlayEnabled = loadedIncoming.overlayEnabled; + + let prev = this.projects.get(slug); // Cold-start resume: if we don't have in-memory state but incoming matches // a known snapshot at incoming.meta.rev, adopt without bumping. From 733d98b999d0ab80c280e095436ee14ed8e3ea69 Mon Sep 17 00:00:00 2001 From: Adi Levinshtein Date: Fri, 17 Apr 2026 17:51:16 -0700 Subject: [PATCH 2/2] Revert "Merge remote-tracking branch 'origin/main'" This reverts commit 9c9a018695bcd6e015e68d8452092e2b498cd2bb, reversing changes made to 60756c607f81570d2c4412c0343ae2c7cbb79433. --- .github/ISSUE_TEMPLATE/bug_report.md | 28 - .github/ISSUE_TEMPLATE/feature_request.md | 18 - .github/pull_request_template.md | 24 - .github/workflows/ci.yml | 2 - .gitignore | 1 - AGENTS.md | 43 - ARCHITECTURE.md | 280 +--- CODE_OF_CONDUCT.md | 11 - CONTRIBUTING.md | 81 -- LICENSE | 180 +-- MIGRATING.md | 393 ------ NOTICE | 19 - OPEN_SOURCE_READINESS.md | 195 --- README.md | 428 +----- SECURITY.md | 68 - bin/commands/blockers.js | 73 - bin/commands/brief.js | 137 -- bin/commands/changed.js | 55 - bin/commands/decisions.js | 53 - bin/commands/execute.js | 91 -- bin/commands/fuzzy.js | 12 - bin/commands/next.js | 56 - bin/commands/pick.js | 55 - bin/commands/reload.js | 33 - bin/commands/search.js | 12 - bin/commands/shared.js | 92 -- bin/commands/verify.js | 68 - bin/commands/why.js | 98 -- bin/llm-tracker.js | 512 ++----- bin/mcp-context-data.js | 190 --- bin/mcp-context.js | 2 - bin/mcp-prompts.js | 184 --- bin/mcp-read-tools.js | 297 ---- bin/mcp-resources.js | 104 -- bin/mcp-server.js | 117 -- bin/mcp-tools.js | 7 - bin/mcp-utils.js | 80 -- bin/mcp-write-tools.js | 163 --- bin/workspace-client.js | 70 - hub/blockers.js | 86 -- hub/briefs.js | 232 --- hub/changed.js | 150 -- hub/decisions.js | 96 -- hub/error-payload.js | 48 - hub/execute.js | 126 -- hub/merge.js | 60 +- hub/next.js | 106 -- hub/pick.js | 125 -- hub/progress.js | 2 +- hub/project-loader.js | 87 -- hub/references.js | 38 - hub/routes/intelligence.js | 188 --- hub/runtime-overlay.js | 180 --- hub/runtime.js | 98 -- hub/search.js | 772 ---------- hub/server.js | 770 +--------- hub/snippets.js | 264 ---- hub/status-vocabulary.js | 50 - hub/store.js | 807 ++--------- hub/task-metadata.js | 112 -- hub/validator.js | 55 +- hub/verify.js | 144 -- hub/why.js | 187 --- package-lock.json | 1547 +-------------------- package.json | 10 +- test/blockers.test.js | 46 - test/brief-cli.test.js | 98 -- test/briefs.test.js | 116 -- test/changed.test.js | 52 - test/daemon.test.js | 270 ---- test/decisions.test.js | 42 - test/error-payload.test.js | 33 - test/execute-cli.test.js | 90 -- test/execute.test.js | 44 - test/field-limits.test.js | 189 --- test/fixtures.js | 13 - test/healthz.test.js | 92 -- test/helpers/fake-embedder.mjs | 26 - test/ingest-lock.test.js | 47 - test/mcp-tools.test.js | 58 - test/mcp.test.js | 539 ------- test/merge.test.js | 12 - test/next-cli.test.js | 88 -- test/next.test.js | 230 --- test/pick-cli.test.js | 87 -- test/pick.test.js | 82 -- test/progress.test.js | 9 - test/references.test.js | 33 - test/reload-cli.test.js | 184 --- test/restore.test.js | 209 --- test/runtime-overlay.test.js | 108 -- test/search-cli.test.js | 115 -- test/search.test.js | 291 ---- test/security.test.js | 337 ----- test/shortcuts-cli.test.js | 39 - test/snippets.test.js | 128 -- test/store.test.js | 180 --- test/swimlane-move.test.js | 60 - test/task-intel-cli.test.js | 98 -- test/tombstones.test.js | 125 -- test/ui-intelligence.test.js | 80 -- test/validator.test.js | 50 - test/verify-cli.test.js | 88 -- test/verify.test.js | 41 - test/versioning.test.js | 84 +- test/watcher-scope.test.js | 93 -- test/why-decisions-cli.test.js | 101 -- test/why.test.js | 44 - ui/app.js | 714 +++------- ui/favicon.png | Bin 6144 -> 0 bytes ui/index.html | 1 - ui/lib/intelligence.js | 116 -- ui/modals/history.js | 125 -- ui/modals/intelligence.js | 636 --------- ui/styles.css | 653 --------- ui/task-outcomes.js | 7 - workspace-template/README.md | 539 +------ 117 files changed, 552 insertions(+), 17662 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .github/pull_request_template.md delete mode 100644 AGENTS.md delete mode 100644 CODE_OF_CONDUCT.md delete mode 100644 CONTRIBUTING.md delete mode 100644 MIGRATING.md delete mode 100644 NOTICE delete mode 100644 OPEN_SOURCE_READINESS.md delete mode 100644 SECURITY.md delete mode 100644 bin/commands/blockers.js delete mode 100644 bin/commands/brief.js delete mode 100644 bin/commands/changed.js delete mode 100644 bin/commands/decisions.js delete mode 100644 bin/commands/execute.js delete mode 100644 bin/commands/fuzzy.js delete mode 100644 bin/commands/next.js delete mode 100644 bin/commands/pick.js delete mode 100644 bin/commands/reload.js delete mode 100644 bin/commands/search.js delete mode 100644 bin/commands/shared.js delete mode 100644 bin/commands/verify.js delete mode 100644 bin/commands/why.js mode change 100755 => 100644 bin/llm-tracker.js delete mode 100644 bin/mcp-context-data.js delete mode 100644 bin/mcp-context.js delete mode 100644 bin/mcp-prompts.js delete mode 100644 bin/mcp-read-tools.js delete mode 100644 bin/mcp-resources.js delete mode 100644 bin/mcp-server.js delete mode 100644 bin/mcp-tools.js delete mode 100644 bin/mcp-utils.js delete mode 100644 bin/mcp-write-tools.js delete mode 100644 bin/workspace-client.js delete mode 100644 hub/blockers.js delete mode 100644 hub/briefs.js delete mode 100644 hub/changed.js delete mode 100644 hub/decisions.js delete mode 100644 hub/error-payload.js delete mode 100644 hub/execute.js delete mode 100644 hub/next.js delete mode 100644 hub/pick.js delete mode 100644 hub/project-loader.js delete mode 100644 hub/references.js delete mode 100644 hub/routes/intelligence.js delete mode 100644 hub/runtime-overlay.js delete mode 100644 hub/runtime.js delete mode 100644 hub/search.js delete mode 100644 hub/snippets.js delete mode 100644 hub/status-vocabulary.js delete mode 100644 hub/task-metadata.js delete mode 100644 hub/verify.js delete mode 100644 hub/why.js delete mode 100644 test/blockers.test.js delete mode 100644 test/brief-cli.test.js delete mode 100644 test/briefs.test.js delete mode 100644 test/changed.test.js delete mode 100644 test/daemon.test.js delete mode 100644 test/decisions.test.js delete mode 100644 test/error-payload.test.js delete mode 100644 test/execute-cli.test.js delete mode 100644 test/execute.test.js delete mode 100644 test/field-limits.test.js delete mode 100644 test/healthz.test.js delete mode 100644 test/helpers/fake-embedder.mjs delete mode 100644 test/ingest-lock.test.js delete mode 100644 test/mcp-tools.test.js delete mode 100644 test/mcp.test.js delete mode 100644 test/next-cli.test.js delete mode 100644 test/next.test.js delete mode 100644 test/pick-cli.test.js delete mode 100644 test/pick.test.js delete mode 100644 test/references.test.js delete mode 100644 test/reload-cli.test.js delete mode 100644 test/restore.test.js delete mode 100644 test/runtime-overlay.test.js delete mode 100644 test/search-cli.test.js delete mode 100644 test/search.test.js delete mode 100644 test/security.test.js delete mode 100644 test/shortcuts-cli.test.js delete mode 100644 test/snippets.test.js delete mode 100644 test/swimlane-move.test.js delete mode 100644 test/task-intel-cli.test.js delete mode 100644 test/tombstones.test.js delete mode 100644 test/ui-intelligence.test.js delete mode 100644 test/verify-cli.test.js delete mode 100644 test/verify.test.js delete mode 100644 test/watcher-scope.test.js delete mode 100644 test/why-decisions-cli.test.js delete mode 100644 test/why.test.js delete mode 100644 ui/favicon.png delete mode 100644 ui/lib/intelligence.js delete mode 100644 ui/modals/history.js delete mode 100644 ui/modals/intelligence.js delete mode 100644 ui/task-outcomes.js diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 3cad999..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Bug report -about: Something isn't working as documented. -title: "" -labels: bug ---- - -## Summary - - - -## Reproduction - -Commands run, expected result, actual result. - -``` -$ llm-tracker ... -``` - -## Environment - -- OS: -- Node version (`node --version`): -- llm-tracker version (`npm list -g llm-tracker` or the CLI's output): - -## Logs - - diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 6463b57..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: Feature request -about: Suggest a capability or change. -title: "" -labels: enhancement ---- - -## Problem - - - -## Proposed solution - -## Alternatives considered - -## Additional context - - diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index f18c6dd..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,24 +0,0 @@ -## Summary - - - -## Motivation - - - -## Test plan - -- [ ] `npm test` passes locally -- [ ] New behavior has a test under `test/` - -## Docs - -- [ ] `README.md` updated if user-facing CLI/HTTP contract changed -- [ ] `workspace-template/README.md` updated if the agent contract (served at `/help`) changed -- [ ] `ARCHITECTURE.md` updated if internals changed materially -- [ ] `MIGRATING.md` updated if the change is breaking - -## Checklist - -- [ ] PR title follows [Conventional Commits](https://www.conventionalcommits.org/) (`feat`, `fix`, `perf`, `revert`, `docs`, `refactor`, `build`, `ci`, `chore`, `test`) -- [ ] I have NOT hand-edited `CHANGELOG.md` (release-please owns it) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0779534..65d9830 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,3 @@ jobs: cache: npm - run: npm ci - run: npm test - - run: npm pack --dry-run - name: Verify package contents diff --git a/.gitignore b/.gitignore index fbe8504..e52ae94 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,3 @@ coverage/ # Project-local scratch initial prompt.txt -.llm-tracker/ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index e54b072..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,43 +0,0 @@ -# AGENTS.md - -When you are working against a running `llm-tracker` hub, fetch `GET /help` first. - -Why: - -- `/help` is the current agent contract for that workspace. -- It serves the workspace `README.md`, which is copied from [`workspace-template/README.md`](./workspace-template/README.md). -- It is the endpoint LLMs should use instead of guessing paths, write modes, or status vocabulary. - -If you change any agent-facing behavior, keep these in sync: - -- `workspace-template/README.md` because `/help` serves it -- `README.md` for human-facing usage and reminders -- `AGENTS.md` when the repo-level workflow changes -- the MCP layer (`llm-tracker mcp`) when the HTTP/CLI contract changes, including tools, resources, and prompts - -For this repo specifically: - -- keep `/help` accurate when you add or change agent-facing endpoints or commands -- prefer additive updates to the contract rather than silent behavior changes -- if MCP is available, prefer `tracker_help`, `tracker_projects_status`, `tracker_project_status`, `tracker_next`, `tracker_search`, `tracker_fuzzy_search`, `tracker_brief`, `tracker_why`, `tracker_decisions`, `tracker_execute`, `tracker_verify`, `tracker_blockers`, `tracker_changed`, `tracker_history`, `tracker_patch`, `tracker_pick`, `tracker_undo`, `tracker_redo`, and `tracker_reload` over raw `curl` -- if MCP resources are available, prefer `tracker://help` for the contract and `tracker://workspace/runtime` for daemon + patch workflow details before rereading the full README -- if MCP prompts are available, start with `tracker_start_here` and use the workflow prompts instead of inventing your own tool order -- remember the MCP daemon rule: read tools work directly from workspace files; write tools (`tracker_patch`, `tracker_pick`, `tracker_undo`, `tracker_redo`, `tracker_reload`) require the hub or daemon to be reachable -- remind agents to use `/help`, `next`, `search` or `fuzzy-search`, `brief`, `why`, `decisions`, `execute`, `verify`, `blockers`, `changed`, and `pick` before they fall back to broad file reads -- if the human wants direct zero-token terminal shortcuts in Codex or Claude, use `llm-tracker shortcuts`; prompt/skill helpers still spend model tokens -- when adding tasks through patch mode, only append genuinely open work; brand-new patch tasks must start as `not_started` or `in_progress`, not `complete` or `deferred` -- if an idea is already folded into an existing owning row, update that row instead of appending a standalone docs/workflow task and retiring it immediately -- treat bare URLs in `reference` / `references[]` as invalid; use repo-relative `path:line` or `path:line-line` -- if `/search` returns a warning, treat that as a degraded local semantic runtime, not as a tracker-data failure; the hub now tries native semantic, then local WASM semantic, then a bundled offline hash semantic runtime, and only then fuzzy fallback -- if `/search` returns `backend: "semantic_hash_fallback"`, continue with the returned matches; that means the local model runtime is unavailable but the bundled offline semantic fallback is still working -- if `/search` returns `backend: "fuzzy_fallback"`, continue with the returned matches or call `fuzzy-search` explicitly instead of blaming tracker data -- if a slug exists on disk but 404s from the hub, retry once first because the hub auto-reloads missing slugs on demand, then prefer `reload` before asking for a daemon restart -- for metadata backfills on existing projects, prefer bounded active tasks before broad roadmap rows, verify with `next` / `brief` / `execute` / `verify` / `search`, and stop before commit or PR refresh unless the human explicitly asked for that step -- do not call a patch that only adds `references[]`, `effort`, `related`, or `comment` a complete migration batch for active work; that is retrieval-only enrichment unless execution-contract fields were intentionally out of scope -- if the human explicitly asks you to relink a shared symlinked project registration to a different branch/worktree file and the slug is already registered, the safe sequence is: verify target -> `DELETE /api/projects/` to remove only the workspace symlink registration -> re-link the slug -> `reload` -> verify a read call before writing patches -- for linked shared-workspace projects, `GET /api/projects/` and successful `POST /api/projects//patch` responses include `file`, the effective tracker JSON path the hub writes; use that instead of guessing whether a linked repo file will get dirtied -- for linked shared-workspace projects, high-churn runtime fields (`status`, `assignee`, `blocker_reason`, `meta.scratchpad`, `updatedAt`, `rev`) now live in `/.runtime/overlays/.json`; durable tracker edits still write through to the linked repo-local file -- for active-task backfills, evaluate the whole author-owned field set, not just the obvious subset: `goal`, `references[]`, `related`, `comment`, `context.tags`, `context.notes`, `context.files_touched`, `blocker_reason`, `effort`, `definition_of_done`, `constraints`, `expected_changes`, `allowed_paths`, and `approval_required_for` -- the migration guide's field order is a sequence, not permission to ignore the rest of the author-owned fields when evidence exists -- for linked repo-local trackers such as `/.llm-tracker/trackers/.json` or `/.phalanx/.json`, keep repo file references portable and relative to the repo root; do not rewrite them into machine-specific absolute paths just to force snippet extraction -- if a valid repo-relative reference is not producing snippets, verify the shared workspace link and use `reload`; treat persistent misses as a resolver/runtime issue to report, not as a reason to bake absolute paths into tracker data diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 878dca0..fc57ccb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,52 +7,17 @@ Deep-dive on the internals. For the marketing hook and installation, see [README ## Pieces ``` -bin/llm-tracker.js CLI entrypoint: init | run | daemon | mcp | status | blockers | changed | search | fuzzy | pick | next | since | rollback | restore | link -bin/mcp-server.js Stdio MCP server wiring for the deterministic `tracker_*` MCP surface -bin/mcp-tools.js MCP tool registry composer -bin/mcp-read-tools.js Workspace-file-backed deterministic MCP read tools -bin/mcp-write-tools.js Hub-backed MCP write tools (`tracker_patch`, `tracker_pick`, `tracker_undo`, `tracker_redo`, `tracker_reload`) -bin/mcp-utils.js Shared MCP result formatting, workspace loads, and hub-mutation helpers -bin/mcp-resources.js MCP resource registry and readers -bin/mcp-prompts.js Thin MCP workflow prompts that point back to deterministic tools -bin/mcp-context-data.js Shared MCP prompt/resource metadata and tool-name lists -bin/workspace-client.js Shared workspace/port resolution + local hub HTTP client -bin/commands/shared.js Shared CLI helpers for hub-backed command modules -bin/commands/blockers.js `llm-tracker blockers` formatter + HTTP client wrapper -bin/commands/changed.js `llm-tracker changed` formatter + HTTP client wrapper -bin/commands/search.js `llm-tracker search` semantic-search formatter + HTTP client wrapper -bin/commands/fuzzy.js `llm-tracker fuzzy` / `fuzzy-search` lexical-search formatter + HTTP client wrapper -bin/commands/pick.js `llm-tracker pick` / `claim` formatter + HTTP client wrapper -bin/commands/next.js `llm-tracker next` formatter + HTTP client wrapper -hub/server.js Express + WebSocket + chokidar wiring + local auth/origin guards + vendor routes -hub/routes/intelligence.js Registers deterministic task-intelligence routes -hub/store.js In-memory state, per-slug lock, applyPatch/applyMove/applyCollapse/rollback/deleteTask/deleteProject/restoreProject/symlinkProject +bin/llm-tracker.js CLI: init | run | status | since | rollback | link +hub/server.js Express + WebSocket + chokidar wiring + vendor routes +hub/store.js In-memory state, per-slug lock, applyPatch/applyMove/applyCollapse/rollback/deleteTask/deleteProject/symlinkProject hub/merge.js Hub-authoritative merge: preserves task order, refuses deletions, keeps collapsed, drops updatedAt/rev hub/versioning.js computeDelta (structured field-level diff), summarize, hasChanges hub/snapshots.js Per-rev .snapshots//.json + .history/.jsonl append-only log -hub/runtime.js Workspace runtime helpers (.runtime/, daemon pid/log metadata) -hub/project-loader.js Direct workspace project/help loader for read-only surfaces outside the hub -hub/task-metadata.js Shared normalized task summary helpers used by deterministic retrieval -hub/blockers.js Structural blocker payload builder -hub/changed.js Changed-task payload builder from append-only history -hub/pick.js Atomic pick/claim selection + response shaping -hub/references.js Shared reference + effort normalization helpers -hub/next.js Deterministic next-task ranking + shortlist payload builder -hub/search.js Semantic `/search` + deterministic `/fuzzy-search` builders -hub/snippets.js Reference parsing + cached snippet extraction under .runtime/ -hub/briefs.js Deterministic task brief pack builder -hub/why.js Deterministic task rationale pack builder -hub/decisions.js Deterministic project decision-memory pack builder -hub/execute.js Deterministic execution pack builder -hub/verify.js Deterministic verification pack builder hub/validator.js Ajv schema + cross-reference checks hub/progress.js Counts, pct, blocked-by derivation hub/status.js Terminal dashboard (used by `llm-tracker status`) ui/index.html Import map + mount ui/app.js Preact + htm components: Matrix, Cell, Card, Drawer, HelpModal, SettingsModal, FilterToggles, Dropdown -ui/modals/history.js Revision-history modal wired to /history and rollback -ui/modals/intelligence.js Project + task intelligence modals for next/blockers/changed/decisions and brief/why/execute/verify -ui/lib/intelligence.js Shared UI helpers for project/task intelligence state and labels ui/styles.css Bloomberg-terminal palette, dark (default) + light theme workspace-template/ Copied on `init` into the workspace folder. README.md is the LLM-facing contract. ``` @@ -68,17 +33,15 @@ workspace-template/ Copied on `init` into the workspace folder. README.md i ├── trackers/.json # canonical project state ├── patches/.*.json # LLM patches (Mode A) — transient ├── templates/default.json # copy to start a new project -├── .runtime/daemon.json # optional background-hub pid/port metadata -├── .runtime/daemon.log # optional background-hub stdout/stderr ├── .snapshots//.json # hub-managed full snapshot per rev (rollback source) -└── .history/.jsonl # append-only event log: change/delete/restore/undo/redo/rollback events +└── .history/.jsonl # append-only event log: {rev, ts, delta, summary} ``` --- ## Schema -Every tracker JSON has two top-level keys: `meta` and `tasks`. The minimal "Day 0" shape the hub accepts is the skeleton in [`workspace-template/templates/default.json`](./workspace-template/templates/default.json) (also inlined in the [README](./README.md)) — everything below is additive context the LLM fills in as work progresses. +Every tracker JSON has two top-level keys: `meta` and `tasks`. ### meta @@ -91,7 +54,6 @@ Every tracker JSON has two top-level keys: `meta` and `tasks`. The minimal "Day | `scratchpad` | string (≤ 5000 chars) | | LLM's status banner to the human. Rendered above the matrix, collapsed by default, editable inline. | | `updatedAt` | ISO string \| null | | **Hub-owned.** | | `rev` | integer \| null | | **Hub-owned.** Monotonic, bumps on every accepted change. | -| `deleted_tasks` | array of task ids \| null | | **Hub-owned.** Tombstones for human-deleted task ids. Incoming writes that try to re-add a listed id are dropped in merge. Rolling back to a rev that predates the deletion clears the tombstone. | **Swimlane object:** @@ -115,23 +77,13 @@ Ordered by array index. Hub owns the order. | `placement` | `{swimlaneId, priorityId}` | ✓ | Both values must exist in `meta`. | | `dependencies` | array of task ids | | Drives **block state** (§Block state). | | `assignee` | string \| null | | LLM's model id when claimed. | -| `reference` | string \| null | | Legacy single source location as `path/to/file.ext:line` or `…:line-line`. | -| `references` | array of strings \| null | | Preferred additive source list. Each entry uses the same `path:line` or `path:line-line` format. | -| `effort` | `xs`\|`s`\|`m`\|`l`\|`xl`\|null | | Optional sizing hint for deterministic ranking and agent planning. | -| `related` | array of task ids \| null | | Optional soft links for future retrieval/search flows. | +| `reference` | string \| null | | Source location as `path/to/file.ext:line` or `…:line-line`. | | `comment` | string \| null (≤ 500 chars) | | One free-form note per task. Rendered as a `[C]` badge with a hover popover. | -| `blocker_reason` | string \| null (≤ 2000 chars) | | One sentence when the LLM is stuck. | -| `definition_of_done` | array of strings \| null | | Optional completion contract for future execution / verify flows. | -| `constraints` | array of strings \| null | | Optional execution guardrails. | -| `expected_changes` | array of strings \| null | | Optional hint about files, modules, or artifacts likely to change. | -| `allowed_paths` | array of strings \| null | | Optional filesystem scope for future execution tooling. | -| `approval_required_for` | array of strings \| null | | Optional approval categories; ranking penalizes these relative to equally-ready work. | +| `blocker_reason` | string \| null | | One sentence when the LLM is stuck. | | `context` | object (freeform) | | Tags, files touched, notes — shallow-merged on patch. | | `updatedAt` | ISO string \| null | | **Hub-owned.** | | `rev` | integer \| null | | **Hub-owned.** | -New writers should prefer `references[]`. Legacy `reference` remains valid for backward compatibility and is normalized alongside `references[]` in ranked `next` responses. - ### Cross-reference rules - Every `task.placement.swimlaneId` must appear in `meta.swimlanes`. @@ -154,12 +106,8 @@ Four values only: | `complete` | Shipped. | | `deferred` | Intentionally parked. Excluded from progress %; LLMs use this in place of deletion. | -Backward compatibility: legacy patch files or tracker files that still use `status: "partial"` are normalized to `in_progress` at the store boundary before validation. Canonical state remains limited to the four values above. - **Progress %** = `round((count(complete) + 0.5 * count(in_progress)) / (total - count(deferred)) * 100)`. -`outcome` is orthogonal to `status`: `partial_slice_landed` marks that a bounded slice shipped while the task remains open. Progress still keys only off the four status values above. - --- ## Block state (derived) @@ -175,118 +123,13 @@ Computed on every accepted write. LLMs don't set it; they influence it via `depe --- -## Deterministic next-task ranking - -`GET /api/projects/:slug/next?limit=5` returns a shortlist for agent decision-making in one call. - -- Response size is capped at 5 tasks. -- The first item is the current recommendation; the rest are ranked alternatives. -- Each item includes readiness, dependency blockers, approval requirements, normalized references, optional effort, freshness (`lastTouchedRev`), and a `reason[]` array explaining the rank. - -Ranking currently favors: - -1. Ready work over blocked work -2. Bounded executable tasks over aggregate roadmap/container rows -3. Active bounded work over starting a fresh bounded task -4. Higher priority lanes (`p0` > `p1` > `p2` > `p3`) -5. Tasks with explicit references or comments -6. Smaller effort where priority is otherwise tied -7. Recently touched tasks as a weak freshness signal - -Aggregate/container rows are detected structurally from roadmap/subtask metadata. They remain in the shortlist as an honest fallback when no finer-grained executable task exists, but they do not block bounded child tasks and they do not outrank them. - -Approval requirements are treated as a penalty, not a hard exclusion, so near-ready work still appears in the shortlist. - -## MCP layer - -`llm-tracker mcp --path ` exposes the same deterministic surfaces as stdio MCP tools, plus read-only resources and thin workflow prompts: - -- `tracker_help` -- `tracker_projects` -- `tracker_projects_status` -- `tracker_project_status` -- `tracker_next` -- `tracker_search` -- `tracker_fuzzy_search` -- `tracker_brief` -- `tracker_why` -- `tracker_decisions` -- `tracker_execute` -- `tracker_verify` -- `tracker_blockers` -- `tracker_changed` -- `tracker_history` -- `tracker_patch` -- `tracker_pick` -- `tracker_undo` -- `tracker_redo` -- `tracker_reload` - -Resources: - -- `tracker://help` -- `tracker://workspace/status` -- `tracker://workspace/runtime` -- `tracker://projects` -- `tracker://projects//status` - -Prompts: - -- `tracker_start_here` -- `tracker_pick_next` -- `tracker_search_project` -- `tracker_task_context` -- `tracker_execute_task` -- `tracker_verify_task` -- `tracker_patch_write` - -Design rule: - -- read tools load the workspace files directly and call the same deterministic payload builders as HTTP/CLI -- resources are preloadable read-only views over the same workspace-file state, including daemon and patch metadata -- prompts are workflow hints only; they must point back to the deterministic tools/resources instead of re-implementing ranking or execution logic -- write tools (`tracker_patch`, `tracker_pick`, `tracker_undo`, `tracker_redo`, `tracker_reload`) go through the running hub so locking, revisioning, and live reconciliation stay authoritative -- `/help`, CLI, and MCP must stay in sync when agent-facing behavior changes - -### Companion surfaces - -- `GET /api/projects/:slug/tasks/:taskId/brief` returns a capped task-context pack: task metadata, dependency summaries, normalized references, extracted snippets, and recent task history. -- `GET /api/projects/:slug/tasks/:taskId/why` returns a capped rationale pack: why the task matters now, what blocks it, what it unblocks, and recent task history. -- `GET /api/projects/:slug/decisions?limit=20` returns recent decision notes derived from task comments in deterministic order. -- `GET /api/projects/:slug/tasks/:taskId/execute` returns the action pack: readiness, explicit contract fields, references, snippets, and recent task history. -- `GET /api/projects/:slug/tasks/:taskId/verify` returns the sign-off pack: deterministic checks plus tracker-backed evidence sources. -- `GET /api/projects/:slug/search?q=` runs semantic local-model search through `@huggingface/transformers` + `Xenova/all-MiniLM-L6-v2`. -- `GET /api/projects/:slug/fuzzy-search?q=` runs deterministic fuzzy lexical matching without embeddings. -- `GET /api/projects/:slug/blockers` returns two deterministic views: blocked tasks and the tasks currently blocking others. -- `GET /api/projects/:slug/changed?fromRev=N&limit=20` returns changed tasks since a rev, with current task state plus grouped change kinds and keys. -- `GET /api/projects/:slug/history?fromRev=N&limit=50` returns the append-only revision log window, including delete/restore/rollback/undo/redo metadata. -- `POST /api/projects/:slug/pick` atomically claims a task under the hub lock. If `taskId` is omitted, the hub selects the top ready task from the deterministic `next` ranking. `POST /api/projects/:slug/claim` is an alias. -- `POST /api/projects/:slug/restore` re-registers a deleted project from the latest snapshot (or an explicit rev) as a fresh audited rev. -- `POST /api/projects/:slug/undo` restores the previous effective project state under the hub lock. -- `POST /api/projects/:slug/redo` reapplies the most recent undo under the hub lock. - -### Brief packs and snippet cache - -- `GET /api/projects/:slug/tasks/:taskId/brief` is the deterministic read path when the agent already knows which task it needs to work on. -- `GET /api/projects/:slug/tasks/:taskId/why` is the deterministic read path when the agent needs the task's rationale rather than its code context. -- `GET /api/projects/:slug/decisions` is the project-level decision-memory surface, backed by current task comments plus history-derived freshness. -- `GET /api/projects/:slug/tasks/:taskId/execute` is the deterministic read path immediately before implementation. -- `GET /api/projects/:slug/tasks/:taskId/verify` is the deterministic read path immediately before sign-off. -- The pack includes task metadata, dependency context, normalized references with `selectedBecause`, up to 5 extracted snippets capped to 8 KB combined text, and up to 3 recent history entries for that task. -- Snippet artifacts are cached under `/.runtime/snippets/.json`. -- For linked repo-local trackers, snippet extraction resolves references relative to the real tracker target, not the shared-workspace symlink path. - ---- - ## Versioning & history Every accepted write: 1. Bumps `meta.rev` (monotonic integer, persisted in the file). 2. Writes a full snapshot to `.snapshots//.json`. -3. Appends an event to `.history/.jsonl`. - -Normal change events carry `{rev, ts, delta, summary}`. Administrative events such as `delete`, `restore`, `undo`, `redo`, and `rollback` add action-specific metadata such as `deletedFromRev`, `restoredFromRev`, `undoOfRev`, `redoOfRev`, `rolledBackFrom`, and `rolledBackTo`. +3. Appends `{rev, ts, delta, summary}` to `.history/.jsonl`. The **delta** is a structured field-level diff: @@ -361,8 +204,6 @@ curl -X POST http://localhost:/api/projects//symlink \ Hub creates `/trackers/.json` as a symlink. Polling on the trackers dir picks up target-file changes. -The linked repo-local file is still part of the same project, but it is **not** a second workspace. File patches still go to `/patches/`, and HTTP writes still target the daemon serving that workspace. - --- ## Updates — two modes @@ -391,8 +232,6 @@ curl -X POST http://localhost:/api/projects//patch \ **Always use `--data-binary @file`, never inline `-d '...'`** — `-d` is curl's form-body mode and strips newlines + can URL-decode special characters, which corrupts JSON bodies. `--data-binary` sends file bytes verbatim. -On success, the response is authoritative immediately: `{ok, rev, updatedAt, file, notes, noop}`. `file` is the effective tracker JSON path the hub wrote, so linked projects expose their repo-local target directly. - --- ## HTTP API reference @@ -402,26 +241,10 @@ On success, the response is authoritative immediately: `{ok, rev, updatedAt, fil | `/api/workspace` | GET | Workspace paths (readme, root). | | `/api/settings` | GET/PUT| Hub config (port). | | `/api/projects` | GET | List of projects with derived counts. | -| `/api/projects/:slug` | GET | Full project state plus effective tracker `file` path. | +| `/api/projects/:slug` | GET | Full project state. | | `/api/projects/:slug` | PUT | Create or replace a project (full body). | -| `/api/projects/:slug` | DELETE | Remove the tracker file and append a `delete` history event. | -| `/api/projects/:slug/next` | GET | Ranked shortlist of the next 1-5 tasks for agent pickup. | -| `/api/projects/:slug/search` | GET | Semantic local-model task search for feature questions. | -| `/api/projects/:slug/fuzzy-search` | GET | Deterministic fuzzy lexical task search. | -| `/api/projects/:slug/tasks/:taskId/brief` | GET | Focused task-context brief pack. | -| `/api/projects/:slug/tasks/:taskId/why` | GET | Focused task-rationale pack. | -| `/api/projects/:slug/tasks/:taskId/execute` | GET | Focused execution pack. | -| `/api/projects/:slug/tasks/:taskId/verify` | GET | Focused verification pack. | -| `/api/projects/:slug/decisions` | GET | Recent decision notes from task comments. | -| `/api/projects/:slug/blockers` | GET | Structural blockers: blocked tasks plus their blockers. | -| `/api/projects/:slug/changed` | GET | Changed tasks since `fromRev`, grouped by task. | -| `/api/projects/:slug/history` | GET | Recent revision-log window with delete/restore/rollback/undo/redo metadata. | -| `/api/projects/:slug/pick` | POST | Atomic task claim. Defaults to the top ready task. | -| `/api/projects/:slug/claim` | POST | Alias for `/pick`. | -| `/api/projects/:slug/restore` | POST | Restore a deleted project from snapshots as a fresh rev. | -| `/api/projects/:slug/undo` | POST | Restore the previous effective project state. | -| `/api/projects/:slug/redo` | POST | Reapply the most recent undo. | -| `/api/projects/:slug/patch` | POST | Partial update merged with existing state; returns authoritative `rev` / `updatedAt` / `file` / `noop`. | +| `/api/projects/:slug` | DELETE | Remove the tracker file. Snapshots/history preserved. | +| `/api/projects/:slug/patch` | POST | Partial update merged with existing state. | | `/api/projects/:slug/move` | POST | UI drag-drop: change task placement + array position. | | `/api/projects/:slug/swimlane-collapse` | POST | Toggle `meta.swimlanes[i].collapsed`. | | `/api/projects/:slug/tasks/:taskId` | DELETE | Remove one task; hub scrubs its id from other deps. | @@ -429,24 +252,11 @@ On success, the response is authoritative immediately: `{ok, rev, updatedAt, fil | `/api/projects/:slug/revisions` | GET | All revs with summaries. | | `/api/projects/:slug/rollback` | POST | Create a new rev with the content of an older rev. | | `/api/projects/:slug/symlink` | POST | Register a project by symlinking an external JSON. | -| `/api/projects/:slug/reload` | POST | Force one tracker file to be reloaded from disk. | -| `/api/reload` | POST | Force all tracker files to be reloaded from disk. | -| `/api/projects/:slug/swimlane-move` | POST | Move one swimlane up or down in `meta.swimlanes`. | | `/api/history/:slug` | GET | Last 50 raw history lines. | -| `/healthz` | GET | Lightweight health probe: `{ok, projects, uptimeSeconds}`. | | `/README.md` | GET | Serve the workspace README. | | `/ws` | WS | WebSocket: SNAPSHOT on connect, UPDATE/ERROR/REMOVE on change. | -All errors return JSON: `{error, type?, hint?}`. Default body limit: 1 MB (override with `LLM_TRACKER_BODY_LIMIT`). - -### Local auth and origin model - -- The hub binds to `127.0.0.1` by default. Override with `LLM_TRACKER_HOST` if you intentionally want a different host bind. -- Mutating requests are rejected with `403` unless they are either origin-less (CLI / MCP / curl), loopback-origin, or exactly same-origin with the host that served the request. -- WebSocket upgrades to `/ws` use the same origin gate, so cross-origin browser tabs cannot subscribe to tracker broadcasts. -- When `LLM_TRACKER_TOKEN` is set, mutating requests must send `Authorization: Bearer ` (or `X-LLM-Tracker-Token`) unless they come from the browser UI's short-lived HttpOnly same-origin session. -- When `LLM_TRACKER_TOKEN` is set, `/ws` also requires that bearer token header (or `X-LLM-Tracker-Token`) unless the upgrade request carries the UI's short-lived same-origin session cookie. -- The raw bearer token is not injected into `index.html`. +All errors return JSON: `{error, type?, hint?}`. Body limit: 16 MB. --- @@ -458,7 +268,6 @@ All errors return JSON: `{error, type?, hint?}`. Default body limit: 1 MB (overr | task `placement.priorityId`, `placement.swimlaneId`| LLM (shared with UI drag; last-write-wins) | | `meta.name`, `meta.priorities`, `meta.swimlanes[].{id,label,description}`, `meta.scratchpad` | LLM | | `meta.swimlanes[].collapsed` | Human (UI) | -| `meta.swimlanes` order | Human (UI) | | Task array order | Hub (structural) | | `meta.updatedAt`, `meta.rev`, `task.updatedAt`, `task.rev` | Hub | @@ -471,38 +280,7 @@ First match wins: 1. `--port N` flag 2. `LLM_TRACKER_PORT` env 3. `/settings.json` → `port` -4. Running daemon metadata in `/.runtime/daemon.json` -5. Default `4400` - ---- - -## Background daemon - -Foreground startup remains the default: `llm-tracker` starts the hub in the current shell. - -Two deployment topologies are supported: - -1. Recommended: one shared workspace, one shared daemon, many projects registered or symlinked into that workspace. -2. Supported alternative: multiple isolated workspaces, each with its own daemon and port. - -In the recommended topology, repo-local tracker files are usually linked in via Option C. The shared daemon remains the source of truth for HTTP, `patches/`, `.runtime/`, and the central UI, while the linked repo-local file is watched for direct edits. - -Linked-tracker writes are still sync, not relocation: durable tracker edits go through the workspace registration and update the linked repo-local JSON in place. High-churn runtime fields now live in `/.runtime/overlays/.json` for linked trackers, so `status` / `assignee` / `blocker_reason` / `meta.scratchpad` / `updatedAt` / `rev` changes no longer need to dirty the repo-visible tracker file. - -Daemon mode is explicit: - -- `llm-tracker --daemon` -- `llm-tracker daemon start` -- `llm-tracker daemon stop` -- `llm-tracker daemon restart` -- `llm-tracker daemon status` -- `llm-tracker daemon logs --lines N` - -Daemon runtime files live under `/.runtime/`. Existing workspaces do not need manual migration; the directory is created lazily on first daemon start. - -Hub-backed CLI commands (`brief`, `why`, `decisions`, `execute`, `verify`, `next`, `blockers`, `changed`, `reload`, `pick`, `since`, `rollback`, `link`) automatically reuse the recorded daemon port when no explicit `--port`, env override, or settings value is present. - -Daemon lifecycle is workspace-scoped. `llm-tracker daemon stop --path ` only targets the daemon registered for that workspace. +4. Default `4400` --- @@ -522,38 +300,13 @@ First match wins: `atomicWriteJson(file, data)` writes to a temp file and `rename`s it into place. A naive `renameSync(tmp, file)` on top of a symlink **replaces the symlink with a regular file** — killing Option C's whole point. The hub resolves the symlink first with `realpathSync` and renames onto the real target, so the symlink survives any number of writes and the rev stamps flow through to the original file in the user's repo. -### Scoped file watching - -Chokidar uses native fsevents on macOS and `inotify` on Linux, which subscribes to directory events. The hub uses two watchers to keep CPU cost bounded: - -- **Main watcher** on `trackers/` with native events (no polling), `depth: 0`, and an `ignored` pattern that excludes `node_modules`, `.git`, `.llm-tracker`, `.runtime`, `.snapshots`, and `.history` in case the workspace lives near any of those. -- **Linked-targets watcher** — a dedicated polling watcher that only watches the specific absolute paths of linked symlink targets (Option C). When `trackers/.json` is a symlink pointing outside the workspace, the hub resolves the target with `realpathSync` and adds only that file to the polling watcher (`interval: 300`). Writes to the target file fire in its own directory — which the `trackers/` native watcher cannot see — but the per-target poller catches them without polling the whole repo the target happens to live in. - -This means the hub never polls directories it does not own and never walks into `node_modules` or `.git` trees, even when an agent symlinks a tracker from deep inside a repo. - -To reduce reliance on watcher timing, the hub also: - -- rescans all tracker files on startup before serving requests -- eagerly ingests a tracker immediately after `POST /api/projects/:slug/symlink` -- auto-reloads a missing slug from disk when a slug route is hit -- refreshes `/api/projects` from disk before returning the list -- exposes `POST /api/projects/:slug/reload` and `POST /api/reload` so operators and agents can force reconciliation without restarting - -### Stale daemon recovery - -If a workspace still has `.runtime/daemon.json` but the recorded port no longer answers, the daemon is wedged or stale rather than healthy. Recovery should stay on the same workspace: - -1. Inspect `.runtime/daemon.log` or `llm-tracker daemon logs`. -2. Stop the recorded PID. -3. Re-run `llm-tracker daemon status`. -4. If the PID is gone but metadata remains, remove the stale `.runtime/daemon.json`. -5. Restart the daemon on that workspace. +### Polling-based file watching -Creating a second accidental workspace is the wrong fix because it forks the source of truth. +Chokidar uses native fsevents on macOS, which subscribes to directory events. When a tracker file in `trackers/` is a symlink to a file in a **different directory** (Option C), writes to the target file fire OS events in *that* other directory — which fsevents on the trackers dir does not see. The hub therefore runs chokidar with `usePolling: true, interval: 300`: `stat()` every ~300 ms picks up changes to target files regardless of where they live. For a local tool watching a handful of files, polling overhead is negligible. ### Body parsing & error format -Express JSON body limit is **1 MB** by default and can be overridden with `LLM_TRACKER_BODY_LIMIT`. Route-level guards reject oversized `meta.scratchpad` (> 5000 chars), `task.comment` (> 500 chars), and `task.blocker_reason` (> 2000 chars) before merge. Any body-parser failure (too-large, malformed JSON) is handled by a custom error middleware that returns `{error, type, hint}` instead of Express's default HTML error page — so LLMs always get a machine-readable response. +Express JSON body limit is **16 MB**. Any body-parser failure (too-large, malformed JSON) is handled by a custom error middleware that returns `{error, type, hint}` instead of Express's default HTML error page — so LLMs always get a machine-readable response. ### Cold-start resume @@ -562,7 +315,6 @@ On hub start, each tracker file's `meta.rev` is compared against the snapshot at ### Concurrency - **Per-slug write lock** — `store.withLock(slug, fn)` serializes all mutations to a project: HTTP patches, UI drags, collapses, rollbacks, deletions. Two simultaneous requests to the same slug run sequentially; different slugs run in parallel. -- **File-watcher ingest is also locked** — `store.ingestLocked(filePath, rawContents)` wraps `ingest` in the same per-slug queue, so chokidar events from the main watcher, the linked-targets poller, and the patches watcher all serialize with HTTP-driven writes instead of racing them. - **In-memory-first for structure-changing ops** — `rollback`, `deleteTask`, `applyCollapse` update in-memory state *before* writing the file. The chokidar re-ingest from that write sees matching state and no-ops. This avoids the merge layer (which is designed to protect against LLM accidents) from clobbering legitimate hub-initiated changes. ### What's deliberately NOT implemented diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 1846581..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,11 +0,0 @@ -# Code of Conduct - -This project adopts the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) as its code of conduct. Contributors are expected to follow it in all project spaces. - -## Reporting - -To report behavior that violates this Code of Conduct, please open a private security advisory at , or file a GitHub issue prefixed with `coc:` for non-sensitive concerns. Reports are reviewed by the maintainers. - -## Enforcement - -Maintainers will follow the enforcement guidelines described in the Contributor Covenant. Consequences range from a private warning to a permanent ban from project spaces, proportional to the violation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 0489e53..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,81 +0,0 @@ -# Contributing to llm-tracker - -Thank you for taking the time to contribute. Please read this document before -opening a pull request. - -## Code of conduct - -This project follows the [Contributor Covenant v2.1](CODE_OF_CONDUCT.md). - -## Getting started - -```bash -# Install dependencies (use ci to match CI exactly) -npm ci - -# Run the test suite -npm test - -# Try the CLI -node bin/llm-tracker.js init -node bin/llm-tracker.js status -``` - -Node.js >= 18 is required (matches the `engines` field in `package.json`). - -## Repository layout - -| File / directory | Purpose | -| --- | --- | -| `AGENTS.md` | Rules governing the agent-contract. Read before changing any field shape or route behaviour visible to LLM agents. | -| `ARCHITECTURE.md` | Internals: hub, router, tracker schema, file layout. | -| `workspace-template/README.md` | The living agent contract served at `/help`. | -| `CHANGELOG.md` | Auto-generated by release-please — **do not hand-edit**. | -| `MIGRATING.md` | User-facing migration notes for breaking changes. | - -## Commit format - -Every commit on a PR **must** follow [Conventional Commits](https://www.conventionalcommits.org/). -The `pr-title.yml` workflow enforces this on the PR title using the types below. - -Allowed types: `feat`, `fix`, `perf`, `revert`, `docs`, `refactor`, `build`, -`ci`, `chore`, `test`. - -Subject rules (enforced by the linter): - -- Must start with a **lowercase** letter. -- Must **not** end with a period. - -Examples: - -``` -feat: add project archiving endpoint -fix: reject oversized scratchpad before schema validation -docs: clarify LAN exposure warning in README -``` - -Scopes are optional. Breaking changes go in the footer: -`BREAKING CHANGE: ` or as `feat!:` / `fix!:`. - -## Release flow - -`main` is the default branch. `release-please` monitors merges to `main` and -automatically opens a release PR that bumps the version in `package.json` and -updates `CHANGELOG.md`. Merge the release PR when you want to ship; the -`publish.yml` workflow then tags the commit and runs `npm publish`. - -Because `CHANGELOG.md` is maintained automatically, **do not hand-edit it**. -If you add a user-visible change that deserves a note, the commit message itself -(processed by release-please) is the authoritative source. - -## Pull request expectations - -Before marking a PR ready for review: - -- [ ] `npm test` passes locally with no skipped tests related to your change. -- [ ] New behaviour is covered by a test under `test/`. -- [ ] User-facing changes update `README.md`. If the change affects the data - shape or routes visible to LLM agents, also update - `workspace-template/README.md` and follow the rules in `AGENTS.md`. -- [ ] If you introduce a breaking change, add an entry to `MIGRATING.md`. -- [ ] The PR title follows the Conventional Commits format described above. diff --git a/LICENSE b/LICENSE index 0054f31..4de51ca 100644 --- a/LICENSE +++ b/LICENSE @@ -1,159 +1,21 @@ -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - -"License" shall mean the terms and conditions for use, reproduction, and -distribution as defined by Sections 1 through 9 of this document. - -"Licensor" shall mean the copyright owner or entity authorized by the copyright -owner that is granting the License. - -"Legal Entity" shall mean the union of the acting entity and all other entities -that control, are controlled by, or are under common control with that entity. -For the purposes of this definition, "control" means (i) the power, direct or -indirect, to cause the direction or management of such entity, whether by -contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the -outstanding shares, or (iii) beneficial ownership of such entity. - -"You" (or "Your") shall mean an individual or Legal Entity exercising -permissions granted by this License. - -"Source" form shall mean the preferred form for making modifications, including -but not limited to software source code, documentation source, and configuration -files. - -"Object" form shall mean any form resulting from mechanical transformation or -translation of a Source form, including but not limited to compiled object code, -generated documentation, and conversions to other media types. - -"Work" shall mean the work of authorship, whether in Source or Object form, made -available under the License, as indicated by a copyright notice that is included -in or attached to the work (an example is provided in the Appendix below). - -"Derivative Works" shall mean any work, whether in Source or Object form, that -is based on (or derived from) the Work and for which the editorial revisions, -annotations, elaborations, or other modifications represent, as a whole, an -original work of authorship. For the purposes of this License, Derivative Works -shall not include works that remain separable from, or merely link (or bind by -name) to the interfaces of, the Work and Derivative Works thereof. - -"Contribution" shall mean any work of authorship, including the original version -of the Work and any modifications or additions to that Work or Derivative Works -thereof, that is intentionally submitted to Licensor for inclusion in the Work -by the copyright owner or by an individual or Legal Entity authorized to submit -on behalf of the copyright owner. For the purposes of this definition, -"submitted" means any form of electronic, verbal, or written communication sent -to the Licensor or its representatives, including but not limited to -communication on electronic mailing lists, source code control systems, and -issue tracking systems that are managed by, or on behalf of, the Licensor for -the purpose of discussing and improving the Work, but excluding communication -that is conspicuously marked or otherwise designated in writing by the copyright -owner as "Not a Contribution." - -"Contributor" shall mean Licensor and any individual or Legal Entity on behalf -of whom a Contribution has been received by Licensor and subsequently -incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of this -License, each Contributor hereby grants to You a perpetual, worldwide, -non-exclusive, no-charge, royalty-free, irrevocable copyright license to -reproduce, prepare Derivative Works of, publicly display, publicly perform, -sublicense, and distribute the Work and such Derivative Works in Source or -Object form. - -3. Grant of Patent License. Subject to the terms and conditions of this License, -each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, -no-charge, royalty-free, irrevocable (except as stated in this section) patent -license to make, have made, use, offer to sell, sell, import, and otherwise -transfer the Work, where such license applies only to those patent claims -licensable by such Contributor that are necessarily infringed by their -Contribution(s) alone or by combination of their Contribution(s) with the Work -to which such Contribution(s) was submitted. If You institute patent litigation -against any entity (including a cross-claim or counterclaim in a lawsuit) -alleging that the Work or a Contribution incorporated within the Work -constitutes direct or contributory patent infringement, then any patent licenses -granted to You under this License for that Work shall terminate as of the date -such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the Work or -Derivative Works thereof in any medium, with or without modifications, and in -Source or Object form, provided that You meet the following conditions: - -(a) You must give any other recipients of the Work or Derivative Works a copy of -this License; and - -(b) You must cause any modified files to carry prominent notices stating that -You changed the files; and - -(c) You must retain, in the Source form of any Derivative Works that You -distribute, all copyright, patent, trademark, and attribution notices from the -Source form of the Work, excluding those notices that do not pertain to any part -of the Derivative Works; and - -(d) If the Work includes a "NOTICE" text file as part of its distribution, then -any Derivative Works that You distribute must include a readable copy of the -attribution notices contained within such NOTICE file, excluding those notices -that do not pertain to any part of the Derivative Works, in at least one of the -following places: within a NOTICE text file distributed as part of the -Derivative Works; within the Source form or documentation, if provided along -with the Derivative Works; or, within a display generated by the Derivative -Works, if and wherever such third-party notices normally appear. The contents of -the NOTICE file are for informational purposes only and do not modify the -License. You may add Your own attribution notices within Derivative Works that -You distribute, alongside or as an addendum to the NOTICE text from the Work, -provided that such additional attribution notices cannot be construed as -modifying the License. - -You may add Your own copyright statement to Your modifications and may provide -additional or different license terms and conditions for use, reproduction, or -distribution of Your modifications, or for any such Derivative Works as a whole, -provided Your use, reproduction, and distribution of the Work otherwise complies -with the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, any -Contribution intentionally submitted for inclusion in the Work by You to the -Licensor shall be under the terms and conditions of this License, without any -additional terms or conditions. Notwithstanding the above, nothing herein shall -supersede or modify the terms of any separate license agreement you may have -executed with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade names, -trademarks, service marks, or product names of the Licensor, except as required -for reasonable and customary use in describing the origin of the Work and -reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or agreed to in -writing, Licensor provides the Work (and each Contributor provides its -Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -KIND, either express or implied, including, without limitation, any warranties -or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A -PARTICULAR PURPOSE. You are solely responsible for determining the -appropriateness of using or redistributing the Work and assume any risks -associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, whether in -tor t (including negligence), contract, or otherwise, unless required by -applicable law (such as deliberate and grossly negligent acts) or agreed to in -writing, shall any Contributor be liable to You for damages, including any -direct, indirect, special, incidental, or consequential damages of any -character arising as a result of this License or out of the use or inability to -use the Work (including but not limited to damages for loss of goodwill, work -stoppage, computer failure or malfunction, or any and all other commercial -damages or losses), even if such Contributor has been advised of the possibility -of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing the Work or -Derivative Works thereof, You may choose to offer, and charge a fee for, -acceptance of support, warranty, indemnity, or other liability obligations -and/or rights consistent with this License. However, in accepting such -obligations, You may act only on Your own behalf and on Your sole responsibility, -not on behalf of any other Contributor, and only if You agree to indemnify, -defend, and hold each Contributor harmless for any liability incurred by, or -claims asserted against, such Contributor by reason of your accepting any such -warranty or additional liability. - -END OF TERMS AND CONDITIONS +MIT License + +Copyright (c) 2026 justguy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MIGRATING.md b/MIGRATING.md deleted file mode 100644 index fc2436f..0000000 --- a/MIGRATING.md +++ /dev/null @@ -1,393 +0,0 @@ -# Migrating Existing Projects To 0.2.0 - -This guide covers migration from **`v0.1.1` or any pre-`0.2.0` workspace/tracker setup** to the **`0.2.0` contract and feature set**. - -Version boundary in this repo: - -- previous release tag: `v0.1.1` -- current release tag: `v0.2.0` - -## What Actually Requires Migration - -### Code/runtime migration - -If you want these capabilities, you need to run `0.2.0` code: - -- deterministic `next` -- `blockers`, `changed`, `pick` -- `brief`, `why`, `decisions`, `execute`, `verify` -- MCP tools/resources/prompts -- semantic `/search` -- deterministic `/fuzzy-search` -- background daemon lifecycle -- history, undo, redo - -This part requires updating the installed package or running the current checkout, then restarting the daemon on the shared workspace. - -### Data migration - -Existing tracker files from `0.1.x` continue to work. There is **no required schema rewrite** just to keep using the tracker. - -Automatic compatibility in `0.2.0`: - -- legacy `reference` still works -- additive `references[]` is supported for new data -- legacy `status: "partial"` is normalized to `in_progress` -- `.runtime/` is created lazily when daemon mode is used -- existing workspaces do not need manual filesystem migration - -## Recommended Migration Plan - -### Phase 1: Upgrade Runtime - -1. Upgrade the code to `0.2.0`. -2. Restart the shared daemon on the same workspace. -3. Verify the live contract: - - `GET /help` - - `GET /api/projects` - - MCP `tracker://help` -4. Verify the new read surfaces are present: - - `/api/projects/:slug/next` - - `/api/projects/:slug/search` - - `/api/projects/:slug/fuzzy-search` - -If you are running from a repo checkout: - -```bash -node /absolute/path/to/llm-project-tracker/bin/llm-tracker.js daemon restart --path /Users/you/.llm-tracker -``` - -If you are using MCP from a local checkout, register the stdio server in the client config, do not run it manually in a spare shell: - -```json -{ - "command": "node", - "args": [ - "/absolute/path/to/llm-project-tracker/bin/llm-tracker.js", - "mcp", - "--path", - "/Users/you/.llm-tracker" - ] -} -``` - -### Phase 2: Keep Old Data Working As-Is - -Do **not** bulk-rewrite every tracker file just because the new fields exist. - -Old projects can keep running with: - -- `reference` -- no `references[]` -- no `effort` -- no execution-contract fields - -That is valid. The new system will still derive: - -- `ready` -- `blocked_kind` -- `blocking_on` -- `requires_approval` -- `lastTouchedRev` - -### Phase 3: Backfill Only High-Value Tasks - -Backfill metadata only where it creates real value: - -- `in_progress` tasks -- `p0` / `p1` tasks -- blocked tasks with weak context -- tasks humans ask about repeatedly - -Do **not** start with old completed tasks unless they are still referenced. - -## Which Fields To Evaluate - -For active-task migration work, evaluate the **full 0.2.0 author-owned field set** and fill whatever the repo/docs/tracker evidence actually supports. Do not silently stop after the first few convenient fields. - -### Author-owned field families to evaluate for active tasks - -#### Retrieval and explanation - -- `goal` -- `references[]` -- `related` -- `comment` -- `context.tags` -- `context.notes` -- `context.files_touched` - -#### Planning and execution - -- `effort` -- `definition_of_done` -- `constraints` -- `expected_changes` -- `allowed_paths` -- `approval_required_for` - -#### Current blocker clarification - -- `blocker_reason` - -#### State correction when evidence says the tracker is stale - -- `dependencies` -- `status` -- `assignee` - -Do **not** churn operational fields as part of a metadata pass unless the current project state is actually wrong. For most migration batches, this means: - -- enrich retrieval/execution fields aggressively -- update `blocker_reason` when a task is currently blocked and the reason is known -- only touch `dependencies`, `status`, or `assignee` if you are correcting stale tracker reality rather than enriching metadata - -### Derived fields: do not write these in migration patches - -- `ready` -- `blocked_kind` -- `blocking_on` -- `requires_approval` -- `lastTouchedRev` -- `updatedAt` -- `rev` - -## Best Backfill Order - -### Pass 1: retrieval quality - -Backfill: - -- `references[]` -- `comment` -- `related` -- `effort` - -This improves: - -- `next` -- `brief` -- `why` -- `search` -- `fuzzy-search` - -Important: - -- a patch that adds only `references[]`, `effort`, `related`, and `comment` is a **retrieval-only** patch -- do **not** call that a complete migration batch for active work -- for bounded active tasks, the first serious migration batch should usually include both retrieval fields and execution-contract fields when they can be grounded - -### Pass 2: execution quality - -Backfill: - -- `definition_of_done` -- `constraints` -- `expected_changes` -- `allowed_paths` -- `approval_required_for` - -This improves: - -- `execute` -- `verify` - -## Existing Project Backfill Playbook - -Use this when an existing live project is already linked into a shared workspace and you want to enrich the tracker metadata safely without writing into the wrong checkout. - -### Step 1: point the shared workspace at the right file - -If the project is linked from a repo checkout and you want writes to land on a branch worktree rather than the main checkout: - -1. verify the new branch/worktree tracker file exists, is valid, and uses the same slug -2. if the slug is already registered, call `DELETE /api/projects/` to remove only the current workspace symlink registration -3. relink the shared workspace slug to the branch worktree tracker file -4. run `reload ` -5. verify one read call against that slug before writing any patches - -Do not assume the daemon will magically follow a different checkout. The shared workspace tracks the linked file path it was given. - -In this relink flow, deleting the project registration removes the workspace symlink only. It does **not** delete the real tracker file in the repo/worktree. - -### Step 2: backfill bounded active work first - -Start with the highest-value executable tasks: - -- `in_progress` bounded tasks -- then `p0` / `p1` bounded tasks -- then blocked tasks missing context - -Do not start with broad roadmap rows or umbrella program sections unless they are the only available representation of the work. - -For each bounded active task in this batch, inspect every author-owned field family above. In practice, that usually means filling: - -- `references[]` -- `effort` -- `goal` when weak or stale -- `comment` -- `context.tags` -- `context.notes` -- `context.files_touched` -- `blocker_reason` when currently blocked -- `definition_of_done` -- `constraints` -- `expected_changes` -- `allowed_paths` -- `approval_required_for` - -If you only fill retrieval fields, treat the batch as **retrieval-only enrichment**, not as a complete migration pass for that task. - -### Step 3: backfill broad program rows only where they help - -Backfill parent/container rows only when they improve: - -- `next` ranking explanations -- `why` -- blocker explanations -- search recall for frequent human questions - -Program rows should not dominate the migration queue ahead of bounded active tasks. - -### Step 4: defer stale or inactive work - -Backfill remaining open but inactive clusters only if they still matter to: - -- current ranking -- current blockers -- frequent search questions -- active execution context - -Deferred tasks and old completed tasks are not first-pass migration targets unless they are still referenced by active work. - -### Step 5: verify, then stop - -After each batch, verify representative tasks with: - -- `next` -- `brief` -- `execute` -- `verify` -- `search` -- `fuzzy-search` - -Stop and report the result after verification. Do **not** commit or refresh a PR unless the human explicitly asked for that step. - -## Agent Migration Prompt - -Use this with an LLM that already has tracker read access and patch/HTTP write access: - -```text -Migrate tracker metadata for project from the 0.1.x contract to the 0.2.0 contract. - -Goal: -- Improve retrieval, ranking, execution, and verification quality for active work. -- Do not rewrite the whole tracker. -- Work in small patches only. - -Scope: -- Start with in_progress tasks, then p0/p1 not_started tasks, then blocked tasks that need context. -- Skip completed or low-priority tasks unless they are still referenced by active work. - -Write only these author-owned fields when grounded by actual repo/docs/tracker evidence: -- references[] -- effort -- related -- comment -- definition_of_done -- constraints -- expected_changes -- allowed_paths -- approval_required_for - -Do not write these derived or hub-owned fields: -- ready -- blocked_kind -- blocking_on -- requires_approval -- lastTouchedRev -- updatedAt -- rev - -Rules: -- Evaluate the full author-owned field set for active tasks. Do not stop after references/comment unless the remaining fields truly lack grounding. -- The suggested order below is a sequence, not a complete field checklist. Also evaluate `goal`, `context.*`, and `blocker_reason` whenever evidence exists. -- Prefer references[] over legacy reference for new additions. -- Preserve existing reference if present; do not delete it just to modernize. -- For linked repo-local trackers such as `/.llm-tracker/trackers/.json` or `/.phalanx/.json`, keep repo references portable and relative to the repo root. Do not rewrite them into machine-specific absolute paths just to make snippets appear. -- If repo-relative references are not producing snippets, verify the shared workspace link and `reload` the slug. Treat persistent misses as a resolver/runtime problem to report, not as a cue to rewrite paths. -- If uncertain, leave the field empty rather than inventing content. -- Use real file paths and real approval categories only when supported by the code/docs/history. -- Keep each patch small: 3-10 tasks max. -- If the project is linked from a branch worktree, verify the shared workspace link and reload the slug before writing. -- Backfill bounded active tasks before broad roadmap/container rows. -- If a patch only adds `references[]`, `effort`, `related`, or `comment`, describe it as retrieval-only enrichment, not as a complete migration batch. -- For bounded active tasks, include `definition_of_done`, `constraints`, `expected_changes`, `allowed_paths`, and `approval_required_for` whenever they can be grounded from actual evidence. -- Also consider `goal`, `context.tags`, `context.notes`, `context.files_touched`, and `blocker_reason` whenever those are materially incomplete and evidence exists. -- Verify with next/brief/execute/verify/search after each batch. -- Stop after verification unless the human explicitly asked you to commit or refresh a PR. - -Suggested order per task: -1. references[] -2. effort -3. comment -4. definition_of_done -5. constraints -6. expected_changes -7. allowed_paths -8. approval_required_for -``` - -## Example Patch - -```json -{ - "tasks": { - "t-017": { - "references": [ - "src/router.js:40-180", - "docs/parallel-flow.md:10-58" - ], - "effort": "m", - "comment": "Needed before the operator can trust parallel branch routing.", - "definition_of_done": [ - "parallel route flow works end to end", - "tests cover branch and variant selection" - ], - "constraints": [ - "preserve current public route contract" - ], - "expected_changes": [ - "src/router.js", - "test/router.test.js" - ], - "allowed_paths": [ - "src/router.js", - "test/router.test.js" - ], - "approval_required_for": [ - "new dependency" - ] - } - } -} -``` - -## Operational Checklist - -1. Upgrade runtime to `0.2.0`. -2. Restart the shared daemon on the same workspace. -3. Confirm `/help` or `tracker://help` reflects the new contract. -4. Leave existing tracker files alone unless they need high-value metadata. -5. Backfill active tasks first. -6. If the project is linked from a repo worktree, and the slug is already registered, remove the workspace symlink registration, relink the slug to the intended branch file, then run `reload `. -7. Review with: - - `next` - - `changed` - - `brief` - - `execute` - - `verify` - - `search` - - `fuzzy-search` -8. Expand backfill only if the new fields materially improve active work. -9. Stop before commit/PR refresh unless the human asked for it. diff --git a/NOTICE b/NOTICE deleted file mode 100644 index 1279f53..0000000 --- a/NOTICE +++ /dev/null @@ -1,19 +0,0 @@ -llm-tracker -Copyright (c) 2026 justguy - -This product includes software developed by the following third parties: - -- @huggingface/transformers (Apache-2.0) — Copyright Hugging Face -- @huggingface/jinja (Apache-2.0) — Copyright Hugging Face -- @huggingface/tokenizers (Apache-2.0) — Copyright Hugging Face -- @modelcontextprotocol/sdk (MIT) — Copyright Anthropic, PBC -- ajv (MIT) — Copyright Evgeny Poberezkin -- ajv-formats (MIT) — Copyright Evgeny Poberezkin -- chokidar (MIT) — Copyright Paul Miller -- express (MIT) — Copyright TJ Holowaychuk -- htm (Apache-2.0) — Copyright 2018 Google Inc. -- preact (MIT) — Copyright Jason Miller -- ws (MIT) — Copyright Einar Otto Stangvik - -No dependency ships its own NOTICE file; none of the above requires -verbatim NOTICE reproduction under Apache-2.0 §4(d). diff --git a/OPEN_SOURCE_READINESS.md b/OPEN_SOURCE_READINESS.md deleted file mode 100644 index c2d4698..0000000 --- a/OPEN_SOURCE_READINESS.md +++ /dev/null @@ -1,195 +0,0 @@ -# Open-Source Readiness Review - -Pre-publish audit of `llm-tracker`. Originally written 2026-04-16 as a gap -list; this revision (same day) records how each item was addressed so the -audit can be checked off in one read. - -Final snapshot: - -- Tests: **179 passing** (`npm test`, ~37s) -- License: Apache-2.0; `NOTICE` now present and shipped -- CI: Ubuntu × Node 18/20/22 + macOS/Windows smoke + `npm pack --dry-run` -- Community files: `SECURITY.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, - `.github/ISSUE_TEMPLATE/{bug_report,feature_request}.md`, - `.github/pull_request_template.md` -- Architecture diagram in `README.md` (Mermaid) - ---- - -## Blockers — all resolved - -### 1. Repo hygiene — resolved - -Scratch files removed from working tree: `FOLLOW_UP_GAPS*.md`, -`RELEASE_CHECKLIST.md`, `execution_order*`, `features.txt`, -`llm-tracker-demo-and-provisional-pack.md`, `llm-tracker-0.2.0.tgz`. -`.llm-tracker/` added to `.gitignore`. Nothing was `git rm` because none of -these were tracked. - -### 2. `package.json` repo URL — not a blocker - -`git remote -v` is `git@github.com:justguy/llm-tracker.git`; metadata matches -the publish target. - -### 3. `SECURITY.md` — added - -Supported versions, private advisory reporting channel, threat model lifted -from README §Local security, known-limitations list (no rate limit, no audit -log, no CSP, session Map not persisted, no token-rotation invalidation), -response expectations. - -### 4. Version bump — not needed - -Backward-compatibility shims cover the bind-host change and contract -additions; release-please drives versioning on its own cadence. - ---- - -## Should-fix — all resolved or accepted - -### 5. Community health files — resolved - -`CONTRIBUTING.md`, `CODE_OF_CONDUCT.md` (references Contributor Covenant 2.1 -by URL), bug-report / feature-request issue templates, PR template. All link -back to the right canonical sources. - -### 6. CI coverage gaps — partly resolved - -Added `npm pack --dry-run` step to `.github/workflows/ci.yml` so a missing -entry in `package.json:files` fails CI. - -**Open**: no lint step (no ESLint config exists in the repo); no -`npm audit` schedule. Both are post-launch follow-ups, not publication -blockers. - -### 7. `files` array — resolved - -`CHANGELOG.md`, `MIGRATING.md`, `NOTICE` added to `package.json:files`. -Verified via `npm pack --dry-run`. - -### 8. Docs duplication between the two READMEs — resolved (cross-reference) - -Added a "Canonical agent contract" blockquote at the top of -`workspace-template/README.md` and a "For agents" pointer in `README.md`'s -Agent Help section. `AGENTS.md` already named `workspace-template/README.md` -as canonical — no change there. - -Not attempted: aggressive de-duplication. Overlap between the two files is -mostly intentional (one for humans, one for agents); the explicit cross- -references are the lowest-risk way to keep them from drifting. - -### 9. Security posture gaps — partly resolved - -- Security headers on UI shell (`X-Content-Type-Options: nosniff`, - `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, - `Permissions-Policy: interest-cohort=()`). `nosniff` also applied to all - UI assets. -- UI session Map TTL sweep (unref'd interval + `clearInterval` on shutdown) - so long-running hubs don't leak entries. - -**Open (documented in `SECURITY.md`)**: no rate limiting, no structured hub -audit log, no CSP (inline importmap in `ui/index.html` would require -nonce-based CSP — deferred), no token-rotation cookie invalidation, wrong -tokens don't log a distinguishable error. All are post-launch work. - -### 10. Dependency footprint / first-run UX — resolved - -Added a stderr hint (`[llm-tracker] preparing semantic search backend — first -run downloads model weights, this may take a minute`) that fires exactly -once, right before the heavy `@huggingface/transformers` import in -`hub/search.js`. Users no longer wonder why the first `/search` call takes a -minute. - -Not attempted: moving `@huggingface/transformers` to `optionalDependencies`. -The existing `semantic_hash_fallback` path covers runtime failures, but a -fully-missing module would need test work to validate; deferring as a -feature decision. - -### 11. UI accessibility — resolved (minimal pass) - -6 modal roots now have `role="dialog"` + `aria-modal="true"` + `aria-label`; -10 icon-only buttons got `aria-label` across `ui/app.js`, -`ui/modals/history.js`, and `ui/modals/intelligence.js`. - -Not attempted: focus trapping, keyboard handling, or full ARIA live regions. -Those are bigger UX projects. - -### 12. Test fixture duplication — resolved - -`makeWorkspace()` added to `test/fixtures.js`. `test/tombstones.test.js` -migrated as the reference example; other tests left alone to avoid churn. - -### 13. Observability — partly resolved - -Added unauthenticated `GET /healthz` returning `{ok, projects, -uptimeSeconds}`. Two tests cover it, including the "reachable without bearer -token when `LLM_TRACKER_TOKEN` is set" case. - -Not attempted: structured logging or daemon-log rotation. Deferred. - ---- - -## Nice-to-have — done or deliberately out of scope - -### 14. Docker — reverted (does not fit the product) - -Initially added a `Dockerfile` + `.dockerignore`; removed on review. -`llm-tracker` is deliberately local-first — the hub binds loopback, -watches a workspace on the same filesystem as the LLM agents that talk to -it, and ships as an `npx` CLI. Running it in a container forces -`LLM_TRACKER_HOST=0.0.0.0` and a mandatory token just to replicate -functionality that `npx llm-tracker` already provides with better security -defaults. MCP registry submission and Homebrew tap (also §14) remain -reasonable but are external actions, user-owned. - -### 15. Docs — partly resolved - -Architecture-at-a-glance Mermaid diagram added to `README.md` between the -pitch and "Why use llm-tracker?". - -Not attempted: moving `ARCHITECTURE.md` / `MIGRATING.md` into a `docs/` -folder (too many cross-references would need updating), standalone tutorial -repo (separate project). - -### 16. Feature opportunities — out of scope - -SSE alongside WebSocket, UI surface for tombstones, per-project ACLs — all -genuine feature work tracked as follow-ups, not readiness concerns. - -### 17. NOTICE file — resolved - -`NOTICE` created at repo root listing bundled third-party software with -licenses. No dependency ships its own NOTICE file (full `find` on -`node_modules` returned zero matches), so no verbatim reproduction under -Apache-2.0 §4(d) is triggered. `NOTICE` added to `package.json:files`. - ---- - -## Suggested pre-publish checklist — final - -- [x] Scratch files + repo URL -- [x] `SECURITY.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, issue/PR templates -- [x] `MIGRATING.md`, `CHANGELOG.md`, `NOTICE` ship in the tarball -- [x] `npm pack --dry-run` step in CI -- [x] Architecture diagram in README -- [x] Security headers + session cleanup -- [x] Healthz endpoint (Dockerfile reverted — see §14) -- [x] UI a11y minimal pass -- [x] First-run download hint -- [x] `npm test` passes (179/179) -- [ ] Run `npm pack && npm install -g ./llm-tracker-*.tgz` on a clean - environment and walk through the quickstart one more time -- [ ] Tag a release (release-please drives the version on next merge to `main`) - ---- - -## Items deliberately deferred (not blocking publication) - -- ESLint / Prettier config and lint step in CI (§6) -- Periodic `npm audit` (§6) -- Rate limiting, structured audit log, CSP, token-rotation invalidation (§9) -- `@huggingface/transformers` → `optionalDependencies` (§10) -- Focus trap + keyboard handlers for modals (§11) -- Daemon-log rotation + structured logging (§13) -- MCP registry submission, Homebrew tap, tutorial repo (§14-15) -- SSE, tombstone UI, per-project ACLs (§16) diff --git a/README.md b/README.md index 0bf4a6c..9b6b896 100644 --- a/README.md +++ b/README.md @@ -4,66 +4,19 @@ [![npm](https://img.shields.io/npm/v/llm-tracker.svg)](https://www.npmjs.com/package/llm-tracker) [![CI](https://github.com/justguy/llm-tracker/actions/workflows/ci.yml/badge.svg)](https://github.com/justguy/llm-tracker/actions/workflows/ci.yml) -[![License: Apache%202.0](https://img.shields.io/badge/License-Apache%202.0-amber.svg)](LICENSE) +[![License: MIT](https://img.shields.io/badge/License-MIT-amber.svg)](LICENSE) Stop forcing your LLMs to re-read and rewrite massive architecture files just to update a status. `llm-tracker` is a **100 % local** mission-control center that bridges the gap between complex agentic workflows and human oversight. It's a file-system-as-database tracker. Your LLMs update project states with tiny HTTP or file-based patches, and the local hub renders a live, **Bloomberg-terminal-style** priority matrix so you can see exactly what your agents are doing at a glance. -When an agent needs to answer "what should I do next?", it can now make one call to `npx llm-tracker next ` or `GET /api/projects//next` and get a ranked shortlist instead of re-reading the full tracker. The ranking prefers bounded actionable work over aggregate roadmap/container rows, and prefers continuing active bounded work over starting a fresh bounded task. - -When a human or agent asks "what about the feature with the..." instead of naming a task id, the hub now exposes both `GET /api/projects//search?q=...` for local embedding-backed semantic search and `GET /api/projects//fuzzy-search?q=...` for deterministic fuzzy lexical matching. The UI keeps the existing exact board filter and adds a separate `[FUZZY]` mode that highlights deterministic fuzzy matches in context. - -When an agent already knows the task id and needs focused context, it can now make one call to `npx llm-tracker brief ` or `GET /api/projects//tasks//brief` and get a capped pack instead of rereading the tracker, docs, and code by hand. - -When an agent needs to answer "why does this task exist?" or "what decisions did we already make?", it can now call `npx llm-tracker why ` or `npx llm-tracker decisions ` instead of reconstructing that from raw comments and history. - -When an agent is ready to act or validate work, it can now call `npx llm-tracker execute ` and `npx llm-tracker verify ` for deterministic execution and verification packs instead of inventing a plan from scratch. - -When an agent needs the current contract for a running hub, it should call `GET /help` first instead of guessing write modes or endpoints. - -The UI now exposes the same deterministic loop for humans: header actions for `[NEXT]`, `[BLOCKERS]`, `[CHANGED]`, and `[DECISIONS]`, hover task actions for `[READ]`, `[WHY]`, `[EXEC]`, and `[VERIFY]`, project-shortlist `[PICK]` actions that jump straight into execution context, a live `/help` contract panel, an exact board filter plus deterministic `[FUZZY]` highlight mode, plus real `[UNDO]`, `[REDO]`, and revision history. - -## Architecture at a glance - -```mermaid -flowchart LR - Browser["Browser (Preact UI)"] - Agent["LLM agent"] - CLI["llm-tracker CLI
(bin/llm-tracker.js)"] - MCP["MCP server
(bin/mcp-server.js)"] - Files["tracker JSON files
(trackers/<slug>.json)"] - Patches["patches/<slug>.*.json"] - Chokidar["chokidar watcher"] - Hub["Hub
(Express + WS)
hub/server.js"] - Store["Store + merge engine
hub/store.js + hub/merge.js"] - Snapshots[".snapshots/ + .history/"] - - Browser -->|"HTTP drag/drop, inline edits"| Hub - Agent -->|"HTTP PATCH / PUT"| Hub - CLI -->|"HTTP"| Hub - Agent -->|"stdio MCP tools"| MCP - MCP -->|"reads workspace files directly;
writes go through hub HTTP"| Hub - Agent -->|"drop patch file"| Patches - Patches -->|"picked up by"| Chokidar - Chokidar -->|"ingest (locked)"| Store - Hub --> Store - Store -->|"atomic write"| Files - Store -->|"snapshot + history append"| Snapshots - Hub -->|"WebSocket broadcast"| Browser -``` - -All write paths converge on the Store under a per-slug lock; the hub is the sole arbiter of merge and revision stamping. See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full contract. - ## Why use llm-tracker? -🔒 **100 % Local & Secure** — the core hub doesn't ping any LLM APIs; it watches your local filesystem, merges patches, and serves the UI. Your project state never leaves your machine. Semantic `/search` uses a local embedding model and only reaches out once if the model is not already cached on disk. - -The hub binds to `127.0.0.1` by default, rejects cross-origin mutating requests, and supports an optional bearer token (`LLM_TRACKER_TOKEN`) that gates every write without exposing the raw secret to the browser UI. See [Local security](#local-security) for the full threat model and override knobs. +🔒 **100 % Local & Secure** — the hub makes zero external calls. It doesn't ping any LLM APIs; it watches your local filesystem, merges patches, and serves the UI. Your project state never leaves your machine. 💸 **Massive Token Savings** — no full-file rewrites. LLMs send surgical JSON patches (often <100 bytes) and pull changes since their last rev, keeping context windows small and API costs low. -⚡ **Stupidly Simple** — no databases, no cloud accounts, and no behavior surprises. `npx llm-tracker init`, `npx llm-tracker`, open the browser. Foreground remains the default; an optional local background daemon is available if you do not want to dedicate a shell. Every project is one JSON file you can `cat`, diff, or commit. +⚡ **Stupidly Simple** — no databases, no daemons, no cloud accounts. `npx llm-tracker init`, `npx llm-tracker`, open the browser. Every project is one JSON file you can `cat`, diff, or commit. --- @@ -94,7 +47,6 @@ The hub binds to `127.0.0.1` by default, rejects cross-origin mutating requests, ```bash npx llm-tracker init # scaffolds ~/.llm-tracker workspace npx llm-tracker # starts hub + UI on http://localhost:4400 -npx llm-tracker --daemon # same hub, but detached into the background ``` The first command prints a paste-ready prompt. Give it to any LLM with file-write or HTTP access, and your project appears in the UI within half a second. @@ -106,72 +58,12 @@ npm install -g llm-tracker llm-tracker init && llm-tracker ``` -### Day 0 project skeleton - -Every tracker is one JSON file with two top-level keys: `meta` and `tasks`. The minimal shape the hub accepts — copied from `workspace-template/templates/default.json` — is: - -```json -{ - "meta": { - "name": "New Project", - "slug": "new-project", - "swimlanes": [ - { "id": "main", "label": "Main" } - ], - "priorities": [ - { "id": "p0", "label": "P0 / Now" }, - { "id": "p1", "label": "P1 / Next" }, - { "id": "p2", "label": "P2 / Soon" }, - { "id": "p3", "label": "P3 / Later" } - ], - "scratchpad": "" - }, - "tasks": [] -} -``` - -Drop this at `/trackers/.json` (with `meta.slug` matching the filename) and the hub renders it immediately. Field-by-field contract → [ARCHITECTURE.md](./ARCHITECTURE.md). - -## Supported Topologies - -`llm-tracker` supports two deployment shapes: - -- Recommended: **one shared workspace + one shared daemon + many linked projects.** Run the hub on a central workspace such as `~/.llm-tracker`, then link repo-local tracker files into it with `npx llm-tracker link `. This gives one source of truth for humans and agents. -- Supported: **multiple isolated workspaces + multiple daemons.** Useful for demos, sandboxes, or teams that want hard isolation. Each daemon needs its own workspace folder and port. - -If you keep a tracker file in a repo and link it into the shared workspace: - -- the shared daemon automatically watches the linked tracker file for direct edits -- the shared daemon automatically watches the shared workspace `patches/` directory -- patch files belong in the shared workspace, not in the repo-local `.llm-tracker/` folder -- durable tracker writes still land on the linked repo-local tracker file itself; that is sync, not relocation -- linked trackers now split high-churn runtime state into the shared workspace overlay at `.runtime/overlays/.json` -- for linked trackers, runtime churn such as task `status`, `assignee`, `blocker_reason`, plus `meta.scratchpad`, `updatedAt`, and `rev` no longer needs to dirty the repo-visible JSON -- durable tracker edits still update the linked repo-local JSON in place, and `GET /api/projects/` / successful patch responses expose that durable path as `file` - -## Agent Help - -> **For agents.** The authoritative contract LLMs should follow is -> [`workspace-template/README.md`](workspace-template/README.md), which is -> served live at `GET /help`. If you are an agent / LLM, read that file (or -> call `/help`) rather than this one. - -Running hubs expose `GET /help` as the current agent contract for that workspace. - -- It serves the workspace `README.md` -- For standard workspaces, that file comes from [`workspace-template/README.md`](./workspace-template/README.md) -- Agents should read `/help` before using write paths or task-intelligence endpoints -- Agents should prefer `next` to choose work, `brief` to load task context, `why` to explain task intent, `decisions` to recall prior decisions, and `execute` / `verify` to close the work loop before broad file reads -- If you change agent-facing behavior, update the workspace template so `/help` stays accurate - --- ## Wire it into your LLM CLI Drop a one-line file into your coding CLI's rules/skills folder. That's the whole install. Next time you ask "what's the state of my projects?" the LLM runs `npx llm-tracker status` on its own. -That path is still prompt-driven. It saves rereads, but it still spends model tokens. - **Claude Code** — create `~/.claude/skills/llm-tracker-status/SKILL.md`: ```markdown @@ -198,224 +90,24 @@ For JSON (chainable): `npx llm-tracker status --json`. Each file just says: *"For project status, run `npx llm-tracker status`."* That's it. -If the hub is already running, add one more line: *"Before using the tracker, read `GET /help`."* - ---- - -## Zero-token terminal shortcut - -If you want direct tracker commands from a Codex or Claude terminal session without asking the model anything, print the shell wrapper once and load it into your shell: - -```bash -eval "$(npx llm-tracker shortcuts)" -``` - -The default short alias for `llm-tracker` is `lt`. That command creates a small `lt` shell function. Use it like: - -```bash -lt next project-phalanx -lt brief project-phalanx t-021 -lt why project-phalanx t-021 -lt execute project-phalanx t-021 -lt verify project-phalanx t-021 -``` - -To keep it permanently, append the printed snippet to `~/.zshrc` or `~/.bashrc`. If you want a different function name, use `npx llm-tracker shortcuts --alias tracker`. - --- ## Shell commands ```bash -# Zero-token shell wrapper for lt next / lt brief / lt verify -npx llm-tracker shortcuts # prints a bash/zsh function wrapper - # Works with hub NOT running — reads files directly npx llm-tracker status # dashboard of all projects npx llm-tracker status # detail on one project npx llm-tracker status --json # machine-readable # Requires hub running -npx llm-tracker help # local CLI usage help -npx llm-tracker blockers # structural blockers and what they are waiting on -npx llm-tracker changed # changed tasks since a rev -npx llm-tracker search # semantic local-model search (requires hub) -npx llm-tracker fuzzy-search # deterministic fuzzy lexical search (requires hub) -npx llm-tracker pick [task-id] --assignee codex # atomic claim, defaults to top ready task -npx llm-tracker next [--limit 5] # ranked shortlist: recommendation + alternatives npx llm-tracker since # event log since a rev (for LLMs to catch up) npx llm-tracker rollback npx llm-tracker link # symlink an external tracker into the workspace -npx llm-tracker reload [] # rescan one or all trackers from disk - -# Optional background lifecycle -npx llm-tracker --daemon # start hub in the background -npx llm-tracker daemon status # show pid / port / log path -npx llm-tracker daemon stop # stop the background hub -npx llm-tracker daemon restart # restart the same background hub -npx llm-tracker daemon logs --lines 80 # print recent daemon logs - -# For LLMs talking to the running hub -curl http://localhost:4400/help # current workspace agent contract ``` Full flag reference: `npx llm-tracker help`. -## HTTP API Quick Reference - -The HTTP hub is the clean machine interface for agent clients. - -Start with: - -```bash -curl http://localhost:4400/help -``` - -That returns the active workspace contract for the running hub. In daemon mode on -another port, use the port recorded in `.runtime/daemon.json` or -`npx llm-tracker daemon status`. - -### Core read endpoints - -```bash -# Workspace / project overview -curl http://localhost:4400/help -curl http://localhost:4400/api/projects -curl http://localhost:4400/api/projects/ -curl "http://localhost:4400/api/projects//next?limit=5" -curl "http://localhost:4400/api/projects//since/" - -# Focused task context -curl http://localhost:4400/api/projects//tasks//brief -curl http://localhost:4400/api/projects//tasks//why -curl http://localhost:4400/api/projects//tasks//execute -curl http://localhost:4400/api/projects//tasks//verify - -# Project-level context -curl "http://localhost:4400/api/projects//decisions?limit=20" -curl http://localhost:4400/api/projects//blockers -curl "http://localhost:4400/api/projects//changed?fromRev=&limit=20" -curl "http://localhost:4400/api/projects//history?limit=50" - -# Search -curl "http://localhost:4400/api/projects//search?q=" -curl "http://localhost:4400/api/projects//fuzzy-search?q=" -``` - -Use `search` for feature-shaped questions and `fuzzy-search` when you want a -deterministic lexical fallback. - -### Core write endpoints - -Use HTTP writes when you want to avoid touching the workspace filesystem. - -```bash -# Atomic claim / pick -curl -X POST http://localhost:4400/api/projects//pick \ - -H "Content-Type: application/json" \ - -d '{"taskId":"","assignee":"codex"}' - -# Fire-and-forget project patch -curl -X POST http://localhost:4400/api/projects//patch \ - -H "Content-Type: application/json" \ - --data-binary @/tmp/-patch.json - -# Revision controls -curl -X POST http://localhost:4400/api/projects//undo -curl -X POST http://localhost:4400/api/projects//redo - -# Reload trackers from disk -curl -X POST http://localhost:4400/api/projects//reload -``` - -Successful `POST /api/projects//patch` responses are authoritative immediately. They now return the accepted post-write `rev`, `updatedAt`, `noop`, and `file` (the effective tracker JSON path the hub wrote, which is the repo-local target for linked projects). - -Patch payloads stay small. Typical shape: - -```json -{ - "tasks": { - "t-001": { - "status": "complete", - "context": { - "notes": "shipped" - } - } - }, - "meta": { - "scratchpad": "t-001 closed; t-002 is next" - } -} -``` - -### Practical rule of thumb - -- Read `/help` first. -- Use `next`, `brief`, `why`, `execute`, and `verify` instead of rereading the whole tracker. -- Use `patch` for status/content updates. -- Use `pick` when you want an atomic claim instead of hand-rolling status changes. -- Keep writes small and frequent. - -### Agent Interfaces - -This project ships a stdio MCP server via `llm-tracker mcp`. - -Use the workspace contract first, then the narrowest interface that fits: - -- read `GET /help` or `tracker_help` first for the active workspace contract -- use `tracker_next`, `tracker_brief`, `tracker_why`, `tracker_decisions`, `tracker_execute`, `tracker_verify`, `tracker_search`, and `tracker_fuzzy_search` for focused reads -- use `tracker_patch`, `tracker_pick`, `tracker_undo`, `tracker_redo`, and `tracker_reload` through the running hub for authoritative writes - -Register the server in your client config instead of launching it manually: - -```toml -[mcp_servers.llm-tracker] -command = "node" -args = [ - "/Users/you/path/to/llm-project-tracker/bin/llm-tracker.js", - "mcp", - "--path", - "/Users/you/.llm-tracker", -] -startup_timeout_sec = 60 -``` - -MCP reads work directly from workspace files. MCP writes still require the shared hub or daemon to be reachable. - -## Background daemon - -Foreground startup is still the default. Existing users can keep using `npx llm-tracker` exactly as before. - -If you want the hub to keep running without a dedicated shell, use `npx llm-tracker --daemon` or `npx llm-tracker daemon start`. Runtime artifacts live under `~/.llm-tracker/.runtime/` by default: - -- `daemon.json` stores pid, port, and startup metadata -- `daemon.log` captures hub stdout and stderr - -Existing workspaces do not need migration work. The `.runtime/` directory is created on demand the first time daemon mode is used. - -Hub-backed CLI commands reuse the active daemon port from `.runtime/daemon.json` when you omit `--port`, so `brief`, `why`, `decisions`, `execute`, `verify`, `next`, `blockers`, `changed`, `pick`, `since`, `rollback`, `link`, and `reload` keep working against a background hub started on a non-default port. - -Daemon state is **workspace-scoped**. `npx llm-tracker daemon stop --path ` only affects the daemon for that workspace. In the recommended shared-daemon topology, that means one daemon for the central workspace and linked repo-local project files underneath it. - -### Daemon troubleshooting - -The hub now rescans tracker files on startup, eagerly loads trackers created through `link`, auto-reloads missing slugs on demand, and refreshes the project list from disk before serving `/api/projects`. In normal use, that should remove most "restart to see the project" failures. - -If a tracker exists on disk but does not appear in the UI or a slug 404s unexpectedly: - -1. If a specific slug 404s, retry the request once. The hub now attempts an on-demand reload for missing slugs. -2. Run `npx llm-tracker reload [] --path ` to rescan one slug or the whole workspace explicitly. -3. If it is a symlinked repo-local tracker, make sure it was registered through `npx llm-tracker link ...` rather than by manual symlink creation. -4. If the daemon itself looks stale, run `npx llm-tracker daemon restart --path `. - -If `daemon stop` times out or hub-backed commands say `Hub not reachable`: - -1. Run `npx llm-tracker daemon status --path ` and `npx llm-tracker daemon logs --path --lines 120`. -2. If the recorded PID still exists but the recorded port does not answer, stop that PID manually. -3. Run `daemon status` again. If the process is gone but `.runtime/daemon.json` is still present, remove the stale metadata file and start the daemon again. - -Do **not** fix this by creating a second accidental workspace in the repo. The correct recovery target is the original workspace. - --- ## The Contract (TL;DR) @@ -425,70 +117,15 @@ Do **not** fix this by creating a second accidental workspace in the repo. The c - **LLM writes** — statuses, assignees, dependencies, notes, tags, priorities, new tasks, scratchpad - **Hub enforces** — task array order, existence (no accidental deletion), human UI state (drag positions, swimlane collapse), version stamps -Two patch-time guardrails now matter for agent reliability: - -- append brand-new patch tasks only as `not_started` or `in_progress`, not already `complete` or `deferred` -- use `reference` / `references[]` only in `path:line` or `path:line-line` form; bare URLs are rejected with an explicit hint - LLMs send small patches. The hub merges them under a per-project lock, bumps `meta.rev`, writes a full snapshot, and appends a `{rev, delta}` line to the history log. When the LLM needs to refresh, it calls `GET /api/projects/:slug/since/` and gets only what changed — **constant-size payload regardless of project size**. -For task pickup, prefer the atomic claim flow over hand-built status patches: - -- `GET /api/projects/:slug/tasks/:taskId/brief` -- `npx llm-tracker brief ` -- `GET /api/projects/:slug/tasks/:taskId/why` -- `npx llm-tracker why ` -- `GET /api/projects/:slug/decisions` -- `npx llm-tracker decisions ` -- `GET /api/projects/:slug/tasks/:taskId/execute` -- `npx llm-tracker execute ` -- `GET /api/projects/:slug/tasks/:taskId/verify` -- `npx llm-tracker verify ` -- `POST /api/projects/:slug/pick` -- `npx llm-tracker pick [task-id] --assignee ` - -For feature-oriented or fuzzy questions, prefer: - -- `GET /api/projects/:slug/search?q=` -- `npx llm-tracker search ` -- `GET /api/projects/:slug/fuzzy-search?q=` -- `npx llm-tracker fuzzy-search ` - -Legacy compatibility: if an older patch or tracker file still uses `status: "partial"`, the hub normalizes it to `in_progress` on ingest and writes back the canonical value. - -`outcome` is separate from `status`: use `partial_slice_landed` when a bounded slice shipped but the task remains open. Progress % still keys only off the four status values above. - -### Semantic search stack - -- Semantic `/search` uses [`@huggingface/transformers`](https://github.com/huggingface/transformers.js) in the local Node.js hub. -- The default embedding model is [`Xenova/all-MiniLM-L6-v2`](https://huggingface.co/Xenova/all-MiniLM-L6-v2). -- Both are Apache-2.0 licensed. -- The first semantic query may download the model into the local Hugging Face cache; after that, query embedding and cosine ranking stay local. -- Semantic `/search` now tries the native Node runtime first, then a local WASM runtime bundled from `onnxruntime-web`, then a bundled offline hash runtime, and only then degrades to deterministic fuzzy matching. -- If semantic has to fall back, the payload returns a warning. If model runtimes are unavailable, `/search` can still return semantic results with `backend: "semantic_hash_fallback"`; only unexpected runtime failures degrade to `backend: "fuzzy_fallback"`. -- `/fuzzy-search` remains the deterministic lexical fallback when you want approximate string matching without loading embeddings. - Two write modes: - **Mode A — File patches** (bash-less): LLM drops a JSON patch in `patches/..json`; hub picks it up and deletes it. - **Mode B — HTTP patches** (fastest): `POST /api/projects/:slug/patch`; one-time `curl` approval in your CLI, then no filesystem access. -Patch failures now return `error`, `type`, and `hint` so agents do not have to reverse-engineer raw schema regex output. The same hint-bearing payload is also written to `.errors.json` files in file-patch mode. - Full schema, merge semantics, field ownership, versioning, rollback, and the whole LLM-facing contract → **[ARCHITECTURE.md](./ARCHITECTURE.md)**. -Migrating an older `0.1.x` workspace or tracker set to the `0.2.0` contract, including agent backfill guidance for the new fields → **[MIGRATING.md](./MIGRATING.md)**. - -That migration guide also covers existing shared-workspace projects linked from repo worktrees: relink the slug to the intended branch file, `reload` it, backfill bounded active tasks first, verify with `execute` / `verify` / `search`, and stop before any commit unless the human asked for one. - -If a linked slug is already registered, the safe relink flow is: remove the current workspace symlink registration, re-link the slug to the new absolute target path, then `reload` it. That unregister step removes only the shared workspace symlink, not the real tracker file in the repo/worktree. - -It now also calls out an easy failure mode: a patch that adds only `references[]`, `effort`, `related`, and `comment` is retrieval-only enrichment, not a complete migration batch for active tasks. - -The migration guidance now also tells agents to evaluate the full author-owned field set for active tasks, including `goal`, `context.*`, and `blocker_reason`, rather than treating only the retrieval and execution-contract subsets as the whole job. - -It also now makes the reference rule explicit: for linked repo-local trackers, keep repo file references portable and repo-relative. If a valid repo-relative reference is not producing snippets, `reload` and treat it as a resolver/runtime problem to report, not as a cue to rewrite the tracker with machine-specific absolute paths. - --- ## How it looks under the hood @@ -511,9 +148,8 @@ It also now makes the reference rule explicit: for linked repo-local trackers, k ```bash npm install -npm test # Node's built-in test runner +npm test # 55 tests (Node's built-in test runner) npm start # hub on http://localhost:4400 -node bin/llm-tracker.js --daemon # optional background hub in dev ``` Module map, internals, and schema deep-dive → **[ARCHITECTURE.md](./ARCHITECTURE.md)**. @@ -547,60 +183,6 @@ Workflow files: [`.github/workflows/release-please.yml`](./.github/workflows/rel --- -## Local security - -The hub is a local service. It ships three layers that matter if another process (or a browser tab on the same machine) tries to talk to it: - -- **Loopback binding** — by default the listener binds to `127.0.0.1`, so other hosts on the LAN cannot reach it. Override with `LLM_TRACKER_HOST=0.0.0.0` if you consciously want LAN access. -- **Cross-origin guard** — every mutating request (`POST` / `PUT` / `PATCH` / `DELETE`) must either have no `Origin` header (trusted CLI / `curl` / MCP context) or carry an origin that exactly matches the hub origin serving that request. Loopback origins are always allowed too. Anything else returns `403`, so browser CSRF stays blocked even if you deliberately expose the hub on a LAN IP. -- **WebSocket guard** — `/ws` uses the same origin policy. Cross-origin browser upgrades are rejected with `403`, so a malicious page cannot subscribe to tracker broadcasts from another site. -- **Optional bearer token** — set `LLM_TRACKER_TOKEN=` before starting the hub and every mutating request must include `Authorization: Bearer ` (or `X-LLM-Tracker-Token: `). The CLI picks the token up from the same env var automatically. The browser UI gets a short-lived HttpOnly same-origin session cookie when it loads `index.html`, so the raw secret is never injected into page JavaScript. -- **WebSocket auth when tokenized** — when `LLM_TRACKER_TOKEN` is set, `/ws` also requires `Authorization: Bearer ` (or `X-LLM-Tracker-Token`) unless the request carries the UI's short-lived same-origin session cookie. - -Body-size hardening: - -- Default JSON body limit is **1 MB** (override with `LLM_TRACKER_BODY_LIMIT`, e.g. `LLM_TRACKER_BODY_LIMIT=4mb`). -- Route-level guards reject oversized `meta.scratchpad` (> 5000 chars), `task.comment` (> 500 chars), and `task.blocker_reason` (> 2000 chars) before merge/validation runs, so hallucinated jumbo patches cost the hub almost nothing. - -## Deleted-project restore - -`DELETE /api/projects/` (or the UI `[DELETE]` button) removes the registered tracker file but preserves `.snapshots//` and `.history/.jsonl` for audit. To bring a deleted project back: - -```bash -# restore latest snapshot -llm-tracker restore - -# restore a specific rev -llm-tracker restore --rev 7 -``` - -Or over HTTP: - -```bash -curl -X POST http://localhost:4400/api/projects//restore \ - -H "Content-Type: application/json" -d '{"rev": 7}' -``` - -`restore` vs `undo` vs `rollback`: - -- `restore` — bring back a project that was fully deleted (`DELETE /api/projects/`). Refuses if the project is still registered, writes a fresh audited rev, and records a `restore` event in `.history/.jsonl`. -- `undo` — reverse the last accepted revision on a **currently registered** project. -- `rollback` — set a registered project back to any prior rev as a new, auditable rev. - -Tombstones — when a human deletes a task (via the UI `[×]` button or `DELETE /api/projects//tasks/`), the id is recorded in `meta.deleted_tasks`. This hub-owned list blocks stale LLM writes from silently resurrecting the deleted task. Rolling back to a rev that predates the deletion clears the tombstone. - -## Windows support - -The hub runs on Windows, but a few paths need extra care: - -- **Symlinking repo-local trackers (`llm-tracker link`, Option C)** — `symlinkSync` requires either Developer Mode (Settings → Privacy & security → For developers) or running the shell as Administrator. If you hit `EPERM` / `EACCES`, fall back to `PUT /api/projects/` (Option B) instead — it has no filesystem-privilege requirements. -- **File watchers** — the main `trackers/` watcher uses native filesystem events. Polling is enabled only for symlinked targets that live outside the workspace, and only on the specific target files — not their parent directories — so CPU cost stays bounded even when a target lives deep inside a repo. -- **Line endings** — tracker JSON is written with `\n` on all platforms. Leave core.autocrlf to your preference in the repo itself; the hub never rewrites linked files for EOL. -- **Paths** — Windows absolute paths (`C:\Users\...`) work everywhere an absolute path is expected. -- **LAN access** — if you override `LLM_TRACKER_HOST`, open the UI through the real host or IP you bound for. Browser writes remain same-origin only; a page loaded from some other site still gets `403`. - -CI currently targets Linux and macOS. Windows-specific behavior is documented but not yet gated by CI — expect the Linux/macOS matrix to be the canonical coverage until a Windows runner lands. - ## License -Apache-2.0 — see [LICENSE](LICENSE). +MIT — see [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 675f585..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,68 +0,0 @@ -# Security Policy - -## Supported versions - -| Version | Supported | -| ------- | --------- | -| 0.2.x | Yes | -| < 0.2 | No | - -## Reporting a vulnerability - -Prefer **GitHub private security advisories**: open one at -. - -If advisories are unavailable in your account, open a regular GitHub issue with -the title prefix `security:` and mark it as a draft until a maintainer contacts -you. Do not include exploit details in the public issue body. - -## Threat model - -`llm-tracker` is designed for **local and trusted-LAN use**, not hostile -multi-tenant environments. Its security surface is bounded accordingly: - -- The hub binds to `127.0.0.1` by default. Other hosts on the LAN cannot reach - it unless you explicitly set `LLM_TRACKER_HOST=0.0.0.0`. -- Every mutating HTTP request (`POST` / `PUT` / `PATCH` / `DELETE`) must carry - an `Origin` that matches the hub's own origin, or no `Origin` at all (CLI / - `curl` / MCP context). Anything else is rejected with `403`, blocking browser - CSRF even when the hub is exposed on a LAN IP. -- WebSocket connections at `/ws` apply the same origin policy; cross-origin - browser upgrades are rejected with `403`. -- When `LLM_TRACKER_TOKEN` is set, every mutating request must present - `Authorization: Bearer ` (or `X-LLM-Tracker-Token: `). The - browser UI receives a short-lived HttpOnly same-origin session cookie so the - raw secret is never injected into page JavaScript. -- When tokenized, `/ws` also requires the bearer token unless the request - carries the UI session cookie. -- JSON request bodies are capped at 1 MB (configurable via - `LLM_TRACKER_BODY_LIMIT`). Per-field limits further bound oversized payloads - before any merge logic runs. - -If you require genuinely multi-tenant or internet-facing deployment you should -place the hub behind a reverse proxy with TLS, network-level access controls, -and additional authentication that matches your threat model. - -## Known limitations - -The following gaps are acknowledged and tracked for future releases: - -- No rate limiting on any endpoint. A local process or browser tab can flood the - hub with requests. -- No structured audit log for hub-level authentication events (token mismatch, - origin rejection). Rejections are returned to clients as `401` / `403` JSON, - but the hub does not currently emit distinct server-side audit records for - them. -- No Content-Security-Policy header on the UI shell (`index.html`). -- The in-memory UI session Map is not persisted; all active browser sessions are - invalidated on hub restart. -- Bearer token rotation does not immediately invalidate existing UI session - cookies; old cookies remain valid until their TTL expires. - -## Response expectations - -We will make a best-effort attempt to acknowledge reports within approximately -seven days. Non-critical issues will be addressed in the next scheduled release. -Critical vulnerabilities (those enabling remote code execution, authentication -bypass, or data exfiltration on default configurations) will be patched as -quickly as possible outside the normal release cadence. diff --git a/bin/commands/blockers.js b/bin/commands/blockers.js deleted file mode 100644 index ed63a1f..0000000 --- a/bin/commands/blockers.js +++ /dev/null @@ -1,73 +0,0 @@ -import { ensureHubResponse } from "./shared.js"; - -function formatBlockedTask(task, index) { - const lines = []; - lines.push(` ${index + 1}. ${task.id} ${task.priorityId || "p?"} ${task.swimlaneId || "?"} blocked`); - lines.push(` ${task.title}`); - lines.push(` blocking: ${task.blocking_on.join(", ")}`); - if (task.blocking_task_details?.length > 0) { - lines.push( - ` blocked by: ${task.blocking_task_details.map((dep) => `${dep.id} (${dep.status})`).join("; ")}` - ); - } - if (task.blocker_reason) lines.push(` blocker: ${task.blocker_reason}`); - if (task.comment) lines.push(` note: ${task.comment}`); - if (task.references?.length > 0) lines.push(` refs: ${task.references.join(" | ")}`); - return lines.join("\n"); -} - -function formatBlockingTask(task, index) { - const lines = []; - lines.push( - ` ${index + 1}. ${task.id} ${task.priorityId || "p?"} ${task.swimlaneId || "?"} blocks ${task.blockedCount}` - ); - lines.push(` ${task.title}`); - if (task.blocks?.length > 0) { - lines.push(` blocks: ${task.blocks.map((blocked) => blocked.id).join(", ")}`); - } - if (task.status) lines.push(` status=${task.status}`); - return lines.join("\n"); -} - -export async function cmdBlockers(args, { resolveWorkspace, httpRequest }) { - const workspace = resolveWorkspace(args.flags.path); - const slug = args._[1]; - if (!slug) { - console.error("Usage: llm-tracker blockers [--json]"); - process.exit(1); - } - - const { status, body } = await httpRequest( - workspace, - args.flags.port, - "GET", - `/api/projects/${slug}/blockers` - ); - ensureHubResponse(status, body, "Blockers"); - - if (args.flags.json) { - console.log(JSON.stringify(body, null, 2)); - return; - } - - console.log(` ${body.project} rev ${body.rev ?? "?"} generated ${body.generatedAt}`); - console.log(` blocked ${body.blocked.length} · blocking ${body.blocking.length}`); - - if (body.blocked.length > 0) { - console.log(" BLOCKED"); - for (const [index, task] of body.blocked.entries()) { - console.log(formatBlockedTask(task, index)); - } - } - - if (body.blocking.length > 0) { - console.log(" BLOCKING"); - for (const [index, task] of body.blocking.entries()) { - console.log(formatBlockingTask(task, index)); - } - } - - if (body.blocked.length === 0 && body.blocking.length === 0) { - console.log(" no structural blockers"); - } -} diff --git a/bin/commands/brief.js b/bin/commands/brief.js deleted file mode 100644 index cfcbd36..0000000 --- a/bin/commands/brief.js +++ /dev/null @@ -1,137 +0,0 @@ -import { ensureHubResponse } from "./shared.js"; - -function indentBlock(text, prefix = " ") { - return (text || "") - .split("\n") - .map((line) => `${prefix}${line}`) - .join("\n"); -} - -function formatTask(task) { - const lines = []; - const readiness = task.ready ? "ready" : task.blocked_kind || "not_ready"; - lines.push(` ${task.id} ${task.priorityId || "p?"} ${task.swimlaneId || "?"} ${readiness}`); - lines.push(` ${task.title}`); - - const extras = []; - if (task.status) extras.push(`status=${task.status}`); - if (task.effort) extras.push(`effort=${task.effort}`); - if (typeof task.lastTouchedRev === "number") extras.push(`lastTouchedRev=${task.lastTouchedRev}`); - if (extras.length > 0) lines.push(` ${extras.join(" · ")}`); - - if (task.goal) lines.push(` goal: ${task.goal}`); - if (task.comment) lines.push(` note: ${task.comment}`); - if (task.blocking_on?.length > 0) lines.push(` blocking: ${task.blocking_on.join(", ")}`); - if (task.requires_approval?.length > 0) lines.push(` approval: ${task.requires_approval.join(", ")}`); - return lines.join("\n"); -} - -function formatLinkedTask(task, index) { - const lines = []; - const readiness = task.ready ? "ready" : task.blocked_kind || "not_ready"; - lines.push(` ${index + 1}. ${task.id} ${task.priorityId || "p?"} ${task.swimlaneId || "?"} ${readiness}`); - lines.push(` ${task.title}`); - if (task.comment) lines.push(` note: ${task.comment}`); - if (task.blocking_on?.length > 0) lines.push(` blocking: ${task.blocking_on.join(", ")}`); - return lines.join("\n"); -} - -function formatReference(reference, index) { - return ` ${index + 1}. ${reference.value}\n why: ${reference.selectedBecause}`; -} - -function formatSnippet(snippet, index) { - const lines = []; - lines.push(` ${index + 1}. ${snippet.reference}`); - lines.push(` why: ${snippet.selectedBecause}`); - if (snippet.error) { - lines.push(` error: ${snippet.error}`); - return lines.join("\n"); - } - if (snippet.hash) lines.push(` hash: ${snippet.hash}`); - lines.push(" text:"); - lines.push(indentBlock(snippet.text || "")); - return lines.join("\n"); -} - -function formatHistory(entry, index) { - const lines = []; - lines.push(` ${index + 1}. rev ${entry.rev} ${entry.ts || "?"}`); - if (entry.changeKinds?.length > 0) lines.push(` kinds: ${entry.changeKinds.join(", ")}`); - if (entry.changedKeys?.length > 0) lines.push(` keys: ${entry.changedKeys.join(", ")}`); - if (entry.summary?.length > 0) { - const summary = entry.summary - .map((item) => (typeof item === "string" ? item : JSON.stringify(item))) - .join("; "); - lines.push(` summary: ${summary}`); - } - return lines.join("\n"); -} - -export async function cmdBrief(args, { resolveWorkspace, httpRequest }) { - const workspace = resolveWorkspace(args.flags.path); - const slug = args._[1]; - const taskId = args._[2]; - if (!slug || !taskId) { - console.error("Usage: llm-tracker brief [--json]"); - process.exit(1); - } - - const { status, body } = await httpRequest( - workspace, - args.flags.port, - "GET", - `/api/projects/${slug}/tasks/${taskId}/brief` - ); - ensureHubResponse(status, body, "Brief"); - - if (args.flags.json) { - console.log(JSON.stringify(body, null, 2)); - return; - } - - console.log(` ${body.project} task ${body.taskId} rev ${body.rev ?? "?"} generated ${body.generatedAt}`); - console.log(formatTask(body.task)); - - if (body.dependencies?.length > 0) { - console.log(" DEPENDENCIES"); - for (const [index, dependency] of body.dependencies.entries()) { - console.log(formatLinkedTask(dependency, index)); - } - } - - if (body.relatedTasks?.length > 0) { - console.log(" RELATED"); - for (const [index, related] of body.relatedTasks.entries()) { - console.log(formatLinkedTask(related, index)); - } - } - - if (body.references?.length > 0) { - console.log(" REFERENCES"); - for (const [index, reference] of body.references.entries()) { - console.log(formatReference(reference, index)); - } - } - - if (body.snippets?.length > 0) { - console.log(" SNIPPETS"); - for (const [index, snippet] of body.snippets.entries()) { - console.log(formatSnippet(snippet, index)); - } - if (body.truncation?.snippets?.applied) { - console.log( - ` snippet budget: ${body.truncation.snippets.returned}/${body.truncation.snippets.totalAvailable} snippets, ${body.truncation.snippets.returnedBytes}/${body.truncation.snippets.maxBytes} bytes` - ); - } - } else { - console.log(" no referenced snippets"); - } - - if (body.recentHistory?.length > 0) { - console.log(" HISTORY"); - for (const [index, entry] of body.recentHistory.entries()) { - console.log(formatHistory(entry, index)); - } - } -} diff --git a/bin/commands/changed.js b/bin/commands/changed.js deleted file mode 100644 index d46c8b2..0000000 --- a/bin/commands/changed.js +++ /dev/null @@ -1,55 +0,0 @@ -import { ensureHubResponse, parseLimit } from "./shared.js"; - -function formatChangedTask(task, index) { - const lines = []; - const state = task.removed ? "removed" : task.ready ? "ready" : task.blocked_kind || "changed"; - lines.push(` ${index + 1}. ${task.id} ${task.priorityId || "p?"} ${task.swimlaneId || "?"} ${state}`); - if (task.title) lines.push(` ${task.title}`); - lines.push(` revs: ${task.changedInRevs.join(", ")}`); - if (task.changeKinds?.length > 0) lines.push(` kinds: ${task.changeKinds.join(", ")}`); - if (task.changedKeys?.length > 0) lines.push(` keys: ${task.changedKeys.join(", ")}`); - if (task.comment) lines.push(` note: ${task.comment}`); - if (task.references?.length > 0) lines.push(` refs: ${task.references.join(" | ")}`); - return lines.join("\n"); -} - -export async function cmdChanged(args, { resolveWorkspace, httpRequest }) { - const workspace = resolveWorkspace(args.flags.path); - const slug = args._[1]; - const fromRev = parseInt(args._[2] ?? "0", 10); - if (!slug || isNaN(fromRev) || fromRev < 0) { - console.error("Usage: llm-tracker changed [] [--json] [--limit N]"); - process.exit(1); - } - - const limit = parseLimit(args.flags.limit, { fallback: 20, max: 50 }); - const { status, body } = await httpRequest( - workspace, - args.flags.port, - "GET", - `/api/projects/${slug}/changed?fromRev=${fromRev}&limit=${limit}` - ); - ensureHubResponse(status, body, "Changed"); - - if (args.flags.json) { - console.log(JSON.stringify(body, null, 2)); - return; - } - - console.log( - ` ${body.project} rev ${body.rev ?? "?"} changed since rev ${body.fromRev} generated ${body.generatedAt}` - ); - if (body.metaChanges?.length > 0) { - console.log(` meta: ${body.metaChanges.map((change) => `${change.key}@${change.lastChangedRev}`).join(", ")}`); - } - if (body.orderChangedRevs?.length > 0) { - console.log(` order changed at revs: ${body.orderChangedRevs.join(", ")}`); - } - if (!body.changed || body.changed.length === 0) { - console.log(" no changes in range"); - return; - } - for (const [index, task] of body.changed.entries()) { - console.log(formatChangedTask(task, index)); - } -} diff --git a/bin/commands/decisions.js b/bin/commands/decisions.js deleted file mode 100644 index 35dfa63..0000000 --- a/bin/commands/decisions.js +++ /dev/null @@ -1,53 +0,0 @@ -import { ensureHubResponse, parseLimit } from "./shared.js"; - -function formatDecision(decision, index) { - const lines = []; - lines.push(` ${index + 1}. ${decision.id} ${decision.priorityId || "p?"} ${decision.swimlaneId || "?"}`); - lines.push(` ${decision.title}`); - lines.push(` decision: ${decision.comment}`); - - const extras = []; - if (decision.status) extras.push(`status=${decision.status}`); - if (decision.effort) extras.push(`effort=${decision.effort}`); - if (typeof decision.lastDecisionRev === "number") extras.push(`lastDecisionRev=${decision.lastDecisionRev}`); - if (typeof decision.lastTouchedRev === "number") extras.push(`lastTouchedRev=${decision.lastTouchedRev}`); - if (extras.length > 0) lines.push(` ${extras.join(" · ")}`); - - if (decision.references?.length > 0) { - lines.push(` refs: ${decision.references.map((reference) => reference.value).join(" | ")}`); - } - - return lines.join("\n"); -} - -export async function cmdDecisions(args, { resolveWorkspace, httpRequest }) { - const workspace = resolveWorkspace(args.flags.path); - const slug = args._[1]; - if (!slug) { - console.error("Usage: llm-tracker decisions [--json] [--limit N]"); - process.exit(1); - } - - const limit = parseLimit(args.flags.limit, { fallback: 20, min: 1, max: 20 }); - const { status, body } = await httpRequest( - workspace, - args.flags.port, - "GET", - `/api/projects/${slug}/decisions?limit=${limit}` - ); - ensureHubResponse(status, body, "Decisions"); - - if (args.flags.json) { - console.log(JSON.stringify(body, null, 2)); - return; - } - - console.log(` ${body.project} rev ${body.rev ?? "?"} generated ${body.generatedAt}`); - console.log(` decisions ${body.decisions.length}`); - for (const [index, decision] of body.decisions.entries()) { - console.log(formatDecision(decision, index)); - } - if (body.decisions.length === 0) { - console.log(" no decisions recorded"); - } -} diff --git a/bin/commands/execute.js b/bin/commands/execute.js deleted file mode 100644 index f022318..0000000 --- a/bin/commands/execute.js +++ /dev/null @@ -1,91 +0,0 @@ -import { ensureHubResponse } from "./shared.js"; - -function formatTask(task) { - const lines = []; - const readiness = task.ready ? "ready" : task.blocked_kind || "not_ready"; - lines.push(` ${task.id} ${task.priorityId || "p?"} ${task.swimlaneId || "?"} ${readiness}`); - lines.push(` ${task.title}`); - if (task.goal) lines.push(` goal: ${task.goal}`); - if (task.comment) lines.push(` note: ${task.comment}`); - return lines.join("\n"); -} - -function formatListItem(item, index, prefix = " ") { - return `${prefix}${index + 1}. ${item}`; -} - -function formatPlanItem(item, index) { - return ` ${index + 1}. ${item.text}\n kind: ${item.kind}`; -} - -function formatReference(reference, index) { - return ` ${index + 1}. ${reference.value}\n why: ${reference.selectedBecause}`; -} - -export async function cmdExecute(args, { resolveWorkspace, httpRequest }) { - const workspace = resolveWorkspace(args.flags.path); - const slug = args._[1]; - const taskId = args._[2]; - if (!slug || !taskId) { - console.error("Usage: llm-tracker execute [--json]"); - process.exit(1); - } - - const { status, body } = await httpRequest( - workspace, - args.flags.port, - "GET", - `/api/projects/${slug}/tasks/${taskId}/execute` - ); - ensureHubResponse(status, body, "Execute"); - - if (args.flags.json) { - console.log(JSON.stringify(body, null, 2)); - return; - } - - console.log(` ${body.project} task ${body.taskId} rev ${body.rev ?? "?"} generated ${body.generatedAt}`); - console.log(formatTask(body.task)); - - if (body.executionPlan?.length > 0) { - console.log(" EXECUTION PLAN"); - for (const [index, item] of body.executionPlan.entries()) { - console.log(formatPlanItem(item, index)); - } - } - - if (body.executionContract?.definition_of_done?.length > 0) { - console.log(" DONE WHEN"); - for (const [index, item] of body.executionContract.definition_of_done.entries()) { - console.log(formatListItem(item, index)); - } - } - - if (body.executionContract?.constraints?.length > 0) { - console.log(" CONSTRAINTS"); - for (const [index, item] of body.executionContract.constraints.entries()) { - console.log(formatListItem(item, index)); - } - } - - if (body.executionContract?.expected_changes?.length > 0) { - console.log(" EXPECTED CHANGES"); - for (const [index, item] of body.executionContract.expected_changes.entries()) { - console.log(formatListItem(item, index)); - } - } - - if (body.executionContract?.allowed_paths?.length > 0) { - console.log(" ALLOWED PATHS"); - for (const [index, item] of body.executionContract.allowed_paths.entries()) { - console.log(formatListItem(item, index)); - } - } - - if (body.references?.length > 0) { - console.log(" REFERENCES"); - for (const [index, reference] of body.references.entries()) { - console.log(formatReference(reference, index)); - } - } -} diff --git a/bin/commands/fuzzy.js b/bin/commands/fuzzy.js deleted file mode 100644 index 778f488..0000000 --- a/bin/commands/fuzzy.js +++ /dev/null @@ -1,12 +0,0 @@ -import { runQueryCommand } from "./shared.js"; - -export async function cmdFuzzy(args, { resolveWorkspace, httpRequest }) { - return runQueryCommand(args, { resolveWorkspace, httpRequest }, { - usage: "llm-tracker fuzzy|fuzzy-search [--json] [--limit N]", - errorLabel: "Fuzzy", - heading: "fuzzy search", - noMatchesLabel: "fuzzy", - includeMatchedOn: true, - pathFor: (slug, query, limit) => `/api/projects/${slug}/fuzzy-search?q=${encodeURIComponent(query)}&limit=${limit}` - }); -} diff --git a/bin/commands/next.js b/bin/commands/next.js deleted file mode 100644 index 900c49d..0000000 --- a/bin/commands/next.js +++ /dev/null @@ -1,56 +0,0 @@ -import { ensureHubResponse, parseLimit } from "./shared.js"; - -function formatTask(task, index) { - const lines = []; - const readiness = task.ready ? "ready" : task.blocked_kind || "not_ready"; - const identity = ` ${index + 1}. ${task.id} ${task.priorityId || "p?"} ${task.swimlaneId || "?"} ${readiness}`; - lines.push(identity); - lines.push(` ${task.title}`); - - const extras = []; - if (task.status) extras.push(`status=${task.status}`); - if (task.effort) extras.push(`effort=${task.effort}`); - if (typeof task.lastTouchedRev === "number") extras.push(`lastTouchedRev=${task.lastTouchedRev}`); - if (extras.length > 0) lines.push(` ${extras.join(" · ")}`); - - if (task.reason?.length > 0) lines.push(` why: ${task.reason.join("; ")}`); - if (task.blocking_on?.length > 0) lines.push(` blocking: ${task.blocking_on.join(", ")}`); - if (task.requires_approval?.length > 0) lines.push(` approval: ${task.requires_approval.join(", ")}`); - if (task.references?.length > 0) lines.push(` refs: ${task.references.join(" | ")}`); - if (task.comment) lines.push(` note: ${task.comment}`); - - return lines.join("\n"); -} - -export async function cmdNext(args, { resolveWorkspace, httpRequest }) { - const workspace = resolveWorkspace(args.flags.path); - const slug = args._[1]; - if (!slug) { - console.error("Usage: llm-tracker next [--json] [--limit N]"); - process.exit(1); - } - - const limit = parseLimit(args.flags.limit); - const { status, body } = await httpRequest( - workspace, - args.flags.port, - "GET", - `/api/projects/${slug}/next?limit=${limit}` - ); - - ensureHubResponse(status, body, "Next"); - - if (args.flags.json) { - console.log(JSON.stringify(body, null, 2)); - return; - } - - console.log(` ${body.project} rev ${body.rev ?? "?"} generated ${body.generatedAt}`); - if (!body.next || body.next.length === 0) { - console.log(" no active tasks"); - return; - } - for (const [index, task] of body.next.entries()) { - console.log(formatTask(task, index)); - } -} diff --git a/bin/commands/pick.js b/bin/commands/pick.js deleted file mode 100644 index daf94f6..0000000 --- a/bin/commands/pick.js +++ /dev/null @@ -1,55 +0,0 @@ -import { ensureHubResponse } from "./shared.js"; - -export async function cmdPick(args, { resolveWorkspace, httpRequest }) { - const workspace = resolveWorkspace(args.flags.path); - const slug = args._[1]; - const taskId = args._[2]; - if (!slug) { - console.error("Usage: llm-tracker pick [] [--assignee ID] [--force] [--json]"); - process.exit(1); - } - - const body = {}; - const assignee = args.flags.assignee || process.env.LLM_TRACKER_ASSIGNEE; - if (taskId) body.taskId = taskId; - if (assignee) body.assignee = assignee; - if (args.flags.force) body.force = true; - if (args.flags.comment !== undefined) body.comment = args.flags.comment; - - const { status, body: response } = await httpRequest( - workspace, - args.flags.port, - "POST", - `/api/projects/${slug}/pick`, - body - ); - ensureHubResponse(status, response, "Pick"); - - if (args.flags.json) { - console.log(JSON.stringify(response, null, 2)); - return; - } - - console.log(` ${response.project} rev ${response.rev ?? "?"} picked ${response.pickedTaskId}`); - console.log( - ` ${response.autoSelected ? "auto-selected" : "explicit"}${response.noop ? " · no changes needed" : ""}` - ); - if (response.selectedBecause) console.log(` why: ${response.selectedBecause}`); - if (response.task?.title) console.log(` ${response.task.title}`); - - const extras = []; - if (response.task?.status) extras.push(`status=${response.task.status}`); - if (response.task?.assignee) extras.push(`assignee=${response.task.assignee}`); - if (response.task?.effort) extras.push(`effort=${response.task.effort}`); - if (extras.length > 0) console.log(` ${extras.join(" · ")}`); - - if (response.task?.blocking_on?.length > 0) { - console.log(` blocking: ${response.task.blocking_on.join(", ")}`); - } - if (response.task?.references?.length > 0) { - console.log(` refs: ${response.task.references.join(" | ")}`); - } - if (response.task?.comment) { - console.log(` note: ${response.task.comment}`); - } -} diff --git a/bin/commands/reload.js b/bin/commands/reload.js deleted file mode 100644 index 46e2c60..0000000 --- a/bin/commands/reload.js +++ /dev/null @@ -1,33 +0,0 @@ -import { ensureHubResponse } from "./shared.js"; - -export async function cmdReload(args, { resolveWorkspace, httpRequest }) { - const workspace = resolveWorkspace(args.flags.path); - const slug = args._[1] || null; - const path = slug ? `/api/projects/${slug}/reload` : "/api/reload"; - - const { status, body } = await httpRequest(workspace, args.flags.port, "POST", path); - ensureHubResponse(status, body, "Reload"); - - if (args.flags.json) { - console.log(JSON.stringify(body, null, 2)); - return; - } - - if (slug) { - console.log(`Reloaded ${slug}.`); - if (typeof body.rev === "number") console.log(` rev: ${body.rev}`); - if (body.noop) console.log(" no file changes detected"); - return; - } - - console.log(`Reloaded ${body.reloaded?.length || 0} projects.`); - for (const item of body.reloaded || []) { - console.log(` ${item.slug} rev ${item.rev ?? "?"}${item.noop ? " noop" : ""}`); - } - if ((body.errors || []).length > 0) { - console.log(" errors:"); - for (const item of body.errors) { - console.log(` ${item.slug || "unknown"}: ${item.message}`); - } - } -} diff --git a/bin/commands/search.js b/bin/commands/search.js deleted file mode 100644 index 9108f09..0000000 --- a/bin/commands/search.js +++ /dev/null @@ -1,12 +0,0 @@ -import { runQueryCommand } from "./shared.js"; - -export async function cmdSearch(args, { resolveWorkspace, httpRequest }) { - return runQueryCommand(args, { resolveWorkspace, httpRequest }, { - usage: "llm-tracker search [--json] [--limit N]", - errorLabel: "Search", - heading: "semantic search", - noMatchesLabel: "semantic", - includeAssignee: true, - pathFor: (slug, query, limit) => `/api/projects/${slug}/search?q=${encodeURIComponent(query)}&limit=${limit}` - }); -} diff --git a/bin/commands/shared.js b/bin/commands/shared.js deleted file mode 100644 index 91a5ff0..0000000 --- a/bin/commands/shared.js +++ /dev/null @@ -1,92 +0,0 @@ -export function parseLimit(value, { fallback = 5, min = 1, max = 5 } = {}) { - const parsed = parseInt(value, 10); - if (isNaN(parsed) || parsed < min) return fallback; - return Math.min(parsed, max); -} - -export function ensureHubResponse(status, body, label) { - if (status === 0) { - console.error("Hub not reachable. Start it with 'llm-tracker' or 'llm-tracker --daemon'."); - process.exit(1); - } - if (status >= 400) { - console.error(`${label} failed (${status}): ${body.error || body.raw}`); - process.exit(1); - } -} - -function formatQueryMatch(match, index, { includeAssignee = false, includeMatchedOn = false } = {}) { - const lines = []; - const readiness = match.ready ? "ready" : match.blocked_kind || "not_ready"; - lines.push( - ` ${index + 1}. ${match.id} ${match.priorityId || "p?"} ${match.swimlaneId || "?"} ${readiness} score=${match.score.toFixed(3)}` - ); - lines.push(` ${match.title}`); - - const extras = []; - if (match.status) extras.push(`status=${match.status}`); - if (match.aggregate) extras.push("aggregate=yes"); - if (includeAssignee && match.assignee) extras.push(`assignee=${match.assignee}`); - if (includeMatchedOn && Array.isArray(match.matchedOn) && match.matchedOn.length > 0) { - extras.push(`matchedOn=${match.matchedOn.join(",")}`); - } - if (extras.length > 0) lines.push(` ${extras.join(" · ")}`); - - if (match.excerpt) lines.push(` excerpt: ${match.excerpt}`); - if (match.references?.length > 0) lines.push(` refs: ${match.references.join(" | ")}`); - - return lines.join("\n"); -} - -export async function runQueryCommand( - args, - { resolveWorkspace, httpRequest }, - { - usage, - errorLabel, - heading, - noMatchesLabel, - includeAssignee = false, - includeMatchedOn = false, - pathFor - } -) { - const workspace = resolveWorkspace(args.flags.path); - const slug = args._[1]; - const query = args._.slice(2).join(" ").trim(); - if (!slug || !query) { - console.error(`Usage: ${usage}`); - process.exit(1); - } - - const limit = parseLimit(args.flags.limit, { fallback: 10, min: 1, max: 50 }); - const { status, body } = await httpRequest( - workspace, - args.flags.port, - "GET", - pathFor(slug, query, limit) - ); - - ensureHubResponse(status, body, errorLabel); - - if (args.flags.json) { - console.log(JSON.stringify(body, null, 2)); - return; - } - - console.log(` ${body.project} rev ${body.rev ?? "?"} ${heading} generated ${body.generatedAt}`); - console.log(` query: ${body.query}`); - if (body.backend && body.backend !== body.mode) { - console.log(` backend: ${body.backend}`); - } - if (body.warning) { - console.log(` warning: ${body.warning}`); - } - if (!body.matches || body.matches.length === 0) { - console.log(` no ${noMatchesLabel} matches`); - return; - } - for (const [index, match] of body.matches.entries()) { - console.log(formatQueryMatch(match, index, { includeAssignee, includeMatchedOn })); - } -} diff --git a/bin/commands/verify.js b/bin/commands/verify.js deleted file mode 100644 index c4885ae..0000000 --- a/bin/commands/verify.js +++ /dev/null @@ -1,68 +0,0 @@ -import { ensureHubResponse } from "./shared.js"; - -function formatTask(task) { - return ` ${task.id} ${task.status}\n ${task.title}`; -} - -function formatCheck(check, index) { - return ` ${index + 1}. ${check.text}\n kind: ${check.kind} · status: ${check.status}\n evidence: ${check.evidenceFrom.join(", ")}`; -} - -function formatReference(reference, index) { - return ` ${index + 1}. ${reference.value}\n why: ${reference.selectedBecause}`; -} - -function formatSnippet(snippet, index) { - const lines = []; - lines.push(` ${index + 1}. ${snippet.reference}`); - lines.push(` why: ${snippet.selectedBecause}`); - if (snippet.error) lines.push(` error: ${snippet.error}`); - return lines.join("\n"); -} - -export async function cmdVerify(args, { resolveWorkspace, httpRequest }) { - const workspace = resolveWorkspace(args.flags.path); - const slug = args._[1]; - const taskId = args._[2]; - if (!slug || !taskId) { - console.error("Usage: llm-tracker verify [--json]"); - process.exit(1); - } - - const { status, body } = await httpRequest( - workspace, - args.flags.port, - "GET", - `/api/projects/${slug}/tasks/${taskId}/verify` - ); - ensureHubResponse(status, body, "Verify"); - - if (args.flags.json) { - console.log(JSON.stringify(body, null, 2)); - return; - } - - console.log(` ${body.project} task ${body.taskId} rev ${body.rev ?? "?"} generated ${body.generatedAt}`); - console.log(formatTask(body.task)); - - if (body.checks?.length > 0) { - console.log(" CHECKS"); - for (const [index, check] of body.checks.entries()) { - console.log(formatCheck(check, index)); - } - } - - if (body.evidenceSources?.references?.length > 0) { - console.log(" REFERENCES"); - for (const [index, reference] of body.evidenceSources.references.entries()) { - console.log(formatReference(reference, index)); - } - } - - if (body.evidenceSources?.snippets?.length > 0) { - console.log(" SNIPPETS"); - for (const [index, snippet] of body.evidenceSources.snippets.entries()) { - console.log(formatSnippet(snippet, index)); - } - } -} diff --git a/bin/commands/why.js b/bin/commands/why.js deleted file mode 100644 index b93e588..0000000 --- a/bin/commands/why.js +++ /dev/null @@ -1,98 +0,0 @@ -import { ensureHubResponse } from "./shared.js"; - -function formatTask(task) { - const lines = []; - const readiness = task.ready ? "ready" : task.blocked_kind || "not_ready"; - lines.push(` ${task.id} ${task.priorityId || "p?"} ${task.swimlaneId || "?"} ${readiness}`); - lines.push(` ${task.title}`); - if (task.goal) lines.push(` goal: ${task.goal}`); - if (task.comment) lines.push(` note: ${task.comment}`); - if (task.blocking_on?.length > 0) lines.push(` blocking: ${task.blocking_on.join(", ")}`); - return lines.join("\n"); -} - -function formatTaskRef(task, index) { - return ` ${index + 1}. ${task.id} ${task.status}\n ${task.title}`; -} - -function formatWhyReason(reason, index) { - return ` ${index + 1}. ${reason.text}\n kind: ${reason.kind}`; -} - -function formatReference(reference, index) { - return ` ${index + 1}. ${reference.value}\n why: ${reference.selectedBecause}`; -} - -function formatHistory(entry, index) { - const lines = []; - lines.push(` ${index + 1}. rev ${entry.rev} ${entry.ts || "?"}`); - if (entry.changedKeys?.length > 0) lines.push(` keys: ${entry.changedKeys.join(", ")}`); - if (entry.summary?.length > 0) { - const summary = entry.summary - .map((item) => (typeof item === "string" ? item : JSON.stringify(item))) - .join("; "); - lines.push(` summary: ${summary}`); - } - return lines.join("\n"); -} - -export async function cmdWhy(args, { resolveWorkspace, httpRequest }) { - const workspace = resolveWorkspace(args.flags.path); - const slug = args._[1]; - const taskId = args._[2]; - if (!slug || !taskId) { - console.error("Usage: llm-tracker why [--json]"); - process.exit(1); - } - - const { status, body } = await httpRequest( - workspace, - args.flags.port, - "GET", - `/api/projects/${slug}/tasks/${taskId}/why` - ); - ensureHubResponse(status, body, "Why"); - - if (args.flags.json) { - console.log(JSON.stringify(body, null, 2)); - return; - } - - console.log(` ${body.project} task ${body.taskId} rev ${body.rev ?? "?"} generated ${body.generatedAt}`); - console.log(formatTask(body.task)); - - if (body.why?.length > 0) { - console.log(" WHY"); - for (const [index, reason] of body.why.entries()) { - console.log(formatWhyReason(reason, index)); - } - } - - if (body.blockedBy?.length > 0) { - console.log(" BLOCKED BY"); - for (const [index, task] of body.blockedBy.entries()) { - console.log(formatTaskRef(task, index)); - } - } - - if (body.unblocks?.length > 0) { - console.log(" UNBLOCKS"); - for (const [index, task] of body.unblocks.entries()) { - console.log(formatTaskRef(task, index)); - } - } - - if (body.references?.length > 0) { - console.log(" REFERENCES"); - for (const [index, reference] of body.references.entries()) { - console.log(formatReference(reference, index)); - } - } - - if (body.recentHistory?.length > 0) { - console.log(" HISTORY"); - for (const [index, entry] of body.recentHistory.entries()) { - console.log(formatHistory(entry, index)); - } - } -} diff --git a/bin/llm-tracker.js b/bin/llm-tracker.js old mode 100755 new mode 100644 index 056c9c4..0c4bc87 --- a/bin/llm-tracker.js +++ b/bin/llm-tracker.js @@ -1,43 +1,32 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; -import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; -import { cmdBlockers } from "./commands/blockers.js"; -import { cmdBrief } from "./commands/brief.js"; -import { cmdChanged } from "./commands/changed.js"; -import { cmdDecisions } from "./commands/decisions.js"; -import { cmdExecute } from "./commands/execute.js"; -import { cmdFuzzy } from "./commands/fuzzy.js"; -import { startMcpServer } from "./mcp-server.js"; -import { cmdNext } from "./commands/next.js"; -import { cmdPick } from "./commands/pick.js"; -import { cmdReload } from "./commands/reload.js"; -import { cmdSearch } from "./commands/search.js"; -import { cmdVerify } from "./commands/verify.js"; -import { cmdWhy } from "./commands/why.js"; -import { DEFAULT_PORT, httpRequest, resolvePort, resolveWorkspace } from "./workspace-client.js"; -import { - daemonLogPath, - ensureRuntimeDir, - getDaemonStatus, - isPidRunning, - removeDaemonMeta, - writeDaemonMeta -} from "../hub/runtime.js"; +import { existsSync, mkdirSync, readdirSync, readFileSync, copyFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { startHub } from "../hub/server.js"; +import { loadProjects, renderDashboard, renderProject, renderJson } from "../hub/status.js"; + +function loadWorkspaceSettings(workspace) { + const file = join(workspace, "settings.json"); + if (!existsSync(file)) return {}; + try { + return JSON.parse(readFileSync(file, "utf-8")); + } catch { + return {}; + } +} -function validLineCount(n) { - const count = parseInt(n, 10); - return !isNaN(count) && count >= 1 && count <= 5000 ? count : null; +function validPort(n) { + const p = parseInt(n, 10); + return !isNaN(p) && p >= 1 && p <= 65535 ? p : null; } const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PKG_ROOT = resolve(__dirname, ".."); -const DAEMON_READY_TIMEOUT_MS = 5000; -const DAEMON_STOP_TIMEOUT_MS = 5000; -const DAEMON_FORCE_STOP_TIMEOUT_MS = 2000; +const DEFAULT_WORKSPACE = join(homedir(), ".llm-tracker"); +const DEFAULT_PORT = 4400; function parseArgs(argv) { const args = { _: [], flags: {} }; @@ -53,6 +42,11 @@ function parseArgs(argv) { return args; } +function resolveWorkspace(flag) { + const fromFlag = flag || process.env.LLM_TRACKER_HOME; + return resolve(fromFlag || DEFAULT_WORKSPACE); +} + function copyTree(src, dst) { mkdirSync(dst, { recursive: true }); for (const entry of readdirSync(src, { withFileTypes: true })) { @@ -67,55 +61,6 @@ function copyTree(src, dst) { } } -function ensureWorkspaceLayout(workspace) { - for (const sub of ["trackers", "patches", ".snapshots", ".history", ".runtime"]) { - mkdirSync(join(workspace, sub), { recursive: true }); - } -} - -function ensureWorkspaceReady(workspace) { - if (!existsSync(workspace)) { - console.error(`No workspace at ${workspace}. Run 'npx llm-tracker init' first.`); - process.exit(1); - } - if (!existsSync(join(workspace, "README.md"))) { - console.error(`Workspace at ${workspace} is missing README.md. Run 'npx llm-tracker init' to repair.`); - process.exit(1); - } - ensureWorkspaceLayout(workspace); -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function readLogTail(file, lines = 40) { - if (!existsSync(file)) return ""; - const text = readFileSync(file, "utf-8"); - const all = text.split("\n"); - if (all.length > 0 && all[all.length - 1] === "") all.pop(); - return all.slice(-lines).join("\n"); -} - -async function waitForDaemonStart(workspace, expectedPid, timeoutMs) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const status = getDaemonStatus(workspace); - if (status.running && status.meta?.pid === expectedPid) return status.meta; - await sleep(100); - } - return null; -} - -async function waitForPidExit(pid, timeoutMs) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!isPidRunning(pid)) return true; - await sleep(100); - } - return !isPidRunning(pid); -} - function cmdInit(args) { const workspace = resolveWorkspace(args.flags.path); const template = join(PKG_ROOT, "workspace-template"); @@ -124,7 +69,9 @@ function cmdInit(args) { console.log(`Workspace already exists at ${workspace}`); } else { copyTree(template, workspace); - ensureWorkspaceLayout(workspace); + for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { + mkdirSync(join(workspace, sub), { recursive: true }); + } console.log(`Initialized workspace at ${workspace}`); } @@ -146,12 +93,37 @@ function cmdInit(args) { console.log(` or POST http://localhost:/api/projects//patch`); console.log(""); console.log("─────────────────────────────────────────────────────────"); - console.log(" Start the hub: npx llm-tracker"); - console.log(" Start in background: npx llm-tracker --daemon"); + console.log(" Start the hub: npx llm-tracker"); console.log(" UI help modal has one-click copy-paste prompts for both modes."); console.log("─────────────────────────────────────────────────────────"); } +async function httpRequest(workspace, method, path, body) { + const settings = loadWorkspaceSettings(workspace); + const port = + validPort(process.env.LLM_TRACKER_PORT) || + validPort(settings.port) || + DEFAULT_PORT; + const url = `http://localhost:${port}${path}`; + try { + const res = await fetch(url, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined + }); + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + json = { raw: text }; + } + return { status: res.status, body: json }; + } catch (e) { + return { status: 0, body: { error: e.message } }; + } +} + async function cmdLink(args) { const workspace = resolveWorkspace(args.flags.path); const slug = args._[1]; @@ -161,13 +133,9 @@ async function cmdLink(args) { process.exit(1); } const abs = resolve(target); - const { status, body } = await httpRequest( - workspace, - args.flags.port, - "POST", - `/api/projects/${slug}/symlink`, - { target: abs } - ); + const { status, body } = await httpRequest(workspace, "POST", `/api/projects/${slug}/symlink`, { + target: abs + }); if (status === 0) { console.error("Hub not reachable. Start it with 'llm-tracker'."); process.exit(1); @@ -179,46 +147,6 @@ async function cmdLink(args) { console.log(`Linked ${slug}:`); console.log(` ${body.linkPath}`); console.log(` → ${body.target}`); - if (body.loaded) { - console.log(` loaded: yes${typeof body.rev === "number" ? ` (rev ${body.rev})` : ""}`); - } -} - -async function cmdRestore(args) { - const workspace = resolveWorkspace(args.flags.path); - const slug = args._[1]; - if (!slug) { - console.error("Usage: llm-tracker restore [--rev ]"); - process.exit(1); - } - const body = {}; - if (args.flags.rev !== undefined) { - const r = parseInt(args.flags.rev, 10); - if (isNaN(r) || r < 1) { - console.error("--rev must be a positive integer"); - process.exit(1); - } - body.rev = r; - } - const { status, body: resp } = await httpRequest( - workspace, - args.flags.port, - "POST", - `/api/projects/${slug}/restore`, - body - ); - if (status === 0) { - console.error("Hub not reachable. Start it with 'llm-tracker'."); - process.exit(1); - } - if (status >= 400) { - console.error(`Restore failed (${status}): ${resp.error || resp.raw}`); - process.exit(1); - } - console.log( - `Restored ${slug} from snapshot rev ${resp.restoredFromRev}. Current rev: ${resp.rev}.` - ); - console.log(` file: ${resp.file}`); } async function cmdRollback(args) { @@ -229,13 +157,9 @@ async function cmdRollback(args) { console.error("Usage: llm-tracker rollback "); process.exit(1); } - const { status, body } = await httpRequest( - workspace, - args.flags.port, - "POST", - `/api/projects/${slug}/rollback`, - { to: rev } - ); + const { status, body } = await httpRequest(workspace, "POST", `/api/projects/${slug}/rollback`, { + to: rev + }); if (status === 0) { console.error("Hub not reachable. Start it with 'llm-tracker'."); process.exit(1); @@ -255,12 +179,7 @@ async function cmdSince(args) { console.error("Usage: llm-tracker since []"); process.exit(1); } - const { status, body } = await httpRequest( - workspace, - args.flags.port, - "GET", - `/api/projects/${slug}/since/${rev}` - ); + const { status, body } = await httpRequest(workspace, "GET", `/api/projects/${slug}/since/${rev}`); if (status === 0) { console.error("Hub not reachable. Start it with 'llm-tracker'."); process.exit(1); @@ -290,13 +209,12 @@ async function cmdSince(args) { } } -async function cmdStatus(args) { +function cmdStatus(args) { const workspace = resolveWorkspace(args.flags.path); if (!existsSync(workspace)) { console.error(`No workspace at ${workspace}. Run 'llm-tracker init' first.`); process.exit(1); } - const { loadProjects, renderDashboard, renderProject, renderJson } = await import("../hub/status.js"); const projects = loadProjects(workspace); if (!projects) { console.error(`No trackers folder at ${workspace}.`); @@ -323,312 +241,53 @@ async function cmdStatus(args) { } } -async function cmdRun(args, { daemonized = false } = {}) { - const workspace = resolveWorkspace(args.flags.path); - const port = resolvePort(workspace, args.flags.port); - - ensureWorkspaceReady(workspace); - const { startHub } = await import("../hub/server.js"); - await startHub({ workspace, port, uiDir: join(PKG_ROOT, "ui") }); - - if (daemonized) { - process.on("exit", () => removeDaemonMeta(workspace)); - writeDaemonMeta(workspace, { - pid: process.pid, - port, - workspace, - startedAt: new Date().toISOString(), - logFile: daemonLogPath(workspace) - }); - } -} - -async function cmdDaemonStart(args) { - const workspace = resolveWorkspace(args.flags.path); - const port = resolvePort(workspace, args.flags.port); - - ensureWorkspaceReady(workspace); - ensureRuntimeDir(workspace); - - const current = getDaemonStatus(workspace); - if (current.running) { - console.error(`Background hub already running for ${workspace}.`); - console.error(` pid: ${current.meta.pid}`); - console.error(` port: ${current.meta.port}`); - console.error(` log: ${current.logFile}`); - process.exit(1); - } - if (current.stale) removeDaemonMeta(workspace); - - const logFile = daemonLogPath(workspace); - writeFileSync(logFile, `\n[${new Date().toISOString()}] starting daemon on :${port}\n`, { flag: "a" }); - const logFd = openSync(logFile, "a"); - - let child; - try { - child = spawn( - process.execPath, - [__filename, "__run-hub", "--path", workspace, "--port", String(port)], - { - cwd: process.cwd(), - detached: true, - stdio: ["ignore", logFd, logFd] - } - ); - } finally { - closeSync(logFd); - } - - let childExit = null; - child.once("exit", (code, signal) => { - childExit = { code, signal }; - }); - - const meta = await waitForDaemonStart(workspace, child.pid, DAEMON_READY_TIMEOUT_MS); - child.unref(); - - if (meta) { - console.log(`Background hub started for ${workspace}.`); - console.log(` pid: ${meta.pid}`); - console.log(` url: http://localhost:${meta.port}`); - console.log(` log: ${meta.logFile}`); - console.log(` stop: llm-tracker daemon stop --path ${workspace}`); - return; - } - - removeDaemonMeta(workspace); - console.error(`Background hub failed to start for ${workspace}.`); - console.error(` log: ${logFile}`); - if (childExit) { - console.error(` child exit: ${childExit.code ?? "null"}${childExit.signal ? ` (${childExit.signal})` : ""}`); - } - const tail = readLogTail(logFile, 20); - if (tail) { - console.error(""); - console.error(tail); - } - process.exit(1); -} - -async function cmdDaemonStop(args) { +async function cmdRun(args) { const workspace = resolveWorkspace(args.flags.path); - const status = getDaemonStatus(workspace); + const wsSettings = loadWorkspaceSettings(workspace); + const port = + validPort(args.flags.port) || + validPort(process.env.LLM_TRACKER_PORT) || + validPort(wsSettings.port) || + DEFAULT_PORT; - if (!status.meta) { - console.log(`No background hub metadata found for ${workspace}.`); - return; - } - - if (status.stale) { - removeDaemonMeta(workspace); - console.log(`Removed stale daemon metadata for ${workspace}.`); - console.log(` log: ${status.logFile}`); - return; - } - - try { - process.kill(status.meta.pid, "SIGTERM"); - } catch (e) { - removeDaemonMeta(workspace); - console.error(`Failed to signal pid ${status.meta.pid}: ${e.message}`); + if (!existsSync(workspace)) { + console.error(`No workspace at ${workspace}. Run 'npx llm-tracker init' first.`); process.exit(1); } - const stopped = await waitForPidExit(status.meta.pid, DAEMON_STOP_TIMEOUT_MS); - if (!stopped) { - try { - process.kill(status.meta.pid, "SIGKILL"); - } catch (e) { - if (!isPidRunning(status.meta.pid)) { - removeDaemonMeta(workspace); - console.log(`Stopped background hub for ${workspace}.`); - console.log(` pid: ${status.meta.pid}`); - return; - } - console.error(`Timed out waiting for pid ${status.meta.pid} to stop.`); - console.error(` log: ${status.logFile}`); - console.error(` force-stop failed: ${e.message}`); - process.exit(1); - } - - const forced = await waitForPidExit(status.meta.pid, DAEMON_FORCE_STOP_TIMEOUT_MS); - if (!forced) { - console.error(`Timed out waiting for pid ${status.meta.pid} to stop after SIGKILL.`); - console.error(` log: ${status.logFile}`); - process.exit(1); - } - - removeDaemonMeta(workspace); - console.log(`Force-stopped background hub for ${workspace}.`); - console.log(` pid: ${status.meta.pid}`); - return; - } - - removeDaemonMeta(workspace); - console.log(`Stopped background hub for ${workspace}.`); - console.log(` pid: ${status.meta.pid}`); -} - -function cmdDaemonStatus(args) { - const workspace = resolveWorkspace(args.flags.path); - const status = getDaemonStatus(workspace); - - if (!status.meta) { - console.log(`Background hub is not running for ${workspace}.`); - console.log(` log: ${status.logFile}`); - return; - } - - if (status.stale) { - removeDaemonMeta(workspace); - console.log(`Background hub metadata was stale for ${workspace} and has been cleared.`); - console.log(` last pid: ${status.meta.pid}`); - console.log(` log: ${status.logFile}`); - return; - } - - console.log(`Background hub is running for ${workspace}.`); - console.log(` pid: ${status.meta.pid}`); - console.log(` port: ${status.meta.port}`); - console.log(` started: ${status.meta.startedAt}`); - console.log(` url: http://localhost:${status.meta.port}`); - console.log(` log: ${status.logFile}`); -} - -function cmdDaemonLogs(args) { - const workspace = resolveWorkspace(args.flags.path); - const lines = validLineCount(args.flags.lines) || 80; - const logFile = daemonLogPath(workspace); - if (!existsSync(logFile)) { - console.error(`No daemon log found at ${logFile}.`); + if (!existsSync(join(workspace, "README.md"))) { + console.error(`Workspace at ${workspace} is missing README.md. Run 'npx llm-tracker init' to repair.`); process.exit(1); } - const tail = readLogTail(logFile, lines); - if (!tail) { - console.log(`No daemon log output yet at ${logFile}.`); - return; - } - console.log(tail); -} - -async function cmdDaemon(args) { - const action = args._[1] || "status"; - if (action === "start") return cmdDaemonStart(args); - if (action === "stop") return cmdDaemonStop(args); - if (action === "restart") { - const workspace = resolveWorkspace(args.flags.path); - const current = getDaemonStatus(workspace); - const restartArgs = { - ...args, - flags: { ...args.flags } - }; - if (restartArgs.flags.port === undefined && current.running && current.meta?.port) { - restartArgs.flags.port = String(current.meta.port); - } - await cmdDaemonStop(args); - return cmdDaemonStart(restartArgs); + for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { + mkdirSync(join(workspace, sub), { recursive: true }); } - if (action === "status") return cmdDaemonStatus(args); - if (action === "logs") return cmdDaemonLogs(args); - console.error(`Unknown daemon action "${action}". Use start, stop, restart, status, or logs.`); - process.exit(1); -} - -function renderShellShortcuts(aliasName = "lt") { - return `# Add to ~/.zshrc or ~/.bashrc, or eval directly: -# eval "$(npx llm-tracker shortcuts)" -# -# Then run zero-token tracker commands such as: -# ${aliasName} next -# ${aliasName} brief -# ${aliasName} verify -__llm_tracker_cli() { - if command -v llm-tracker >/dev/null 2>&1; then - llm-tracker "$@" - else - npx llm-tracker "$@" - fi -} -${aliasName}() { - __llm_tracker_cli "$@" -} -`; -} - -function cmdShortcuts(args) { - const aliasName = String(args.flags.alias || "lt"); - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(aliasName)) { - console.error("Usage: llm-tracker shortcuts [--alias ]"); - console.error(`Invalid alias "${aliasName}". Use letters, numbers, and underscores only.`); - process.exit(1); - } - console.log(renderShellShortcuts(aliasName)); + await startHub({ workspace, port, uiDir: join(PKG_ROOT, "ui") }); } async function main() { const args = parseArgs(process.argv.slice(2)); const cmd = args._[0]; - const daemonFlag = !!(args.flags.daemon || args.flags.background); - - if (cmd === "__run-hub") return cmdRun(args, { daemonized: true }); if (cmd === "init") return cmdInit(args); if (cmd === "status") return cmdStatus(args); - if (cmd === "brief") return cmdBrief(args, { resolveWorkspace, httpRequest }); - if (cmd === "why") return cmdWhy(args, { resolveWorkspace, httpRequest }); - if (cmd === "decisions") return cmdDecisions(args, { resolveWorkspace, httpRequest }); - if (cmd === "execute") return cmdExecute(args, { resolveWorkspace, httpRequest }); - if (cmd === "verify") return cmdVerify(args, { resolveWorkspace, httpRequest }); - if (cmd === "blockers") return cmdBlockers(args, { resolveWorkspace, httpRequest }); - if (cmd === "changed") return cmdChanged(args, { resolveWorkspace, httpRequest }); - if (cmd === "search") return cmdSearch(args, { resolveWorkspace, httpRequest }); - if (cmd === "fuzzy" || cmd === "fuzzy-search") return cmdFuzzy(args, { resolveWorkspace, httpRequest }); - if (cmd === "reload") return cmdReload(args, { resolveWorkspace, httpRequest }); - if (cmd === "pick" || cmd === "claim") return cmdPick(args, { resolveWorkspace, httpRequest }); - if (cmd === "next") return cmdNext(args, { resolveWorkspace, httpRequest }); if (cmd === "rollback") return cmdRollback(args); - if (cmd === "restore") return cmdRestore(args); if (cmd === "since") return cmdSince(args); if (cmd === "link") return cmdLink(args); - if (cmd === "shortcuts") return cmdShortcuts(args); - if (cmd === "mcp") return startMcpServer({ workspace: args.flags.path, portFlag: args.flags.port }); - if (cmd === "daemon") return cmdDaemon(args); if (cmd === "help" || args.flags.help) { console.log(`llm-tracker — file-system-as-database project tracker Usage: - llm-tracker init [--path ] Create a workspace (default ~/.llm-tracker) - llm-tracker [--path ] [--port N] Start the hub in the foreground (default) - llm-tracker [--path ] [--port N] --daemon Start the hub in the background - llm-tracker daemon start [--path ] [--port N] Start the background daemon - llm-tracker daemon stop [--path ] Stop the background daemon - llm-tracker daemon restart [--path ] [--port N] Restart the background daemon - llm-tracker daemon status [--path ] Show daemon status - llm-tracker daemon logs [--path ] [--lines N] Print recent daemon logs - llm-tracker mcp [--path ] [--port N] Start the stdio MCP server - llm-tracker status [] [--json] Print project status to stdout - llm-tracker reload [] [--json] Reload one or all trackers from disk (requires hub) - llm-tracker brief [--json] Print a task brief pack (requires hub) - llm-tracker why [--json] Explain why a task matters now (requires hub) - llm-tracker decisions [--json] [--limit N] Print recent project decisions (requires hub) - llm-tracker execute [--json] Print a deterministic execution pack (requires hub) - llm-tracker verify [--json] Print a deterministic verification pack (requires hub) - llm-tracker blockers [--json] Print structurally blocked tasks (requires hub) - llm-tracker changed [] [--json] Print changed tasks since a rev (requires hub) - llm-tracker search [--json] [--limit N] Semantic task search with local embeddings (requires hub) - llm-tracker fuzzy|fuzzy-search [--json] [--limit N] Fuzzy lexical task search (requires hub) - llm-tracker pick [] [--assignee ID] Claim a task atomically (requires hub) - llm-tracker next [--json] [--limit N] Print ranked next tasks (requires hub) - llm-tracker since [] [--json] Print events since rev (requires hub running) - llm-tracker rollback Roll a project back to a prior rev (requires hub) - llm-tracker restore [--rev ] Restore a deleted project from its snapshot (requires hub) - llm-tracker link Symlink an external tracker file into the workspace (requires hub) - llm-tracker shortcuts [--alias NAME] Print shell shortcuts for zero-token lt next / lt brief usage - llm-tracker help Show this help + llm-tracker init [--path ] Create a workspace (default ~/.llm-tracker) + llm-tracker [--path ] [--port N] Start the hub (default port 4400) + llm-tracker status [] [--json] Print project status to stdout + llm-tracker since [] [--json] Print events since rev (requires hub running) + llm-tracker rollback Roll a project back to a prior rev (requires hub) + llm-tracker link Symlink an external tracker file into the workspace (requires hub) + llm-tracker help Show this help Env: LLM_TRACKER_HOME Workspace folder (overrides default) LLM_TRACKER_PORT Port (overrides settings.json and default) - LLM_TRACKER_ASSIGNEE Default assignee for pick / claim Port priority (first match wins): 1. --port flag @@ -638,11 +297,6 @@ Port priority (first match wins): `); return; } - if (cmd) { - console.error(`Unknown command "${cmd}". Use 'llm-tracker help'.`); - process.exit(1); - } - if (daemonFlag) return cmdDaemonStart(args); return cmdRun(args); } diff --git a/bin/mcp-context-data.js b/bin/mcp-context-data.js deleted file mode 100644 index 9992665..0000000 --- a/bin/mcp-context-data.js +++ /dev/null @@ -1,190 +0,0 @@ -import { join } from "node:path"; -import { daemonMetaPath, getDaemonStatus, runtimeDir } from "../hub/runtime.js"; -import { listProjectEntries } from "../hub/project-loader.js"; - -export const HELP_URI = "tracker://help"; -export const WORKSPACE_STATUS_URI = "tracker://workspace/status"; -export const WORKSPACE_RUNTIME_URI = "tracker://workspace/runtime"; -export const PROJECTS_URI = "tracker://projects"; - -export const READ_TOOL_NAMES = [ - "tracker_help", - "tracker_projects", - "tracker_projects_status", - "tracker_project_status", - "tracker_next", - "tracker_search", - "tracker_fuzzy_search", - "tracker_brief", - "tracker_why", - "tracker_decisions", - "tracker_execute", - "tracker_verify", - "tracker_blockers", - "tracker_changed", - "tracker_history" -]; - -export const WRITE_TOOL_NAMES = [ - "tracker_patch", - "tracker_pick", - "tracker_undo", - "tracker_redo", - "tracker_reload" -]; - -export function makeResourceContent(uri, mimeType, text) { - return { - contents: [ - { - uri, - mimeType, - text - } - ] - }; -} - -export function makePrompt(description, text) { - return { - description, - messages: [ - { - role: "user", - content: { - type: "text", - text - } - } - ] - }; -} - -export function summarizeProject(entry) { - if (!entry.ok) { - return { - slug: entry.slug, - ok: false, - path: entry.path, - error: entry.message - }; - } - - return { - slug: entry.slug, - ok: true, - path: entry.path, - name: entry.data?.meta?.name || null, - rev: entry.rev, - total: entry.derived?.total ?? 0, - pct: entry.derived?.pct ?? 0, - counts: entry.derived?.counts || {}, - blockedCount: Object.keys(entry.derived?.blocked || {}).length - }; -} - -export function projectStatusPayload(workspace, entry) { - if (!entry.ok) { - return { - workspace, - slug: entry.slug, - ok: false, - path: entry.path, - error: entry.message - }; - } - - return { - workspace, - project: { - slug: entry.slug, - name: entry.data?.meta?.name || null, - path: entry.path, - rev: entry.rev, - total: entry.derived?.total ?? 0, - pct: entry.derived?.pct ?? 0, - counts: entry.derived?.counts || {}, - blocked: entry.derived?.blocked || {}, - perSwimlane: entry.derived?.perSwimlane || {}, - scratchpad: entry.data?.meta?.scratchpad || "", - updatedAt: entry.data?.meta?.updatedAt || null - } - }; -} - -export function buildProjectStatusUri(slug) { - return `tracker://projects/${encodeURIComponent(slug)}/status`; -} - -export function parseProjectStatusUri(uri) { - const match = uri.match(/^tracker:\/\/projects\/([^/]+)\/status$/); - if (!match) return null; - try { - return decodeURIComponent(match[1]); - } catch { - return null; - } -} - -export function workspaceStatusPayload(workspace) { - const projects = listProjectEntries(workspace).map(summarizeProject); - const daemon = getDaemonStatus(workspace); - - return { - workspace, - trackerDir: join(workspace, "trackers"), - patchesDir: join(workspace, "patches"), - runtimeDir: runtimeDir(workspace), - daemon: { - running: daemon.running, - stale: daemon.stale, - pid: daemon.meta?.pid ?? null, - port: daemon.meta?.port ?? null, - metaFile: daemonMetaPath(workspace), - logFile: daemon.logFile - }, - mcp: { - readToolsRequireDaemon: false, - writeToolsRequireDaemon: true, - readTools: READ_TOOL_NAMES, - writeTools: WRITE_TOOL_NAMES - }, - projectCount: projects.length, - projects - }; -} - -export function workspaceRuntimePayload(workspace) { - const daemon = getDaemonStatus(workspace); - - return { - workspace, - trackersDir: join(workspace, "trackers"), - patchesDir: join(workspace, "patches"), - runtimeDir: runtimeDir(workspace), - daemonMetaPath: daemonMetaPath(workspace), - daemonLogPath: daemon.logFile, - daemon: { - running: daemon.running, - stale: daemon.stale, - pid: daemon.meta?.pid ?? null, - port: daemon.meta?.port ?? null - }, - patchWorkflow: { - preferredWritePath: - daemon.running || daemon.stale - ? "Use hub-backed HTTP or MCP write tools when available." - : "Use patch files when the hub is not reachable.", - patchDirectory: join(workspace, "patches"), - exampleFilename: "..json", - examplePath: join(workspace, "patches", "..json"), - errorFileRule: - "Rejected patch files produce a sibling .errors.json file with structured validation details." - }, - daemonRule: { - readToolsRequireDaemon: false, - writeToolsRequireDaemon: true, - writeTools: WRITE_TOOL_NAMES - } - }; -} diff --git a/bin/mcp-context.js b/bin/mcp-context.js deleted file mode 100644 index 7e59843..0000000 --- a/bin/mcp-context.js +++ /dev/null @@ -1,2 +0,0 @@ -export { getPrompt, listPrompts } from "./mcp-prompts.js"; -export { listResources, readResource } from "./mcp-resources.js"; diff --git a/bin/mcp-prompts.js b/bin/mcp-prompts.js deleted file mode 100644 index bb4504c..0000000 --- a/bin/mcp-prompts.js +++ /dev/null @@ -1,184 +0,0 @@ -import { join } from "node:path"; -import { makePrompt, WRITE_TOOL_NAMES } from "./mcp-context-data.js"; - -export function listPrompts() { - return [ - { - name: "tracker_start_here", - description: "Load the workspace contract, daemon rule, and patch workflow before acting.", - arguments: [] - }, - { - name: "tracker_pick_next", - description: "Find the next task for a project, then claim it safely.", - arguments: [ - { - name: "slug", - description: "Project slug", - required: true - } - ] - }, - { - name: "tracker_task_context", - description: "Load the bounded context for one task without rereading the whole tracker.", - arguments: [ - { - name: "slug", - description: "Project slug", - required: true - }, - { - name: "taskId", - description: "Task id", - required: true - } - ] - }, - { - name: "tracker_search_project", - description: "Search a project when the question is fuzzy or feature-oriented instead of task-id-oriented.", - arguments: [ - { - name: "slug", - description: "Project slug", - required: true - }, - { - name: "query", - description: "Feature or concept to search for", - required: true - } - ] - }, - { - name: "tracker_execute_task", - description: "Prepare an execution pass for one task with the deterministic pack.", - arguments: [ - { - name: "slug", - description: "Project slug", - required: true - }, - { - name: "taskId", - description: "Task id", - required: true - } - ] - }, - { - name: "tracker_verify_task", - description: "Prepare a verification pass for one task with explicit evidence sources.", - arguments: [ - { - name: "slug", - description: "Project slug", - required: true - }, - { - name: "taskId", - description: "Task id", - required: true - } - ] - }, - { - name: "tracker_patch_write", - description: "Explain the file-based patch workflow when the hub is unavailable.", - arguments: [ - { - name: "slug", - description: "Optional project slug for the example patch filename", - required: false - } - ] - } - ]; -} - -export function getPrompt(workspace, name, args = {}) { - const slug = typeof args.slug === "string" && args.slug.trim() ? args.slug.trim() : ""; - const taskId = typeof args.taskId === "string" && args.taskId.trim() ? args.taskId.trim() : ""; - const patchExample = join(workspace, "patches", `${slug}..json`); - - switch (name) { - case "tracker_start_here": - return makePrompt( - "Load the workspace contract and operating rules before touching project state.", - [ - "Start with the workspace contract before acting.", - "1. Read resource `tracker://help` or call `tracker_help`.", - "2. Read resource `tracker://workspace/runtime` for daemon state, patch directory, and MCP write rules.", - `3. MCP read tools do not require the daemon. MCP write tools ${WRITE_TOOL_NAMES.map((tool) => `\`${tool}\``).join(", ")} do require the hub or daemon.`, - `4. If the hub is unavailable, file-mode patches go in \`${join(workspace, "patches")}\` as \`${patchExample}\`. Rejections create a sibling \`.errors.json\` file.`, - "5. Preferred agent flow: `tracker_projects_status` or `tracker_project_status`, then `tracker_next`, then `tracker_brief` or `tracker_why`, then `tracker_execute`, then `tracker_pick` and `tracker_patch`, and finally `tracker_verify`." - ].join("\n") - ); - case "tracker_pick_next": - return makePrompt( - `Find the next task for ${slug} and claim it only if a write path is available.`, - [ - `Use \`tracker_project_status\` for \`${slug}\` if you need a quick progress snapshot.`, - `Call \`tracker_next\` with \`${slug}\` to get the ranked shortlist.`, - "Inspect the top recommendation and alternatives before choosing work.", - `If the task should be claimed and the hub is reachable, call \`tracker_pick\` for \`${slug}\`.`, - `If the hub is not reachable, do not pretend the claim succeeded. Use a patch file in \`${patchExample}\` instead.` - ].join("\n") - ); - case "tracker_task_context": - return makePrompt( - `Load bounded task context for ${slug}/${taskId}.`, - [ - `Call \`tracker_brief\` with slug \`${slug}\` and task id \`${taskId}\`.`, - `If rationale is unclear, call \`tracker_why\` for \`${slug}\` / \`${taskId}\`.`, - `If you need recent changes or decision trail, call \`tracker_history\` or \`tracker_changed\` for \`${slug}\`.`, - "Avoid rereading the whole tracker unless the bounded packs are insufficient." - ].join("\n") - ); - case "tracker_search_project": { - const query = typeof args.query === "string" && args.query.trim() ? args.query.trim() : ""; - return makePrompt( - `Search ${slug} for feature-oriented or fuzzy questions like "${query}".`, - [ - `Start with \`tracker_search\` for \`${slug}\` and query \`${query}\` when you want semantic search over task meaning and nearby concepts.`, - `If you want deterministic lexical matching instead, call \`tracker_fuzzy_search\` for \`${slug}\` and \`${query}\`.`, - "Use the returned matches to choose a task id, then follow up with `tracker_brief` or `tracker_why` instead of rereading the whole tracker." - ].join("\n") - ); - } - case "tracker_execute_task": - return makePrompt( - `Prepare to execute ${slug}/${taskId} with deterministic guardrails.`, - [ - `Call \`tracker_execute\` with slug \`${slug}\` and task id \`${taskId}\`.`, - `If you have not claimed the task yet and the hub is reachable, use \`tracker_pick\` first.`, - "Use the execution pack's readiness, constraints, expected changes, and references to plan edits.", - `After editing, use \`tracker_verify\` for \`${slug}\` / \`${taskId}\` and \`tracker_changed\` to confirm the resulting tracker state.` - ].join("\n") - ); - case "tracker_verify_task": - return makePrompt( - `Verify completion of ${slug}/${taskId} using tracker evidence.`, - [ - `Call \`tracker_verify\` with slug \`${slug}\` and task id \`${taskId}\`.`, - `Use \`tracker_history\` and \`tracker_changed\` for \`${slug}\` if you need revision-backed confirmation.`, - "If verification fails, explain the missing evidence or unmet checks explicitly instead of marking the task complete." - ].join("\n") - ); - case "tracker_patch_write": - return makePrompt( - "Use the file-based patch workflow when the hub is unavailable.", - [ - "If the hub is reachable, prefer `tracker_patch` so hub locking, revisioning, and broadcasts stay authoritative.", - `Patch files belong in \`${join(workspace, "patches")}\`.`, - `Use a filename like \`${patchExample}\`.`, - "Patch mode is the fallback for when the hub is unavailable; MCP write tools remain hub-backed.", - "If a patch is rejected, inspect the sibling `.errors.json` file for the structured validation error.", - "Once the hub is reachable again, prefer `tracker_patch` or HTTP writes so locking, revisioning, and broadcasts stay authoritative." - ].join("\n") - ); - default: - throw new Error(`Unknown prompt: ${name}`); - } -} diff --git a/bin/mcp-read-tools.js b/bin/mcp-read-tools.js deleted file mode 100644 index 9b5074e..0000000 --- a/bin/mcp-read-tools.js +++ /dev/null @@ -1,297 +0,0 @@ -import { readHistory } from "../hub/snapshots.js"; -import { getBlockersPayload } from "../hub/blockers.js"; -import { getBriefPayload } from "../hub/briefs.js"; -import { getChangedPayload } from "../hub/changed.js"; -import { getDecisionsPayload } from "../hub/decisions.js"; -import { getExecutePayload } from "../hub/execute.js"; -import { getNextPayload } from "../hub/next.js"; -import { getVerifyPayload } from "../hub/verify.js"; -import { getWhyPayload } from "../hub/why.js"; -import { loadReadableEntry, makeJsonResult, nonEmptyString, clampInt, readToolPayload, makeTextResult } from "./mcp-utils.js"; -import { projectStatusPayload, summarizeProject } from "./mcp-context-data.js"; -import { listProjectEntries, readWorkspaceHelp } from "../hub/project-loader.js"; - -let searchModulePromise = null; - -async function loadSearchModule() { - if (!searchModulePromise) { - searchModulePromise = import("../hub/search.js"); - } - return searchModulePromise; -} - -async function getSearchToolPayload(...args) { - const module = await loadSearchModule(); - return module.getSearchPayload(...args); -} - -async function getFuzzySearchToolPayload(...args) { - const module = await loadSearchModule(); - return module.getFuzzyPayload(...args); -} - -export function createReadTools(workspace) { - return [ - { - name: "tracker_help", - description: "Read the workspace agent contract served by /help.", - inputSchema: { type: "object", properties: {} }, - handler: async () => { - const help = readWorkspaceHelp(workspace); - if (!help.ok) { - return makeTextResult(`Workspace help is unavailable at ${help.path}: ${help.message}`, { - isError: true - }); - } - return makeTextResult(help.text); - } - }, - { - name: "tracker_projects", - description: "List projects available in the configured llm-tracker workspace.", - inputSchema: { type: "object", properties: {} }, - handler: async () => { - const projects = listProjectEntries(workspace).map(summarizeProject); - return makeJsonResult({ - workspace, - projectCount: projects.length, - projects - }); - } - }, - { - name: "tracker_projects_status", - description: "Return overall status for all projects in the configured workspace.", - inputSchema: { type: "object", properties: {} }, - handler: async () => { - const projects = listProjectEntries(workspace).map(summarizeProject); - return makeJsonResult({ - workspace, - projectCount: projects.length, - projects - }); - } - }, - { - name: "tracker_project_status", - description: - "Return status for one project: totals, progress, blocked map, swimlane breakdown, and scratchpad.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" } - }, - required: ["slug"] - }, - handler: async (args = {}) => { - const slug = nonEmptyString(args.slug); - const loaded = loadReadableEntry(workspace, slug); - if (!loaded.ok) return loaded.result; - return makeJsonResult(projectStatusPayload(workspace, loaded.entry)); - } - }, - { - name: "tracker_next", - description: "Return the ranked next-task shortlist for one project.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" }, - limit: { type: "integer", minimum: 1, maximum: 5 } - }, - required: ["slug"] - }, - handler: async (args = {}) => - readToolPayload(getNextPayload, workspace, nonEmptyString(args.slug), { - limit: clampInt(args.limit, { fallback: 5, min: 1, max: 5 }) - }) - }, - { - name: "tracker_search", - description: "Search tasks semantically with local embeddings.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" }, - query: { type: "string", description: "Search query" }, - limit: { type: "integer", minimum: 1, maximum: 50 } - }, - required: ["slug", "query"] - }, - handler: async (args = {}) => - readToolPayload(getSearchToolPayload, workspace, nonEmptyString(args.slug), { - query: nonEmptyString(args.query), - limit: clampInt(args.limit, { fallback: 10, min: 1, max: 50 }) - }) - }, - { - name: "tracker_fuzzy_search", - description: "Search tasks with deterministic fuzzy lexical matching.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" }, - query: { type: "string", description: "Search query" }, - limit: { type: "integer", minimum: 1, maximum: 50 } - }, - required: ["slug", "query"] - }, - handler: async (args = {}) => - readToolPayload(getFuzzySearchToolPayload, workspace, nonEmptyString(args.slug), { - query: nonEmptyString(args.query), - limit: clampInt(args.limit, { fallback: 10, min: 1, max: 50 }) - }) - }, - { - name: "tracker_brief", - description: "Return the bounded task brief pack for one task.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" }, - taskId: { type: "string", description: "Task id" } - }, - required: ["slug", "taskId"] - }, - handler: async (args = {}) => - readToolPayload(getBriefPayload, workspace, nonEmptyString(args.slug), { - taskId: nonEmptyString(args.taskId) - }) - }, - { - name: "tracker_why", - description: "Return the deterministic why pack for one task.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" }, - taskId: { type: "string", description: "Task id" } - }, - required: ["slug", "taskId"] - }, - handler: async (args = {}) => - readToolPayload(getWhyPayload, workspace, nonEmptyString(args.slug), { - taskId: nonEmptyString(args.taskId) - }) - }, - { - name: "tracker_decisions", - description: "Return recent project decisions derived from task comments.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" }, - limit: { type: "integer", minimum: 1, maximum: 20 } - }, - required: ["slug"] - }, - handler: async (args = {}) => - readToolPayload(getDecisionsPayload, workspace, nonEmptyString(args.slug), { - limit: clampInt(args.limit, { fallback: 20, min: 1, max: 20 }) - }) - }, - { - name: "tracker_execute", - description: "Return the deterministic execution pack for one task.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" }, - taskId: { type: "string", description: "Task id" } - }, - required: ["slug", "taskId"] - }, - handler: async (args = {}) => - readToolPayload(getExecutePayload, workspace, nonEmptyString(args.slug), { - taskId: nonEmptyString(args.taskId) - }) - }, - { - name: "tracker_verify", - description: "Return the deterministic verification pack for one task.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" }, - taskId: { type: "string", description: "Task id" } - }, - required: ["slug", "taskId"] - }, - handler: async (args = {}) => - readToolPayload(getVerifyPayload, workspace, nonEmptyString(args.slug), { - taskId: nonEmptyString(args.taskId) - }) - }, - { - name: "tracker_blockers", - description: "Return blocked tasks and the tasks currently blocking others.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" } - }, - required: ["slug"] - }, - handler: async (args = {}) => - readToolPayload(getBlockersPayload, workspace, nonEmptyString(args.slug)) - }, - { - name: "tracker_changed", - description: "Return changed tasks since a given revision.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" }, - fromRev: { type: "integer", minimum: 0 }, - limit: { type: "integer", minimum: 1, maximum: 50 } - }, - required: ["slug"] - }, - handler: async (args = {}) => - readToolPayload(getChangedPayload, workspace, nonEmptyString(args.slug), { - fromRev: clampInt(args.fromRev, { fallback: 0, min: 0, max: Number.MAX_SAFE_INTEGER }), - limit: clampInt(args.limit, { fallback: 20, min: 1, max: 50 }) - }) - }, - { - name: "tracker_history", - description: "Return recent project history entries from the append-only revision log.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" }, - fromRev: { type: "integer", minimum: 0 }, - limit: { type: "integer", minimum: 1, maximum: 200 } - }, - required: ["slug"] - }, - handler: async (args = {}) => { - const slug = nonEmptyString(args.slug); - const loaded = loadReadableEntry(workspace, slug); - if (!loaded.ok) return loaded.result; - - const fromRev = clampInt(args.fromRev, { - fallback: 0, - min: 0, - max: Number.MAX_SAFE_INTEGER - }); - const limit = clampInt(args.limit, { fallback: 50, min: 1, max: 200 }); - const all = readHistory(workspace, slug).filter((event) => event.rev > fromRev); - const events = all.slice(-limit); - - return makeJsonResult({ - project: slug, - currentRev: loaded.entry.rev, - fromRev, - events, - truncation: { - applied: events.length < all.length, - returned: events.length, - totalAvailable: all.length, - maxCount: limit - } - }); - } - } - ]; -} diff --git a/bin/mcp-resources.js b/bin/mcp-resources.js deleted file mode 100644 index 17de5ee..0000000 --- a/bin/mcp-resources.js +++ /dev/null @@ -1,104 +0,0 @@ -import { loadProjectEntry, readWorkspaceHelp, listProjectEntries } from "../hub/project-loader.js"; -import { - HELP_URI, - PROJECTS_URI, - WORKSPACE_RUNTIME_URI, - WORKSPACE_STATUS_URI, - buildProjectStatusUri, - makeResourceContent, - parseProjectStatusUri, - projectStatusPayload, - summarizeProject, - workspaceRuntimePayload, - workspaceStatusPayload -} from "./mcp-context-data.js"; - -export function listResources(workspace) { - const projects = listProjectEntries(workspace).map(summarizeProject); - - return [ - { - uri: HELP_URI, - name: "Workspace Help", - description: "Full workspace agent contract, including daemon and patch workflow rules.", - mimeType: "text/markdown" - }, - { - uri: WORKSPACE_STATUS_URI, - name: "Workspace Status", - description: "Structured workspace, daemon, and project status overview.", - mimeType: "application/json" - }, - { - uri: WORKSPACE_RUNTIME_URI, - name: "Workspace Runtime", - description: "Daemon state, MCP daemon rule, and patch-file workflow details.", - mimeType: "application/json" - }, - { - uri: PROJECTS_URI, - name: "Projects", - description: "Structured summaries for all known projects in the workspace.", - mimeType: "application/json" - }, - ...projects.map((project) => ({ - uri: buildProjectStatusUri(project.slug), - name: `${project.slug} Status`, - description: `Structured status for project ${project.slug}.`, - mimeType: "application/json" - })) - ]; -} - -export function readResource(workspace, uri) { - switch (uri) { - case HELP_URI: { - const help = readWorkspaceHelp(workspace); - if (!help.ok) { - throw new Error(`Workspace help is unavailable at ${help.path}: ${help.message}`); - } - return makeResourceContent(uri, "text/markdown", help.text); - } - case WORKSPACE_STATUS_URI: - return makeResourceContent( - uri, - "application/json", - JSON.stringify(workspaceStatusPayload(workspace), null, 2) - ); - case WORKSPACE_RUNTIME_URI: - return makeResourceContent( - uri, - "application/json", - JSON.stringify(workspaceRuntimePayload(workspace), null, 2) - ); - case PROJECTS_URI: - return makeResourceContent( - uri, - "application/json", - JSON.stringify( - { - workspace, - projectCount: listProjectEntries(workspace).length, - projects: listProjectEntries(workspace).map(summarizeProject) - }, - null, - 2 - ) - ); - default: { - const slug = parseProjectStatusUri(uri); - if (!slug) { - throw new Error(`Unknown resource: ${uri}`); - } - const entry = loadProjectEntry(workspace, slug); - if (!entry.ok) { - throw new Error(`Failed to load project "${slug}" from ${entry.path}: ${entry.message}`); - } - return makeResourceContent( - uri, - "application/json", - JSON.stringify(projectStatusPayload(workspace, entry), null, 2) - ); - } - } -} diff --git a/bin/mcp-server.js b/bin/mcp-server.js deleted file mode 100644 index 9828563..0000000 --- a/bin/mcp-server.js +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env node -import { createRequire } from "node:module"; -import { appendFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - GetPromptRequestSchema, - ListPromptsRequestSchema, - ListResourceTemplatesRequestSchema, - ListResourcesRequestSchema, - ListToolsRequestSchema, - ReadResourceRequestSchema -} from "@modelcontextprotocol/sdk/types.js"; -import { getPrompt, listPrompts, listResources, readResource } from "./mcp-context.js"; -import { createTools } from "./mcp-tools.js"; -import { makeTextResult, nonEmptyString } from "./mcp-utils.js"; -import { resolveWorkspace } from "./workspace-client.js"; - -const require = createRequire(import.meta.url); -const pkg = require("../package.json"); -const MCP_DEBUG_LOG = process.env.LLM_TRACKER_MCP_DEBUG_LOG || "/tmp/llm-tracker-mcp-debug.log"; - -function debugLog(line) { - if (!MCP_DEBUG_LOG) return; - try { - appendFileSync(MCP_DEBUG_LOG, `[${new Date().toISOString()}] ${line}\n`); - } catch { - // debug logging should never break MCP startup - } -} - -export async function startMcpServer({ workspace: workspaceFlag, portFlag } = {}) { - const workspace = resolveWorkspace(workspaceFlag); - if (!existsSync(workspace)) { - throw new Error(`No workspace at ${workspace}. Run 'llm-tracker init' first.`); - } - if (!existsSync(join(workspace, "README.md"))) { - throw new Error(`Workspace at ${workspace} is missing README.md.`); - } - debugLog(`server start pid=${process.pid} cwd=${process.cwd()} workspace=${workspace}`); - - const tools = createTools(workspace, portFlag); - - const server = new Server( - { name: pkg.name, version: pkg.version }, - { - capabilities: { - prompts: {}, - resources: {}, - tools: {} - } - } - ); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: Array.from(tools.values()).map(({ name, description, inputSchema }) => ({ - name, - description, - inputSchema - })) - })); - - server.setRequestHandler(CallToolRequestSchema, async (request) => { - const name = nonEmptyString(request.params?.name); - const tool = tools.get(name); - if (!tool) { - return makeTextResult(`Unknown tool: ${name || "(missing)"}`, { isError: true }); - } - try { - return await tool.handler(request.params?.arguments || {}); - } catch (error) { - debugLog(`tool error ${name}: ${error.message}`); - return makeTextResult(`Tool ${name} failed: ${error.message}`, { isError: true }); - } - }); - - server.setRequestHandler(ListResourcesRequestSchema, async () => ({ - resources: listResources(workspace) - })); - - server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ - resourceTemplates: [] - })); - - server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - const uri = nonEmptyString(request.params?.uri); - if (!uri) { - throw new Error("resources/read requires a resource uri"); - } - return readResource(workspace, uri); - }); - - server.setRequestHandler(ListPromptsRequestSchema, async () => ({ - prompts: listPrompts() - })); - - server.setRequestHandler(GetPromptRequestSchema, async (request) => { - const name = nonEmptyString(request.params?.name); - if (!name) { - throw new Error("prompts/get requires a prompt name"); - } - return getPrompt(workspace, name, request.params?.arguments || {}); - }); - - const transport = new StdioServerTransport(); - transport.onclose = () => { - debugLog("transport close"); - }; - transport.onerror = (error) => { - debugLog(`transport error: ${error.message}`); - }; - - await server.connect(transport); - debugLog("connected to stdio transport"); -} diff --git a/bin/mcp-tools.js b/bin/mcp-tools.js deleted file mode 100644 index f0ea194..0000000 --- a/bin/mcp-tools.js +++ /dev/null @@ -1,7 +0,0 @@ -import { createReadTools } from "./mcp-read-tools.js"; -import { createWriteTools } from "./mcp-write-tools.js"; - -export function createTools(workspace, portFlag) { - const tools = [...createReadTools(workspace), ...createWriteTools(workspace, portFlag)]; - return new Map(tools.map((tool) => [tool.name, tool])); -} diff --git a/bin/mcp-utils.js b/bin/mcp-utils.js deleted file mode 100644 index 74169b8..0000000 --- a/bin/mcp-utils.js +++ /dev/null @@ -1,80 +0,0 @@ -import { httpRequest } from "./workspace-client.js"; -import { loadProjectEntry } from "../hub/project-loader.js"; - -export function clampInt(value, { fallback, min, max }) { - const parsed = parseInt(value, 10); - if (isNaN(parsed) || parsed < min) return fallback; - return Math.min(parsed, max); -} - -export function nonEmptyString(value) { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -export function makeTextResult(text, { isError = false } = {}) { - return { - content: [ - { - type: "text", - text - } - ], - isError - }; -} - -export function makeJsonResult(value, { isError = false } = {}) { - return makeTextResult(JSON.stringify(value, null, 2), { isError }); -} - -export function loadReadableEntry(workspace, slug) { - const entry = loadProjectEntry(workspace, slug); - if (!entry.ok) { - return { - ok: false, - result: makeTextResult( - `Failed to load project "${slug}" from ${entry.path}: ${entry.message}`, - { isError: true } - ) - }; - } - return { ok: true, entry }; -} - -export async function readToolPayload(getter, workspace, slug, extra = {}) { - const loaded = loadReadableEntry(workspace, slug); - if (!loaded.ok) return loaded.result; - - const payload = await getter({ - workspace, - slug, - entry: loaded.entry, - ...extra - }); - - if (payload?.ok === false) { - return makeTextResult(payload.message || `${slug}: request failed`, { isError: true }); - } - if (!payload) { - return makeTextResult(`Project "${slug}" is not available.`, { isError: true }); - } - - return makeJsonResult(payload.payload || payload); -} - -export async function runHubMutation({ workspace, portFlag, method, path, label, body }) { - const response = await httpRequest(workspace, portFlag, method, path, body); - if (response.status === 0) { - return makeTextResult( - `Hub not reachable at ${response.url}. Start the hub or daemon before calling ${label}.`, - { isError: true } - ); - } - if (response.status >= 400) { - return makeTextResult( - `${label} failed (${response.status}): ${response.body.error || response.body.raw}`, - { isError: true } - ); - } - return makeJsonResult(response.body); -} diff --git a/bin/mcp-write-tools.js b/bin/mcp-write-tools.js deleted file mode 100644 index 8aae170..0000000 --- a/bin/mcp-write-tools.js +++ /dev/null @@ -1,163 +0,0 @@ -import { runHubMutation, nonEmptyString, makeTextResult } from "./mcp-utils.js"; - -function createHubWriteTool(workspace, portFlag, definition) { - return { - name: definition.name, - description: definition.description, - inputSchema: definition.inputSchema, - handler: async (args = {}) => { - const prepared = await definition.prepareRequest(args); - if (prepared?.error) { - return makeTextResult(prepared.error, { isError: true }); - } - return runHubMutation({ - workspace, - portFlag, - method: "POST", - path: prepared.path, - label: definition.name, - body: prepared.body - }); - } - }; -} - -function createPatchToolDefinition() { - return { - name: "tracker_patch", - description: "Submit a normal partial tracker patch through the running hub.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" }, - patch: { - type: "object", - description: "Partial tracker patch body to merge through the hub", - additionalProperties: true - } - }, - required: ["slug", "patch"] - }, - prepareRequest(args = {}) { - const slug = nonEmptyString(args.slug); - if (!slug) { - return { error: "tracker_patch requires a project slug." }; - } - if (!args.patch || typeof args.patch !== "object" || Array.isArray(args.patch)) { - return { error: "tracker_patch requires a JSON object patch." }; - } - return { - path: `/api/projects/${slug}/patch`, - body: args.patch - }; - } - }; -} - -function createPickToolDefinition() { - return { - name: "tracker_pick", - description: "Atomically claim a task through the running hub. Requires the hub to be reachable.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" }, - taskId: { type: "string", description: "Optional explicit task id" }, - assignee: { type: "string", description: "Optional assignee id" }, - force: { type: "boolean", description: "Override blocked or assignee conflicts" }, - comment: { type: "string", description: "Optional task note to write with the claim" } - }, - required: ["slug"] - }, - prepareRequest(args = {}) { - const slug = nonEmptyString(args.slug); - if (!slug) { - return { error: "tracker_pick requires a project slug." }; - } - - const body = {}; - const taskId = nonEmptyString(args.taskId); - const assignee = nonEmptyString(args.assignee); - if (taskId) body.taskId = taskId; - if (assignee) body.assignee = assignee; - if (args.force === true) body.force = true; - if (args.comment !== undefined) body.comment = args.comment; - - return { - path: `/api/projects/${slug}/pick`, - body - }; - } - }; -} - -function createSlugWriteTool(name, description, pathSuffix) { - return { - name, - description, - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Project slug" } - }, - required: ["slug"] - }, - prepareRequest(args = {}) { - const slug = nonEmptyString(args.slug); - if (!slug) { - return { error: `${name} requires a project slug.` }; - } - return { - path: pathSuffix(slug) - }; - } - }; -} - -export function createWriteTools(workspace, portFlag) { - return [ - createHubWriteTool(workspace, portFlag, createPatchToolDefinition()), - createHubWriteTool(workspace, portFlag, createPickToolDefinition()), - createHubWriteTool( - workspace, - portFlag, - createSlugWriteTool( - "tracker_undo", - "Undo the most recent effective project change through the running hub.", - (slug) => `/api/projects/${slug}/undo` - ) - ), - createHubWriteTool( - workspace, - portFlag, - createSlugWriteTool( - "tracker_redo", - "Redo the last undone project change through the running hub.", - (slug) => `/api/projects/${slug}/redo` - ) - ), - createHubWriteTool( - workspace, - portFlag, - { - name: "tracker_reload", - description: "Force one project or the full workspace to reload from disk through the running hub.", - inputSchema: { - type: "object", - properties: { - slug: { - type: "string", - description: "Optional project slug. If omitted, reload all trackers." - } - } - }, - prepareRequest(args = {}) { - const slug = nonEmptyString(args.slug); - return { - path: slug ? `/api/projects/${slug}/reload` : "/api/reload" - }; - } - } - ) - ]; -} diff --git a/bin/workspace-client.js b/bin/workspace-client.js deleted file mode 100644 index f1c0479..0000000 --- a/bin/workspace-client.js +++ /dev/null @@ -1,70 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join, resolve } from "node:path"; -import { getDaemonStatus } from "../hub/runtime.js"; - -export const DEFAULT_WORKSPACE = join(homedir(), ".llm-tracker"); -export const DEFAULT_PORT = 4400; - -export function loadWorkspaceSettings(workspace) { - const file = join(workspace, "settings.json"); - if (!existsSync(file)) return {}; - try { - return JSON.parse(readFileSync(file, "utf-8")); - } catch { - return {}; - } -} - -export function validPort(value) { - const parsed = parseInt(value, 10); - return !isNaN(parsed) && parsed >= 1 && parsed <= 65535 ? parsed : null; -} - -export function resolveWorkspace(flag) { - const fromFlag = flag || process.env.LLM_TRACKER_HOME; - return resolve(fromFlag || DEFAULT_WORKSPACE); -} - -export function resolvePort(workspace, flagPort) { - const wsSettings = loadWorkspaceSettings(workspace); - const daemonStatus = getDaemonStatus(workspace); - return ( - validPort(flagPort) || - validPort(process.env.LLM_TRACKER_PORT) || - validPort(wsSettings.port) || - (daemonStatus.running ? validPort(daemonStatus.meta?.port) : null) || - DEFAULT_PORT - ); -} - -export async function httpRequest(workspace, portFlag, method, path, body) { - const port = resolvePort(workspace, portFlag); - const url = `http://localhost:${port}${path}`; - const headers = {}; - if (body) headers["Content-Type"] = "application/json"; - const token = process.env.LLM_TRACKER_TOKEN; - if (token) headers["Authorization"] = `Bearer ${token}`; - try { - const res = await fetch(url, { - method, - headers: Object.keys(headers).length ? headers : undefined, - body: body ? JSON.stringify(body) : undefined - }); - const text = await res.text(); - let json; - try { - json = JSON.parse(text); - } catch { - json = { raw: text }; - } - return { status: res.status, body: json, port, url }; - } catch (error) { - return { - status: 0, - body: { error: error.message }, - port, - url - }; - } -} diff --git a/hub/blockers.js b/hub/blockers.js deleted file mode 100644 index 0d9014d..0000000 --- a/hub/blockers.js +++ /dev/null @@ -1,86 +0,0 @@ -import { readHistory } from "./snapshots.js"; -import { buildProjectTaskContext, summarizeTask } from "./task-metadata.js"; - -function sortBlocked(a, b) { - if (b.priorityWeight !== a.priorityWeight) return b.priorityWeight - a.priorityWeight; - if (a.status !== b.status) return a.status === "in_progress" ? -1 : 1; - if (a.blocking_on.length !== b.blocking_on.length) return a.blocking_on.length - b.blocking_on.length; - return a.id.localeCompare(b.id); -} - -function sortBlocking(a, b) { - if (b.blockedCount !== a.blockedCount) return b.blockedCount - a.blockedCount; - if (b.priorityWeight !== a.priorityWeight) return b.priorityWeight - a.priorityWeight; - return a.id.localeCompare(b.id); -} - -export function buildBlockersPayload({ slug, data, history = [], now = new Date().toISOString() }) { - const context = buildProjectTaskContext({ data, history }); - const summaries = (data?.tasks || []) - .filter((task) => task.status === "not_started" || task.status === "in_progress") - .map((task) => summarizeTask(task, context)); - - const byId = new Map(summaries.map((task) => [task.id, task])); - const blocked = summaries - .filter((task) => task.blocking_on.length > 0) - .map((task) => ({ - ...task, - blocking_task_details: task.blocking_on - .map((depId) => byId.get(depId)) - .filter(Boolean) - .map((dep) => ({ - id: dep.id, - title: dep.title, - status: dep.status, - priorityId: dep.priorityId, - swimlaneId: dep.swimlaneId - })) - })) - .sort(sortBlocked); - - const reverse = new Map(); - for (const task of blocked) { - for (const depId of task.blocking_on) { - const current = reverse.get(depId) || []; - current.push({ - id: task.id, - title: task.title, - priorityId: task.priorityId, - swimlaneId: task.swimlaneId - }); - reverse.set(depId, current); - } - } - - const blocking = Array.from(reverse.entries()) - .map(([taskId, blockedTasks]) => { - const task = byId.get(taskId); - if (!task) return null; - return { - ...task, - blockedCount: blockedTasks.length, - blocks: blockedTasks.sort((a, b) => a.id.localeCompare(b.id)) - }; - }) - .filter(Boolean) - .sort(sortBlocking); - - return { - project: slug, - rev: context.currentRev, - generatedAt: now, - blocked, - blocking - }; -} - -export function getBlockersPayload({ workspace, slug, entry, now }) { - if (!entry?.data) return null; - const history = readHistory(workspace, slug); - return buildBlockersPayload({ - slug, - data: entry.data, - history, - now - }); -} diff --git a/hub/briefs.js b/hub/briefs.js deleted file mode 100644 index 4b2b6ec..0000000 --- a/hub/briefs.js +++ /dev/null @@ -1,232 +0,0 @@ -import { readHistory } from "./snapshots.js"; -import { normalizeTaskReferences } from "./references.js"; -import { loadReferenceSnippets, SNIPPET_MAX_BYTES, SNIPPET_MAX_COUNT } from "./snippets.js"; -import { buildProjectTaskContext, summarizeTask } from "./task-metadata.js"; - -const BRIEF_HISTORY_LIMIT = 3; - -function stringArray(values) { - if (!Array.isArray(values)) return []; - return values.filter((value) => typeof value === "string" && value.trim()); -} - -function summarizeTaskForBrief(task, context) { - const summary = summarizeTask(task, context); - return { - id: summary.id, - title: summary.title, - goal: task.goal || null, - status: summary.status, - assignee: summary.assignee, - priorityId: summary.priorityId, - swimlaneId: summary.swimlaneId, - effort: summary.effort, - ready: summary.ready, - blocked_kind: summary.blocked_kind, - blocking_on: summary.blocking_on, - blocker_reason: summary.blocker_reason, - requires_approval: summary.requires_approval, - references: summary.references, - comment: summary.comment, - lastTouchedRev: summary.lastTouchedRev, - dependencies: stringArray(task.dependencies), - related: stringArray(task.related), - definition_of_done: stringArray(task.definition_of_done), - constraints: stringArray(task.constraints), - expected_changes: stringArray(task.expected_changes), - allowed_paths: stringArray(task.allowed_paths) - }; -} - -function summarizeTaskIds(taskIds, context) { - return taskIds - .map((taskId) => context.byId.get(taskId)) - .filter(Boolean) - .map((task) => summarizeTaskForBrief(task, context)); -} - -function deltaChangeKeys(delta) { - if (!delta || typeof delta !== "object") return []; - if (delta.__added__) return ["__added__"]; - if (delta.__removed__) return ["__removed__"]; - return Object.keys(delta).sort(); -} - -function deltaChangeKinds(delta) { - const keys = deltaChangeKeys(delta); - const kinds = new Set(); - for (const key of keys) { - if (key === "__added__") kinds.add("added"); - else if (key === "__removed__") kinds.add("removed"); - else if (key === "status") kinds.add("status"); - else if (key === "placement") kinds.add("placement"); - else if (key === "dependencies") kinds.add("dependencies"); - else if (key === "assignee") kinds.add("assignee"); - else if (key === "reference" || key === "references") kinds.add("references"); - else if (key === "comment" || key === "blocker_reason") kinds.add("notes"); - else kinds.add("edit"); - } - return Array.from(kinds).sort(); -} - -function collectTaskHistory(history = [], taskId) { - return history - .filter((entry) => Object.prototype.hasOwnProperty.call(entry?.delta?.tasks || {}, taskId)) - .sort((a, b) => (b.rev ?? -1) - (a.rev ?? -1)) - .map((entry) => { - const delta = entry?.delta?.tasks?.[taskId]; - return { - rev: entry.rev, - ts: entry.ts, - summary: Array.isArray(entry.summary) ? entry.summary : [], - changedKeys: deltaChangeKeys(delta), - changeKinds: deltaChangeKinds(delta) - }; - }); -} - -function applySnippetBudget(snippets = []) { - const limited = []; - let usedBytes = 0; - let byteCapped = false; - - for (const snippet of snippets) { - if (limited.length >= SNIPPET_MAX_COUNT) break; - - const size = Buffer.byteLength(snippet.text || "", "utf-8"); - if (snippet.text && usedBytes + size > SNIPPET_MAX_BYTES) { - byteCapped = true; - continue; - } - - limited.push(snippet); - usedBytes += size; - } - - const totalBytes = snippets.reduce((sum, snippet) => sum + Buffer.byteLength(snippet.text || "", "utf-8"), 0); - return { - snippets: limited, - returnedBytes: usedBytes, - applied: limited.length < snippets.length, - byteCapped, - totalAvailable: snippets.length, - totalAvailableBytes: totalBytes - }; -} - -export function selectBriefReferences(task, context) { - const selected = []; - const seen = new Set(); - - const addReference = (value, selectedBecause) => { - if (!value || seen.has(value)) return; - seen.add(value); - selected.push({ value, selectedBecause }); - }; - - for (const value of normalizeTaskReferences(task)) { - addReference(value, "explicit task reference"); - } - - for (const dependencyId of stringArray(task.dependencies)) { - const dependency = context.byId.get(dependencyId); - if (!dependency) continue; - for (const value of normalizeTaskReferences(dependency)) { - addReference(value, `dependency reference from ${dependencyId}`); - } - } - - return selected; -} - -export function buildBriefPayload({ - slug, - data, - history = [], - taskId, - references = null, - snippets = [], - now = new Date().toISOString() -}) { - const context = buildProjectTaskContext({ data, history }); - const task = context.byId.get(taskId); - if (!task) return null; - - const taskPack = summarizeTaskForBrief(task, context); - const selectedReferences = references || selectBriefReferences(task, context); - const selectedBecause = new Map(selectedReferences.map((reference) => [reference.value, reference.selectedBecause])); - const enrichedSnippets = snippets.map((snippet) => ({ - ...snippet, - selectedBecause: selectedBecause.get(snippet.reference) || "derived snippet" - })); - const snippetBudget = applySnippetBudget(enrichedSnippets); - const relatedHistory = collectTaskHistory(history, taskId); - const recentHistory = relatedHistory.slice(0, BRIEF_HISTORY_LIMIT); - - return { - packType: "brief", - project: slug, - taskId, - rev: context.currentRev, - generatedAt: now, - task: taskPack, - dependencies: summarizeTaskIds(taskPack.dependencies, context), - relatedTasks: summarizeTaskIds(taskPack.related, context), - references: selectedReferences, - snippets: snippetBudget.snippets, - recentHistory, - truncation: { - snippets: { - applied: snippetBudget.applied, - byteCapped: snippetBudget.byteCapped, - returned: snippetBudget.snippets.length, - totalAvailable: snippetBudget.totalAvailable, - returnedBytes: snippetBudget.returnedBytes, - totalAvailableBytes: snippetBudget.totalAvailableBytes, - maxCount: SNIPPET_MAX_COUNT, - maxBytes: SNIPPET_MAX_BYTES - }, - history: { - applied: recentHistory.length < relatedHistory.length, - returned: recentHistory.length, - totalAvailable: relatedHistory.length, - maxCount: BRIEF_HISTORY_LIMIT - } - } - }; -} - -export function getBriefPayload({ workspace, slug, entry, taskId, now }) { - if (!entry?.data) { - return { ok: false, status: 404, message: "not found" }; - } - - const history = readHistory(workspace, slug); - const context = buildProjectTaskContext({ data: entry.data, history }); - const task = context.byId.get(taskId); - if (!task) { - return { ok: false, status: 404, message: "task not found" }; - } - - const references = selectBriefReferences(task, context); - const { snippets } = loadReferenceSnippets({ - workspace, - slug, - trackerPath: entry.path, - references: references.map((reference) => reference.value), - indexedAtRev: entry.rev ?? entry.data?.meta?.rev ?? null - }); - - return { - ok: true, - payload: buildBriefPayload({ - slug, - data: entry.data, - history, - taskId, - references, - snippets, - now - }) - }; -} diff --git a/hub/changed.js b/hub/changed.js deleted file mode 100644 index 8c9a720..0000000 --- a/hub/changed.js +++ /dev/null @@ -1,150 +0,0 @@ -import { readHistory } from "./snapshots.js"; -import { buildProjectTaskContext, summarizeTask } from "./task-metadata.js"; - -function parseChangeKind(key) { - if (key === "__added__") return "added"; - if (key === "__removed__") return "removed"; - if (key === "status") return "status"; - if (key === "placement") return "placement"; - if (key === "dependencies") return "dependencies"; - if (key === "assignee") return "assignee"; - if (key === "comment" || key === "blocker_reason") return "notes"; - if (key === "reference" || key === "references") return "references"; - if (key === "effort") return "effort"; - return "edit"; -} - -function collectChanges(history = [], fromRev = 0) { - const taskChanges = new Map(); - const metaChanges = new Map(); - const orderChangedRevs = []; - - for (const entry of history) { - if (!Number.isInteger(entry?.rev) || entry.rev <= fromRev) continue; - - for (const key of Object.keys(entry?.delta?.meta || {})) { - metaChanges.set(key, entry.rev); - } - - if (Array.isArray(entry?.delta?.order)) { - orderChangedRevs.push(entry.rev); - } - - for (const [taskId, delta] of Object.entries(entry?.delta?.tasks || {})) { - const existing = taskChanges.get(taskId) || { - id: taskId, - changedInRevs: [], - changeKinds: new Set(), - changedKeys: new Set(), - added: false, - removed: false, - lastChangedRev: null - }; - - existing.changedInRevs.push(entry.rev); - existing.lastChangedRev = entry.rev; - - if (delta?.__added__) { - existing.added = true; - existing.changeKinds.add("added"); - existing.changedKeys.add("__added__"); - } else if (delta?.__removed__) { - existing.removed = true; - existing.changeKinds.add("removed"); - existing.changedKeys.add("__removed__"); - } else { - for (const key of Object.keys(delta || {})) { - existing.changedKeys.add(key); - existing.changeKinds.add(parseChangeKind(key)); - } - } - - taskChanges.set(taskId, existing); - } - } - - return { taskChanges, metaChanges, orderChangedRevs }; -} - -function sortChanged(a, b) { - if ((b.lastChangedRev ?? -1) !== (a.lastChangedRev ?? -1)) { - return (b.lastChangedRev ?? -1) - (a.lastChangedRev ?? -1); - } - if ((b.priorityWeight ?? 0) !== (a.priorityWeight ?? 0)) { - return (b.priorityWeight ?? 0) - (a.priorityWeight ?? 0); - } - return a.id.localeCompare(b.id); -} - -export function buildChangedPayload({ - slug, - data, - history = [], - fromRev = 0, - limit = 20, - now = new Date().toISOString() -}) { - const context = buildProjectTaskContext({ data, history }); - const { taskChanges, metaChanges, orderChangedRevs } = collectChanges(history, fromRev); - - const changed = Array.from(taskChanges.values()) - .map((change) => { - const task = context.byId.get(change.id); - const summary = task ? summarizeTask(task, context) : null; - return { - ...(summary || { - id: change.id, - title: null, - goal: null, - status: "removed", - assignee: null, - priorityId: null, - swimlaneId: null, - effort: null, - ready: false, - blocked_kind: null, - blocking_on: [], - blocker_reason: null, - requires_approval: [], - dependenciesResolved: false, - references: [], - comment: null, - lastTouchedRev: null, - priorityWeight: 0 - }), - added: change.added, - removed: change.removed, - changedInRevs: change.changedInRevs, - lastChangedRev: change.lastChangedRev, - changeKinds: Array.from(change.changeKinds).sort(), - changedKeys: Array.from(change.changedKeys).sort() - }; - }) - .sort(sortChanged) - .slice(0, Math.max(1, Math.min(limit, 50))); - - return { - project: slug, - rev: context.currentRev, - fromRev, - generatedAt: now, - changed, - metaChanges: Array.from(metaChanges.entries()) - .map(([key, lastChangedRev]) => ({ key, lastChangedRev })) - .sort((a, b) => b.lastChangedRev - a.lastChangedRev || a.key.localeCompare(b.key)), - orderChangedRevs - }; -} - -export function getChangedPayload({ workspace, slug, entry, fromRev = 0, limit = 20, now }) { - if (!entry?.data) return null; - const history = readHistory(workspace, slug); - return buildChangedPayload({ - slug, - data: entry.data, - history, - fromRev, - limit, - now - }); -} diff --git a/hub/decisions.js b/hub/decisions.js deleted file mode 100644 index 1024ddc..0000000 --- a/hub/decisions.js +++ /dev/null @@ -1,96 +0,0 @@ -import { readHistory } from "./snapshots.js"; -import { normalizeTaskReferences } from "./references.js"; -import { buildProjectTaskContext, summarizeTask } from "./task-metadata.js"; - -const DEFAULT_DECISION_LIMIT = 20; - -function lastDecisionRev(task, history = []) { - for (let index = history.length - 1; index >= 0; index -= 1) { - const delta = history[index]?.delta?.tasks?.[task.id]; - if (!delta) continue; - if (Object.prototype.hasOwnProperty.call(delta, "comment")) { - return history[index].rev; - } - if (delta.__added__ && typeof delta.__added__.comment === "string" && delta.__added__.comment.trim()) { - return history[index].rev; - } - } - return null; -} - -function sortDecisions(a, b) { - if ((b.lastDecisionRev ?? -1) !== (a.lastDecisionRev ?? -1)) { - return (b.lastDecisionRev ?? -1) - (a.lastDecisionRev ?? -1); - } - if ((b.lastTouchedRev ?? -1) !== (a.lastTouchedRev ?? -1)) { - return (b.lastTouchedRev ?? -1) - (a.lastTouchedRev ?? -1); - } - if ((b.priorityWeight ?? 0) !== (a.priorityWeight ?? 0)) { - return (b.priorityWeight ?? 0) - (a.priorityWeight ?? 0); - } - return a.id.localeCompare(b.id); -} - -export function buildDecisionsPayload({ - slug, - data, - history = [], - limit = DEFAULT_DECISION_LIMIT, - now = new Date().toISOString() -}) { - const context = buildProjectTaskContext({ data, history }); - const all = (data?.tasks || []) - .filter((task) => typeof task.comment === "string" && task.comment.trim()) - .map((task) => { - const summary = summarizeTask(task, context); - return { - id: task.id, - title: task.title, - goal: task.goal || null, - status: summary.status, - priorityId: summary.priorityId, - swimlaneId: summary.swimlaneId, - effort: summary.effort, - comment: summary.comment, - lastTouchedRev: summary.lastTouchedRev, - lastDecisionRev: lastDecisionRev(task, history) ?? summary.lastTouchedRev, - selectedBecause: "task comment present", - references: normalizeTaskReferences(task).map((value) => ({ - value, - selectedBecause: "task reference attached to the decision" - })), - priorityWeight: summary.priorityWeight - }; - }) - .sort(sortDecisions); - - const capped = all.slice(0, Math.max(1, Math.min(limit, DEFAULT_DECISION_LIMIT))); - - return { - packType: "decisions", - project: slug, - rev: context.currentRev, - generatedAt: now, - decisions: capped.map(({ priorityWeight, ...decision }) => decision), - truncation: { - decisions: { - applied: capped.length < all.length, - returned: capped.length, - totalAvailable: all.length, - maxCount: DEFAULT_DECISION_LIMIT - } - } - }; -} - -export function getDecisionsPayload({ workspace, slug, entry, limit = DEFAULT_DECISION_LIMIT, now }) { - if (!entry?.data) return null; - const history = readHistory(workspace, slug); - return buildDecisionsPayload({ - slug, - data: entry.data, - history, - limit, - now - }); -} diff --git a/hub/error-payload.js b/hub/error-payload.js deleted file mode 100644 index 13b1387..0000000 --- a/hub/error-payload.js +++ /dev/null @@ -1,48 +0,0 @@ -export function inferTrackerErrorHint(message = "") { - if (typeof message !== "string" || message.trim() === "") return null; - - if ( - message.includes("/reference") || - message.includes("/references/") || - message.includes("reference must use") - ) { - return "Use repo-relative file references in `path:line` or `path:line-line` form. Bare URLs are invalid in `reference` and `references[]`."; - } - - if (message.includes("new tasks added through patch mode")) { - return "Add brand-new patch tasks as `not_started` or `in_progress`. If the work is already complete, deferred, or folded into an owning row, update the existing row instead of appending a new standalone task."; - } - - if (message.includes("project not found — register")) { - return "Register the project first with the full tracker file or `PUT /api/projects/:slug`, then use patch mode for updates."; - } - - return null; -} - -export function buildTrackerErrorBody({ - message, - kind = null, - type = null, - path = null, - notes = null, - timestamp = new Date().toISOString() -} = {}) { - const normalizedType = type || kind || null; - const hint = inferTrackerErrorHint(message); - const body = { - error: message || "", - type: normalizedType, - timestamp - }; - - if (hint) body.hint = hint; - if (path) body.path = path; - if (notes !== null && notes !== undefined) body.notes = notes; - - // Legacy fields kept for compatibility with any existing consumers. - body.message = body.error; - if (kind) body.kind = kind; - - return body; -} diff --git a/hub/execute.js b/hub/execute.js deleted file mode 100644 index 5aeaacc..0000000 --- a/hub/execute.js +++ /dev/null @@ -1,126 +0,0 @@ -import { buildBriefPayload, getBriefPayload } from "./briefs.js"; - -function executionContract(task) { - return { - definition_of_done: task.definition_of_done || [], - constraints: task.constraints || [], - expected_changes: task.expected_changes || [], - allowed_paths: task.allowed_paths || [], - approval_required_for: task.requires_approval || [] - }; -} - -function buildExecutionPlan(task, references = []) { - const plan = []; - - if (task.ready) { - plan.push({ - kind: "start", - text: "Task is ready for execution now" - }); - } else if (task.blocking_on?.length > 0) { - plan.push({ - kind: "blockers", - text: `Resolve blockers first: ${task.blocking_on.join(", ")}` - }); - } - - for (const path of task.expected_changes || []) { - plan.push({ - kind: "expected_change", - text: `Expect to touch ${path}` - }); - } - - if ((task.allowed_paths || []).length > 0) { - plan.push({ - kind: "allowed_paths", - text: `Keep edits within ${task.allowed_paths.join(", ")}` - }); - } - - for (const constraint of task.constraints || []) { - plan.push({ - kind: "constraint", - text: constraint - }); - } - - for (const approval of task.requires_approval || []) { - plan.push({ - kind: "approval", - text: `Stop for approval before: ${approval}` - }); - } - - if (references.length > 0) { - plan.push({ - kind: "reading_list", - text: `Start from ${references.map((reference) => reference.value).join(", ")}` - }); - } - - for (const item of task.definition_of_done || []) { - plan.push({ - kind: "done_when", - text: item - }); - } - - return plan; -} - -export function buildExecutePayload({ - slug, - data, - history = [], - taskId, - references = null, - snippets = [], - now = new Date().toISOString() -}) { - const brief = buildBriefPayload({ - slug, - data, - history, - taskId, - references, - snippets, - now - }); - if (!brief) return null; - - return { - ...brief, - packType: "execute", - readiness: { - ready: brief.task.ready, - blocked_kind: brief.task.blocked_kind, - blocking_on: brief.task.blocking_on, - requires_approval: brief.task.requires_approval - }, - executionContract: executionContract(brief.task), - executionPlan: buildExecutionPlan(brief.task, brief.references) - }; -} - -export function getExecutePayload({ workspace, slug, entry, taskId, now }) { - const result = getBriefPayload({ workspace, slug, entry, taskId, now }); - if (!result.ok) return result; - - return { - ok: true, - payload: { - ...result.payload, - packType: "execute", - readiness: { - ready: result.payload.task.ready, - blocked_kind: result.payload.task.blocked_kind, - blocking_on: result.payload.task.blocking_on, - requires_approval: result.payload.task.requires_approval - }, - executionContract: executionContract(result.payload.task), - executionPlan: buildExecutionPlan(result.payload.task, result.payload.references) - } - }; -} diff --git a/hub/merge.js b/hub/merge.js index 2d09887..e72fc05 100644 --- a/hub/merge.js +++ b/hub/merge.js @@ -11,17 +11,10 @@ export function mergeProject(existing, incoming) { const merged = JSON.parse(JSON.stringify(existing)); - // Tombstone list of task ids the human has explicitly deleted. Hub-owned — - // incoming writes cannot set or clear it, and incoming task updates that - // target a tombstoned id are dropped with a warning. - const tombstones = new Set( - Array.isArray(existing?.meta?.deleted_tasks) ? existing.meta.deleted_tasks : [] - ); - // ── meta ───────────────────────────────────────────────────────── if (incoming && incoming.meta) { for (const [k, v] of Object.entries(incoming.meta)) { - if (k === "updatedAt" || k === "rev" || k === "deleted_tasks") { + if (k === "updatedAt" || k === "rev") { notes.ignored.push(`meta.${k} is hub-owned`); continue; } @@ -29,47 +22,25 @@ export function mergeProject(existing, incoming) { merged.meta[k] = v; } if (incoming.meta.swimlanes) { - const existingLanes = existing.meta.swimlanes || []; - const incomingById = new Map((incoming.meta.swimlanes || []).map((lane) => [lane.id, lane])); - const existingIds = new Set(existingLanes.map((lane) => lane.id)); - const nextLanes = []; - - for (const prevLane of existingLanes) { - const lane = incomingById.get(prevLane.id); - if (!lane) { - nextLanes.push(prevLane); - continue; - } + const prev = new Map((existing.meta.swimlanes || []).map((l) => [l.id, l])); + merged.meta.swimlanes = incoming.meta.swimlanes.map((lane) => { + const p = prev.get(lane.id); const out = { ...lane }; - if ("collapsed" in prevLane) { - if ("collapsed" in lane && lane.collapsed !== prevLane.collapsed) { + if (p && "collapsed" in p) { + if ("collapsed" in lane && lane.collapsed !== p.collapsed) { notes.ignored.push( `meta.swimlanes[${lane.id}].collapsed is human-owned (kept existing)` ); } - out.collapsed = prevLane.collapsed; + out.collapsed = p.collapsed; } else if ("collapsed" in lane) { notes.ignored.push( `meta.swimlanes[${lane.id}].collapsed is human-owned (dropped)` ); delete out.collapsed; } - nextLanes.push(out); - } - - for (const lane of incoming.meta.swimlanes) { - if (existingIds.has(lane.id)) continue; - const out = { ...lane }; - if ("collapsed" in lane) { - notes.ignored.push( - `meta.swimlanes[${lane.id}].collapsed is human-owned (dropped)` - ); - delete out.collapsed; - } - nextLanes.push(out); - } - - merged.meta.swimlanes = nextLanes; + return out; + }); } } @@ -99,16 +70,11 @@ export function mergeProject(existing, incoming) { } } for (const inc of incomingArr) { - if (existingIds.has(inc.id)) continue; - if (tombstones.has(inc.id)) { - notes.ignored.push( - `task ${inc.id} was deleted by a human; refusing to resurrect (pick a new id if you need to reopen the work)` - ); - continue; + if (!existingIds.has(inc.id)) { + const defaulted = { dependencies: [], ...inc }; + next.push(defaulted); + notes.appended.push(inc.id); } - const defaulted = { dependencies: [], ...inc }; - next.push(defaulted); - notes.appended.push(inc.id); } merged.tasks = next; } diff --git a/hub/next.js b/hub/next.js deleted file mode 100644 index f626661..0000000 --- a/hub/next.js +++ /dev/null @@ -1,106 +0,0 @@ -import { readHistory } from "./snapshots.js"; -import { buildProjectTaskContext, summarizeTask } from "./task-metadata.js"; - -const EFFORT_BONUS = { - xs: 8, - s: 5, - m: 2, - l: -2, - xl: -6 -}; -const AGGREGATE_PENALTY = 100; - -function freshnessBonus(lastTouchedRev, currentRev) { - if (!Number.isInteger(lastTouchedRev) || !Number.isInteger(currentRev)) return 0; - const age = currentRev - lastTouchedRev; - if (age <= 1) return 4; - if (age <= 3) return 2; - if (age <= 7) return 1; - return 0; -} - -function scoreTask(summary, currentRev) { - let score = summary.priorityWeight; - if (summary.dependenciesResolved) score += 25; - if (summary.status === "in_progress") score += 15; - if (summary.references.length > 0) score += 10; - if (summary.comment) score += 8; - score += EFFORT_BONUS[summary.effort] ?? 0; - score += freshnessBonus(summary.lastTouchedRev, currentRev); - if (summary.requires_approval.length > 0) score -= 12; - if (summary.aggregate) score -= AGGREGATE_PENALTY; - return score; -} - -function buildReason(summary, index) { - const reasons = []; - - if (summary.ready) { - reasons.push(index === 0 ? "highest-ranked ready task" : "ready for work now"); - } else if (summary.blocked_kind === "deps" && summary.blocking_on.length > 0) { - reasons.push(`blocked on dependencies: ${summary.blocking_on.join(", ")}`); - } - - if (summary.priorityId) reasons.push(`${summary.priorityId} priority`); - if (summary.aggregate) reasons.push("aggregate roadmap/container row"); - if (summary.dependenciesResolved) reasons.push("dependencies satisfied"); - if (summary.references.length > 0) reasons.push("explicit references available"); - if (summary.comment) reasons.push("decision note present"); - if (summary.status === "in_progress") reasons.push("already in progress"); - if (summary.effort) reasons.push(`estimated effort ${summary.effort}`); - if (summary.requires_approval.length > 0) { - reasons.push(`requires approval for ${summary.requires_approval.join(", ")}`); - } - - return reasons; -} - -function sortSummaries(a, b) { - if (a.ready !== b.ready) return a.ready ? -1 : 1; - if (a.aggregate !== b.aggregate) return a.aggregate ? 1 : -1; - if (a.status !== b.status) return a.status === "in_progress" ? -1 : 1; - if (b.score !== a.score) return b.score - a.score; - if (a.priorityWeight !== b.priorityWeight) return b.priorityWeight - a.priorityWeight; - return a.id.localeCompare(b.id); -} - -export function buildNextPayload({ slug, data, history = [], limit = 5, now = new Date().toISOString() }) { - const context = buildProjectTaskContext({ data, history }); - - const summaries = (data?.tasks || []) - .filter((task) => task.status === "not_started" || task.status === "in_progress") - .map((task) => { - const summary = summarizeTask(task, context); - return { - ...summary, - score: scoreTask(summary, context.currentRev) - }; - }) - .sort(sortSummaries) - .slice(0, Math.max(1, Math.min(limit, 5))); - - const ranked = summaries.map((summary, index) => ({ - ...summary, - reason: buildReason(summary, index) - })); - - return { - project: slug, - rev: context.currentRev, - generatedAt: now, - recommendedTaskId: ranked[0]?.id ?? null, - next: ranked - }; -} - -export function getNextPayload({ workspace, slug, entry, limit = 5, now }) { - if (!entry?.data) return null; - const history = readHistory(workspace, slug); - return buildNextPayload({ - slug, - data: entry.data, - history, - limit, - now - }); -} diff --git a/hub/pick.js b/hub/pick.js deleted file mode 100644 index f203c9e..0000000 --- a/hub/pick.js +++ /dev/null @@ -1,125 +0,0 @@ -import { buildNextPayload } from "./next.js"; -import { buildProjectTaskContext, summarizeTask } from "./task-metadata.js"; - -function blockedMessage(summary) { - if (summary.blocking_on.length === 0) { - return `task "${summary.id}" is not ready to pick`; - } - return `task "${summary.id}" is blocked on dependencies: ${summary.blocking_on.join(", ")}`; -} - -function assigneeConflict(summary, assignee) { - if (summary.status !== "in_progress" || !summary.assignee) return null; - if (!assignee) { - return `task "${summary.id}" is already in progress by ${summary.assignee}; pass --assignee ${summary.assignee} or force the claim`; - } - if (assignee !== summary.assignee) { - return `task "${summary.id}" is already in progress by ${summary.assignee}`; - } - return null; -} - -function validatePickCandidate(summary, { assignee = null, force = false } = {}) { - if (!summary) { - return { ok: false, status: 404, message: "task not found" }; - } - if (summary.status === "complete" || summary.status === "deferred") { - return { - ok: false, - status: 409, - message: `task "${summary.id}" cannot be picked from status ${summary.status}` - }; - } - if (!summary.ready && !force) { - return { - ok: false, - status: 409, - message: blockedMessage(summary) - }; - } - const conflict = assigneeConflict(summary, assignee); - if (conflict && !force) { - return { - ok: false, - status: 409, - message: conflict - }; - } - return { ok: true }; -} - -export function resolvePickSelection({ - slug, - data, - history = [], - taskId, - assignee = null, - force = false -}) { - const context = buildProjectTaskContext({ data, history }); - - if (taskId) { - const task = context.byId.get(taskId); - const summary = task ? summarizeTask(task, context) : null; - const verdict = validatePickCandidate(summary, { assignee, force }); - if (!verdict.ok) return verdict; - return { - ok: true, - taskId: summary.id, - autoSelected: false, - selectedBecause: "explicit task selection", - task: summary - }; - } - - const next = buildNextPayload({ slug, data, history, limit: 5 }); - let firstConflict = null; - for (const candidate of next.next) { - if (!candidate.ready) continue; - const verdict = validatePickCandidate(candidate, { assignee, force }); - if (verdict.ok) { - return { - ok: true, - taskId: candidate.id, - autoSelected: true, - selectedBecause: candidate.reason?.[0] || "top ready task from next ranking", - task: candidate - }; - } - if (!firstConflict) firstConflict = verdict; - } - - return ( - firstConflict || { - ok: false, - status: 409, - message: "no ready task available to pick" - } - ); -} - -export function buildPickedPayload({ - slug, - data, - history = [], - taskId, - autoSelected = false, - selectedBecause = null, - noop = false, - now = new Date().toISOString() -}) { - const context = buildProjectTaskContext({ data, history }); - const task = context.byId.get(taskId); - if (!task) return null; - - return { - project: slug, - rev: data?.meta?.rev ?? null, - generatedAt: now, - pickedTaskId: taskId, - autoSelected, - noop, - selectedBecause, - task: summarizeTask(task, context) - }; -} diff --git a/hub/progress.js b/hub/progress.js index 3a24b8b..eaee469 100644 --- a/hub/progress.js +++ b/hub/progress.js @@ -1,4 +1,4 @@ -import { STATUS_VALUES } from "./status-vocabulary.js"; +import { STATUS_VALUES } from "./validator.js"; const SCORE = { not_started: 0, in_progress: 0.5, complete: 1, deferred: 0 }; diff --git a/hub/project-loader.js b/hub/project-loader.js deleted file mode 100644 index d120b9c..0000000 --- a/hub/project-loader.js +++ /dev/null @@ -1,87 +0,0 @@ -import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { join } from "node:path"; -import { deriveProject } from "./progress.js"; -import { loadProjectWithRuntimeOverlay } from "./runtime-overlay.js"; -import { normalizeProjectStatuses } from "./status-vocabulary.js"; -import { validateProject } from "./validator.js"; - -export function readWorkspaceHelp(workspace) { - const readmePath = join(workspace, "README.md"); - if (!existsSync(readmePath)) { - return { ok: false, status: 404, message: "workspace README not found", path: readmePath }; - } - return { - ok: true, - path: readmePath, - text: readFileSync(readmePath, "utf-8") - }; -} - -export function trackerFilePath(workspace, slug) { - return join(workspace, "trackers", `${slug}.json`); -} - -function loadProjectFile(workspace, path, slug) { - if (!existsSync(path)) { - return { ok: false, status: 404, message: "project not found", slug, path }; - } - - try { - const raw = readFileSync(path, "utf-8"); - const parsed = JSON.parse(raw); - const notes = { warnings: [] }; - const normalized = normalizeProjectStatuses(parsed, notes).data; - const loaded = loadProjectWithRuntimeOverlay({ - workspace, - slug, - trackerPath: path, - baseProject: normalized - }); - const validation = validateProject(loaded.data); - if (!validation.ok) { - return { - ok: false, - status: 400, - message: validation.errors.join("; "), - slug, - path - }; - } - - return { - ok: true, - slug, - path, - data: loaded.data, - base: loaded.base, - derived: deriveProject(loaded.data), - rev: loaded.data?.meta?.rev ?? null, - notes - }; - } catch (error) { - return { - ok: false, - status: 400, - message: `parse: ${error.message}`, - slug, - path - }; - } -} - -export function loadProjectEntry(workspace, slug) { - return loadProjectFile(workspace, trackerFilePath(workspace, slug), slug); -} - -export function listProjectEntries(workspace) { - const dir = join(workspace, "trackers"); - if (!existsSync(dir)) return []; - - return readdirSync(dir) - .filter((name) => name.endsWith(".json") && !name.endsWith(".errors.json")) - .sort() - .map((name) => { - const slug = name.slice(0, -5); - return loadProjectFile(workspace, join(dir, name), slug); - }); -} diff --git a/hub/references.js b/hub/references.js deleted file mode 100644 index 6cd84d7..0000000 --- a/hub/references.js +++ /dev/null @@ -1,38 +0,0 @@ -export const REFERENCE_PATTERN_SOURCE = "^.+:\\d+(-\\d+)?$"; -export const REFERENCE_PATTERN = new RegExp(REFERENCE_PATTERN_SOURCE); - -export const EFFORT_VALUES = ["xs", "s", "m", "l", "xl"]; - -export function isReferenceString(value) { - return typeof value === "string" && REFERENCE_PATTERN.test(value); -} - -export function normalizeTaskReferences(task) { - if (!task || typeof task !== "object") return []; - - const out = []; - const seen = new Set(); - - const add = (value) => { - if (!isReferenceString(value)) return; - if (seen.has(value)) return; - seen.add(value); - out.push(value); - }; - - if (Array.isArray(task.references)) { - for (const ref of task.references) add(ref); - } - - add(task.reference); - - return out; -} - -export function hasNormalizedReferences(task) { - return normalizeTaskReferences(task).length > 0; -} - -export function normalizeEffort(value) { - return EFFORT_VALUES.includes(value) ? value : null; -} diff --git a/hub/routes/intelligence.js b/hub/routes/intelligence.js deleted file mode 100644 index edde087..0000000 --- a/hub/routes/intelligence.js +++ /dev/null @@ -1,188 +0,0 @@ -import { getBriefPayload } from "../briefs.js"; -import { getBlockersPayload } from "../blockers.js"; -import { getChangedPayload } from "../changed.js"; -import { getDecisionsPayload } from "../decisions.js"; -import { getExecutePayload } from "../execute.js"; -import { getNextPayload } from "../next.js"; -import { getFuzzyPayload, getSearchPayload } from "../search.js"; -import { getVerifyPayload } from "../verify.js"; -import { getWhyPayload } from "../why.js"; - -function clampLimit(value, fallback, max) { - const parsed = parseInt(Array.isArray(value) ? value[0] : value, 10); - if (isNaN(parsed) || parsed < 1) return fallback; - return Math.min(parsed, max); -} - -export function registerIntelligenceRoutes(app, { workspace, store }) { - const queryValue = (value) => (Array.isArray(value) ? value[0] : value); - const requireQuery = (value) => (typeof value === "string" && value.trim() ? value.trim() : null); - - const pickHandler = async (req, res) => { - const body = req.body || {}; - const result = await store.pickTask(req.params.slug, { - taskId: body.taskId, - assignee: typeof body.assignee === "string" && body.assignee.trim() ? body.assignee : null, - force: body.force === true, - comment: body.comment - }); - if (!result.ok) return res.status(result.status || 400).json({ error: result.message }); - res.json(result.payload); - }; - - app.get("/api/projects/:slug/next", (req, res) => { - const entry = store.get(req.params.slug); - if (!entry) return res.status(404).json({ error: "not found" }); - - const payload = getNextPayload({ - workspace, - slug: req.params.slug, - entry, - limit: clampLimit(req.query.limit, 5, 5) - }); - res.json(payload); - }); - - app.get("/api/projects/:slug/search", async (req, res) => { - const entry = store.get(req.params.slug); - if (!entry) return res.status(404).json({ error: "not found" }); - - const query = requireQuery(queryValue(req.query.q)); - if (!query) return res.status(400).json({ error: "q is required" }); - - const result = await getSearchPayload({ - workspace, - slug: req.params.slug, - entry, - query, - limit: clampLimit(req.query.limit, 10, 50) - }); - if (result?.ok === false) { - return res.status(result.status || 503).json({ error: result.message }); - } - res.json(result); - }); - - const fuzzyHandler = (req, res) => { - const entry = store.get(req.params.slug); - if (!entry) return res.status(404).json({ error: "not found" }); - - const query = requireQuery(queryValue(req.query.q)); - if (!query) return res.status(400).json({ error: "q is required" }); - - const result = getFuzzyPayload({ - slug: req.params.slug, - entry, - query, - limit: clampLimit(req.query.limit, 10, 50) - }); - res.json(result); - }; - - app.get("/api/projects/:slug/fuzzy", fuzzyHandler); - app.get("/api/projects/:slug/fuzzy-search", fuzzyHandler); - - app.get("/api/projects/:slug/tasks/:taskId/brief", (req, res) => { - const entry = store.get(req.params.slug); - if (!entry) return res.status(404).json({ error: "not found" }); - - const result = getBriefPayload({ - workspace, - slug: req.params.slug, - entry, - taskId: req.params.taskId - }); - if (!result.ok) return res.status(result.status || 400).json({ error: result.message }); - res.json(result.payload); - }); - - app.get("/api/projects/:slug/tasks/:taskId/why", (req, res) => { - const entry = store.get(req.params.slug); - if (!entry) return res.status(404).json({ error: "not found" }); - - const result = getWhyPayload({ - workspace, - slug: req.params.slug, - entry, - taskId: req.params.taskId - }); - if (!result.ok) return res.status(result.status || 400).json({ error: result.message }); - res.json(result.payload); - }); - - app.get("/api/projects/:slug/tasks/:taskId/execute", (req, res) => { - const entry = store.get(req.params.slug); - if (!entry) return res.status(404).json({ error: "not found" }); - - const result = getExecutePayload({ - workspace, - slug: req.params.slug, - entry, - taskId: req.params.taskId - }); - if (!result.ok) return res.status(result.status || 400).json({ error: result.message }); - res.json(result.payload); - }); - - app.get("/api/projects/:slug/tasks/:taskId/verify", (req, res) => { - const entry = store.get(req.params.slug); - if (!entry) return res.status(404).json({ error: "not found" }); - - const result = getVerifyPayload({ - workspace, - slug: req.params.slug, - entry, - taskId: req.params.taskId - }); - if (!result.ok) return res.status(result.status || 400).json({ error: result.message }); - res.json(result.payload); - }); - - app.get("/api/projects/:slug/blockers", (req, res) => { - const entry = store.get(req.params.slug); - if (!entry) return res.status(404).json({ error: "not found" }); - - const payload = getBlockersPayload({ - workspace, - slug: req.params.slug, - entry - }); - res.json(payload); - }); - - app.get("/api/projects/:slug/changed", (req, res) => { - const entry = store.get(req.params.slug); - if (!entry) return res.status(404).json({ error: "not found" }); - - const fromRevRaw = Array.isArray(req.query.fromRev) ? req.query.fromRev[0] : req.query.fromRev; - const fromRev = fromRevRaw === undefined ? 0 : parseInt(fromRevRaw, 10); - if (isNaN(fromRev) || fromRev < 0) { - return res.status(400).json({ error: "fromRev must be a non-negative integer" }); - } - - const payload = getChangedPayload({ - workspace, - slug: req.params.slug, - entry, - fromRev, - limit: clampLimit(req.query.limit, 20, 50) - }); - res.json(payload); - }); - - app.get("/api/projects/:slug/decisions", (req, res) => { - const entry = store.get(req.params.slug); - if (!entry) return res.status(404).json({ error: "not found" }); - - const payload = getDecisionsPayload({ - workspace, - slug: req.params.slug, - entry, - limit: clampLimit(req.query.limit, 20, 20) - }); - res.json(payload); - }); - - app.post("/api/projects/:slug/pick", pickHandler); - app.post("/api/projects/:slug/claim", pickHandler); -} diff --git a/hub/runtime-overlay.js b/hub/runtime-overlay.js deleted file mode 100644 index 338cb95..0000000 --- a/hub/runtime-overlay.js +++ /dev/null @@ -1,180 +0,0 @@ -import { - existsSync, - lstatSync, - mkdirSync, - realpathSync, - readFileSync, - renameSync, - unlinkSync, - writeFileSync -} from "node:fs"; -import { join } from "node:path"; -import { runtimeDir } from "./runtime.js"; - -const META_RUNTIME_FIELDS = ["scratchpad", "updatedAt", "rev"]; -const TASK_RUNTIME_FIELDS = ["status", "assignee", "blocker_reason", "updatedAt", "rev"]; - -function clone(value) { - return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); -} - -function atomicWriteJson(file, data) { - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tmp, JSON.stringify(data, null, 2)); - renameSync(tmp, file); -} - -function ensureOverlayDir(workspace) { - const dir = join(runtimeDir(workspace), "overlays"); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function taskRuntimeState(task) { - const out = {}; - for (const field of TASK_RUNTIME_FIELDS) { - if (field in task) out[field] = clone(task[field]); - } - return out; -} - -export function runtimeOverlayPath(workspace, slug) { - return join(runtimeDir(workspace), "overlays", `${slug}.json`); -} - -export function isOverlayBackedTracker(trackerPath) { - try { - return lstatSync(trackerPath).isSymbolicLink(); - } catch { - return false; - } -} - -function durableWritePath(trackerPath, overlayEnabled) { - if (!overlayEnabled) return trackerPath; - try { - return realpathSync(trackerPath); - } catch { - return trackerPath; - } -} - -export function readRuntimeOverlay(workspace, slug) { - const file = runtimeOverlayPath(workspace, slug); - if (!existsSync(file)) return null; - try { - return JSON.parse(readFileSync(file, "utf-8")); - } catch { - return null; - } -} - -export function clearRuntimeOverlay(workspace, slug) { - const file = runtimeOverlayPath(workspace, slug); - if (!existsSync(file)) return; - try { - unlinkSync(file); - } catch {} -} - -export function applyRuntimeOverlay(baseProject, overlay) { - const data = clone(baseProject); - if (!overlay || !data) return data; - - if (overlay.meta && data.meta) { - for (const field of META_RUNTIME_FIELDS) { - if (field in overlay.meta) data.meta[field] = clone(overlay.meta[field]); - } - } - - if (overlay.tasks && Array.isArray(data.tasks)) { - const overlayTasks = overlay.tasks || {}; - data.tasks = data.tasks.map((task) => { - const runtime = overlayTasks[task.id]; - if (!runtime) return task; - const next = { ...task }; - for (const field of TASK_RUNTIME_FIELDS) { - if (field in runtime) next[field] = clone(runtime[field]); - } - return next; - }); - } - - return data; -} - -export function loadProjectWithRuntimeOverlay({ workspace, slug, trackerPath, baseProject }) { - const overlayEnabled = isOverlayBackedTracker(trackerPath); - const base = clone(baseProject); - if (!overlayEnabled) { - return { base, data: clone(baseProject), overlayEnabled }; - } - return { - base, - data: applyRuntimeOverlay(baseProject, readRuntimeOverlay(workspace, slug)), - overlayEnabled - }; -} - -export function splitRuntimeOverlay(baseProject, effectiveProject) { - const previousBase = clone(baseProject) || clone(effectiveProject); - const base = clone(previousBase); - const overlay = { meta: {}, tasks: {} }; - - if (effectiveProject?.meta) { - base.meta = base.meta || {}; - for (const [key, value] of Object.entries(effectiveProject.meta)) { - if (META_RUNTIME_FIELDS.includes(key)) { - overlay.meta[key] = clone(value); - continue; - } - base.meta[key] = clone(value); - } - for (const field of META_RUNTIME_FIELDS) { - if (previousBase?.meta && field in previousBase.meta) { - base.meta[field] = clone(previousBase.meta[field]); - } - } - } - - const baseById = new Map((previousBase?.tasks || []).map((task) => [task.id, task])); - base.tasks = (effectiveProject?.tasks || []).map((task) => { - const previousTask = baseById.get(task.id); - const nextTask = clone(previousTask) || clone(task); - for (const [key, value] of Object.entries(task)) { - if (TASK_RUNTIME_FIELDS.includes(key)) continue; - nextTask[key] = clone(value); - } - overlay.tasks[task.id] = taskRuntimeState(task); - return nextTask; - }); - - return { base, overlay }; -} - -export function persistProjectWithRuntimeOverlay({ - workspace, - slug, - trackerPath, - baseProject, - effectiveProject, - overlayEnabled -}) { - if (!overlayEnabled) { - atomicWriteJson(trackerPath, effectiveProject); - clearRuntimeOverlay(workspace, slug); - return { - base: clone(effectiveProject), - overlayEnabled: false - }; - } - - const { base, overlay } = splitRuntimeOverlay(baseProject, effectiveProject); - atomicWriteJson(durableWritePath(trackerPath, true), base); - ensureOverlayDir(workspace); - atomicWriteJson(runtimeOverlayPath(workspace, slug), overlay); - return { - base, - overlayEnabled: true - }; -} diff --git a/hub/runtime.js b/hub/runtime.js deleted file mode 100644 index 080dc5e..0000000 --- a/hub/runtime.js +++ /dev/null @@ -1,98 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { - existsSync, - mkdirSync, - readFileSync, - renameSync, - unlinkSync, - writeFileSync -} from "node:fs"; -import { join } from "node:path"; - -function isZombieProcess(pid) { - if (process.platform === "win32") return false; - try { - const result = spawnSync("ps", ["-p", String(pid), "-o", "state="], { - encoding: "utf-8" - }); - if (result.status !== 0) return false; - return (result.stdout || "") - .trim() - .split(/\s+/) - .some((state) => state.startsWith("Z")); - } catch { - return false; - } -} - -export function runtimeDir(workspace) { - return join(workspace, ".runtime"); -} - -export function ensureRuntimeDir(workspace) { - const dir = runtimeDir(workspace); - mkdirSync(dir, { recursive: true }); - return dir; -} - -export function daemonMetaPath(workspace) { - return join(runtimeDir(workspace), "daemon.json"); -} - -export function daemonLogPath(workspace) { - return join(runtimeDir(workspace), "daemon.log"); -} - -function atomicWriteJson(file, data) { - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tmp, JSON.stringify(data, null, 2)); - renameSync(tmp, file); -} - -export function readDaemonMeta(workspace) { - const file = daemonMetaPath(workspace); - if (!existsSync(file)) return null; - try { - return JSON.parse(readFileSync(file, "utf-8")); - } catch { - return null; - } -} - -export function writeDaemonMeta(workspace, meta) { - ensureRuntimeDir(workspace); - atomicWriteJson(daemonMetaPath(workspace), meta); -} - -export function removeDaemonMeta(workspace) { - const file = daemonMetaPath(workspace); - if (!existsSync(file)) return; - try { - unlinkSync(file); - } catch {} -} - -export function isPidRunning(pid) { - if (!Number.isInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return !isZombieProcess(pid); - } catch (e) { - return e?.code === "EPERM"; - } -} - -export function getDaemonStatus(workspace) { - const meta = readDaemonMeta(workspace); - const logFile = meta?.logFile || daemonLogPath(workspace); - if (!meta) { - return { running: false, stale: false, meta: null, logFile }; - } - const running = isPidRunning(meta.pid); - return { - running, - stale: !running, - meta, - logFile - }; -} diff --git a/hub/search.js b/hub/search.js deleted file mode 100644 index b2bf715..0000000 --- a/hub/search.js +++ /dev/null @@ -1,772 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { buildProjectTaskContext, summarizeTask } from "./task-metadata.js"; - -const DEFAULT_LIMIT = 10; -const MAX_LIMIT = 50; -const MIN_SEMANTIC_SCORE = 0.18; -const MIN_FUZZY_SCORE = 0.22; -const EMBEDDING_MODEL = process.env.LLM_TRACKER_EMBEDDING_MODEL || "Xenova/all-MiniLM-L6-v2"; -const LOCAL_HASH_VECTOR_SIZE = 384; -const ORT_SYMBOL = Symbol.for("onnxruntime"); -const SEARCH_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const TRANSFORMERS_WEB_MODULE = resolve( - SEARCH_ROOT, - "node_modules", - "@huggingface", - "transformers", - "dist", - "transformers.web.js" -); -const ONNXRUNTIME_WEB_DIST = resolve(SEARCH_ROOT, "node_modules", "onnxruntime-web", "dist"); -const WASM_FACTORY_FILE = "ort-wasm-simd-threaded.asyncify.mjs"; -const WASM_BINARY_FILE = "ort-wasm-simd-threaded.asyncify.wasm"; - -let extractorFactoryOverride = null; -let nativeExtractorFactoryOverride = null; -let wasmExtractorFactoryOverride = null; -let onnxWebModuleLoaderOverride = null; -let transformersWebModuleLoaderOverride = null; -let extractorPromise = null; -let semanticActivated = false; -let firstRunHintPrinted = false; - -function printFirstRunHintOnce() { - if (firstRunHintPrinted) return; - firstRunHintPrinted = true; - console.error("[llm-tracker] preparing semantic search backend — first run downloads model weights, this may take a minute"); -} - -const semanticIndexCache = new Map(); - -function clampLimit(value, fallback = DEFAULT_LIMIT) { - const parsed = parseInt(value, 10); - if (isNaN(parsed) || parsed < 1) return fallback; - return Math.min(parsed, MAX_LIMIT); -} - -function cacheKey(workspace, slug) { - return `${workspace || ""}::${slug}`; -} - -function normalizeText(value) { - if (typeof value !== "string") return ""; - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .trim(); -} - -function tokenize(text) { - const normalized = normalizeText(text); - return normalized ? normalized.split(/\s+/).filter(Boolean) : []; -} - -function trigramSet(text) { - const normalized = normalizeText(text).replace(/\s+/g, " "); - if (!normalized) return new Set(); - if (normalized.length <= 3) return new Set([normalized]); - const out = new Set(); - for (let index = 0; index <= normalized.length - 3; index += 1) { - out.add(normalized.slice(index, index + 3)); - } - return out; -} - -function diceCoefficient(setA, setB) { - if (!setA.size || !setB.size) return 0; - let overlap = 0; - for (const value of setA) { - if (setB.has(value)) overlap += 1; - } - return (2 * overlap) / (setA.size + setB.size); -} - -function tokenCoverage(queryTokens, candidateTokens) { - if (!queryTokens.length || !candidateTokens.length) return 0; - const candidate = new Set(candidateTokens); - let matches = 0; - for (const token of queryTokens) { - if (candidate.has(token)) matches += 1; - } - return matches / queryTokens.length; -} - -function taskTags(task) { - if (!Array.isArray(task?.context?.tags)) return []; - return task.context.tags.filter((value) => typeof value === "string" && value.trim()); -} - -function buildSearchDocument(task) { - const context = task?.context || {}; - return [ - task?.id || "", - task?.title || "", - task?.title || "", - task?.goal || "", - task?.comment || "", - task?.blocker_reason || "", - context.notes || "", - context.source_title || "", - taskTags(task).join(" ") - ] - .map((value) => (typeof value === "string" ? value.trim() : "")) - .filter(Boolean) - .join("\n"); -} - -function cosineSimilarity(vecA, vecB) { - if (!Array.isArray(vecA) || !Array.isArray(vecB) || vecA.length !== vecB.length || vecA.length === 0) { - return 0; - } - - let dotProduct = 0; - let normA = 0; - let normB = 0; - - for (let index = 0; index < vecA.length; index += 1) { - dotProduct += vecA[index] * vecB[index]; - normA += vecA[index] * vecA[index]; - normB += vecB[index] * vecB[index]; - } - - if (normA === 0 || normB === 0) return 0; - return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); -} - -function stableHash32(value) { - let hash = 2166136261; - for (let index = 0; index < value.length; index += 1) { - hash ^= value.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return hash >>> 0; -} - -function addHashedFeature(vector, feature, weight) { - const hash = stableHash32(feature); - const index = hash % LOCAL_HASH_VECTOR_SIZE; - const sign = hash & 1 ? 1 : -1; - vector[index] += sign * weight; -} - -function normalizeVector(vector) { - let magnitude = 0; - for (const value of vector) magnitude += value * value; - if (magnitude === 0) return vector; - - const scale = 1 / Math.sqrt(magnitude); - for (let index = 0; index < vector.length; index += 1) { - vector[index] *= scale; - } - return vector; -} - -function localHashVector(text) { - const normalized = normalizeText(text).replace(/\s+/g, " "); - const vector = new Float32Array(LOCAL_HASH_VECTOR_SIZE); - if (!normalized) return vector; - - const tokens = tokenize(normalized); - for (const token of tokens) { - addHashedFeature(vector, `tok:${token}`, 1.8); - } - for (let index = 0; index < tokens.length - 1; index += 1) { - addHashedFeature(vector, `bi:${tokens[index]}_${tokens[index + 1]}`, 1.1); - } - for (const trigram of trigramSet(normalized)) { - addHashedFeature(vector, `tri:${trigram}`, 0.2); - } - - return normalizeVector(vector); -} - -function createLocalHashExtractor() { - return async (input) => ({ - data: localHashVector(String(input || "")) - }); -} - -function taskExcerpt(task) { - return ( - task?.goal || - task?.comment || - task?.blocker_reason || - task?.context?.notes || - null - ); -} - -function summarizeResult(task, summary, score, extras = {}) { - return { - id: task.id, - title: task.title, - goal: task.goal || null, - status: task.status, - assignee: task.assignee ?? null, - priorityId: summary.priorityId, - swimlaneId: summary.swimlaneId, - ready: summary.ready, - aggregate: summary.aggregate, - blocked_kind: summary.blocked_kind, - blocking_on: summary.blocking_on, - references: summary.references, - comment: summary.comment, - excerpt: taskExcerpt(task), - score: Number(score.toFixed(4)), - ...(extras.matchedOn?.length ? { matchedOn: extras.matchedOn } : {}) - }; -} - -function sortMatches(a, b) { - if (b.score !== a.score) return b.score - a.score; - if (a.aggregate !== b.aggregate) return a.aggregate ? 1 : -1; - if (a.status !== b.status) return a.status === "in_progress" ? -1 : 1; - return a.id.localeCompare(b.id); -} - -function emptyPayload({ slug, rev, query, mode, now, limit, model = null }) { - return { - project: slug, - rev, - generatedAt: now, - mode, - query, - model, - backend: mode, - matches: [], - truncation: { - applied: false, - returned: 0, - totalAvailable: 0, - maxCount: limit - } - }; -} - -function contentTypeForFile(fileUrl) { - const pathname = typeof fileUrl === "string" ? fileUrl : fileUrl?.pathname || ""; - if (pathname.endsWith(".wasm")) return "application/wasm"; - if (pathname.endsWith(".mjs") || pathname.endsWith(".js")) return "text/javascript"; - if (pathname.endsWith(".json")) return "application/json"; - return "application/octet-stream"; -} - -function createLocalAwareFetch(baseFetch = globalThis.fetch?.bind(globalThis)) { - return async (input, init) => { - let url = null; - try { - url = - input instanceof URL - ? input - : typeof input === "string" - ? new URL(input) - : input?.url - ? new URL(input.url) - : null; - } catch { - url = null; - } - - if (url?.protocol === "file:") { - const body = await readFile(fileURLToPath(url)); - return new Response(body, { - status: 200, - headers: { "content-type": contentTypeForFile(url) } - }); - } - - if (!baseFetch) { - throw new Error("fetch is unavailable for semantic runtime setup"); - } - return baseFetch(input, init); - }; -} - -function configureTransformersWasmEnv(env) { - if (!env?.backends?.onnx?.wasm) return; - env.fetch = createLocalAwareFetch(env.fetch || globalThis.fetch?.bind(globalThis)); - // The web build defaults to probing /models in Node, which is not a real - // local model store for llm-tracker and produces parse errors. - env.allowLocalModels = false; - env.backends.onnx.wasm.wasmPaths = { - mjs: pathToFileURL(resolve(ONNXRUNTIME_WEB_DIST, WASM_FACTORY_FILE)).href, - wasm: pathToFileURL(resolve(ONNXRUNTIME_WEB_DIST, WASM_BINARY_FILE)).href - }; -} - -function fullErrorMessage(error) { - const seen = new Set(); - const parts = []; - let current = error; - while (current && !seen.has(current)) { - seen.add(current); - parts.push(current?.message || String(current)); - current = current?.cause; - } - return parts.join(" :: "); -} - -function isOnnxNativeBindingFailure(error) { - const message = fullErrorMessage(error); - return /onnxruntime-node|native binding|bindings file|could not locate the bindings file|dlopen|no native build|napi/i.test(message); -} - -async function loadExtractorFactory() { - if (extractorFactoryOverride) return extractorFactoryOverride; - - const factoryModule = process.env.LLM_TRACKER_EMBEDDER_MODULE; - if (factoryModule) { - const loaded = await import(pathToFileURL(resolve(factoryModule)).href); - const factory = loaded.createEmbedder || loaded.default; - if (typeof factory !== "function") { - throw new Error("LLM_TRACKER_EMBEDDER_MODULE must export a default factory or createEmbedder()"); - } - return factory; - } - - const { pipeline } = await import("@huggingface/transformers"); - return async ({ modelId }) => pipeline("feature-extraction", modelId); -} - -async function loadNativeExtractorFactory() { - if (extractorFactoryOverride) return extractorFactoryOverride; - if (nativeExtractorFactoryOverride) return nativeExtractorFactoryOverride; - return loadExtractorFactory(); -} - -async function loadWasmExtractorFactory() { - if (extractorFactoryOverride) return extractorFactoryOverride; - if (wasmExtractorFactoryOverride) return wasmExtractorFactoryOverride; - - const onnxWebModule = onnxWebModuleLoaderOverride - ? await onnxWebModuleLoaderOverride() - : await import("onnxruntime-web/webgpu"); - globalThis[ORT_SYMBOL] = onnxWebModule.default || onnxWebModule; - - const module = transformersWebModuleLoaderOverride - ? await transformersWebModuleLoaderOverride() - : await import(pathToFileURL(TRANSFORMERS_WEB_MODULE).href); - configureTransformersWasmEnv(module.env); - const { pipeline } = module; - // Avoid the Node default device ("cpu") because transformers.web does not - // populate a supported-device list when onnxruntime-web is injected via the - // global symbol. "auto" still allows the runtime to pick an execution - // provider without tripping the unsupported-device guard first. - return async ({ modelId }) => pipeline("feature-extraction", modelId, { device: "auto" }); -} - -async function initializeExtractor(factoryLoader) { - const factory = await factoryLoader(); - const extractor = await factory({ - modelId: EMBEDDING_MODEL, - task: "feature-extraction" - }); - if (typeof extractor !== "function") { - throw new Error("semantic embedder factory must return a callable extractor"); - } - return extractor; -} - -function localHashRuntimeWarning() { - return "semantic search is using the bundled local hash runtime because model runtimes are unavailable in this environment"; -} - -async function fallbackToLocalHashRuntime() { - return { - extractor: createLocalHashExtractor(), - warning: localHashRuntimeWarning(), - mode: "hash", - backend: "semantic_hash_fallback" - }; -} - -async function getExtractorRuntime() { - semanticActivated = true; - if (!extractorPromise) { - printFirstRunHintOnce(); - extractorPromise = (async () => { - try { - return { - extractor: await initializeExtractor(loadNativeExtractorFactory), - warning: null, - mode: "native", - backend: "semantic" - }; - } catch (error) { - if (isOnnxNativeBindingFailure(error)) { - return fallbackToWasmRuntime(error); - } - if (shouldHideSemanticRuntimeDetail(error)) { - return fallbackToLocalHashRuntime(error); - } - - throw error; - } - })(); - } - return extractorPromise; -} - -async function fallbackToWasmRuntime(nativeFailure) { - try { - return { - extractor: await initializeExtractor(loadWasmExtractorFactory), - warning: `semantic search is using the local wasm runtime because the native backend failed: ${nativeFailure.message}`, - mode: "wasm", - backend: "semantic" - }; - } catch (wasmError) { - if (shouldHideSemanticRuntimeDetail(wasmError)) { - return fallbackToLocalHashRuntime(wasmError, nativeFailure); - } - wasmError.cause = wasmError.cause || nativeFailure; - throw wasmError; - } -} - -async function embedText(text, runtime) { - try { - const output = await runtime.extractor(text, { pooling: "mean", normalize: true }); - return { - vector: Array.from(output?.data || []), - runtime - }; - } catch (error) { - if (runtime.mode === "native") { - if (isOnnxNativeBindingFailure(error)) { - const wasmRuntime = await fallbackToWasmRuntime(error); - extractorPromise = Promise.resolve(wasmRuntime); - try { - const output = await wasmRuntime.extractor(text, { pooling: "mean", normalize: true }); - return { - vector: Array.from(output?.data || []), - runtime: wasmRuntime - }; - } catch (wasmError) { - if (shouldHideSemanticRuntimeDetail(wasmError)) { - const hashRuntime = await fallbackToLocalHashRuntime(wasmError); - extractorPromise = Promise.resolve(hashRuntime); - const output = await hashRuntime.extractor(text, { pooling: "mean", normalize: true }); - return { - vector: Array.from(output?.data || []), - runtime: hashRuntime - }; - } - throw wasmError; - } - } - if (shouldHideSemanticRuntimeDetail(error)) { - const hashRuntime = await fallbackToLocalHashRuntime(error); - extractorPromise = Promise.resolve(hashRuntime); - const output = await hashRuntime.extractor(text, { pooling: "mean", normalize: true }); - return { - vector: Array.from(output?.data || []), - runtime: hashRuntime - }; - } - } - if (runtime.mode === "wasm" && shouldHideSemanticRuntimeDetail(error)) { - const hashRuntime = await fallbackToLocalHashRuntime(error); - extractorPromise = Promise.resolve(hashRuntime); - const output = await hashRuntime.extractor(text, { pooling: "mean", normalize: true }); - return { - vector: Array.from(output?.data || []), - runtime: hashRuntime - }; - } - throw error; - } -} - -async function ensureSemanticIndex({ workspace, slug, entry, runtime }) { - const key = cacheKey(workspace, slug); - const rev = entry?.rev ?? entry?.data?.meta?.rev ?? null; - const cached = semanticIndexCache.get(key); - if (cached && cached.rev === rev) return { items: cached.items, runtime }; - - const context = buildProjectTaskContext({ data: entry?.data }); - const tasks = (entry?.data?.tasks || []).map((task) => ({ - task, - summary: summarizeTask(task, context), - document: buildSearchDocument(task) - })); - - const items = []; - let activeRuntime = runtime; - for (const item of tasks) { - const embedded = await embedText(item.document, activeRuntime); - activeRuntime = embedded.runtime; - items.push({ - ...item, - vector: embedded.vector - }); - } - - semanticIndexCache.set(key, { rev, items }); - return { items, runtime: activeRuntime }; -} - -function queryState(rawQuery) { - const query = typeof rawQuery === "string" ? rawQuery.trim() : ""; - return { - raw: query, - normalized: normalizeText(query), - tokens: tokenize(query), - trigrams: trigramSet(query) - }; -} - -function fuzzyFieldScore(query, value) { - if (!value || !query.normalized) return 0; - const normalized = normalizeText(value); - if (!normalized) return 0; - - let score = 0; - if (normalized === query.normalized) score = 1; - else if (normalized.startsWith(query.normalized)) score = 0.97; - else if (normalized.includes(query.normalized)) score = 0.9; - - const tokens = tokenize(normalized); - const combined = - 0.55 * diceCoefficient(query.trigrams, trigramSet(normalized)) + - 0.45 * tokenCoverage(query.tokens, tokens); - - score = Math.max(score, combined); - if (query.tokens.length > 0 && query.tokens.every((token) => tokens.includes(token))) { - score = Math.min(1, score + 0.08); - } - - return score; -} - -function fuzzyTaskMatch(task, summary, query) { - const matchedOn = []; - const fieldScores = []; - let score = 0; - - const consider = (label, value) => { - const fieldScore = fuzzyFieldScore(query, value); - if (fieldScore > score) score = fieldScore; - fieldScores.push({ label, score: fieldScore }); - if (fieldScore >= 0.55) matchedOn.push(label); - }; - - consider("id", task.id); - consider("title", task.title); - consider("goal", task.goal || ""); - consider("comment", task.comment || ""); - consider("notes", task.context?.notes || ""); - consider("source", task.context?.source_title || ""); - for (const tag of taskTags(task)) consider("tag", tag); - - const combinedScore = fuzzyFieldScore(query, buildSearchDocument(task)); - score = Math.max(score, combinedScore * 0.95); - if (score < MIN_FUZZY_SCORE) return null; - - const selectedFields = Array.from(new Set( - matchedOn.length > 0 - ? matchedOn - : fieldScores - .filter((entry) => entry.score > 0) - .sort((a, b) => b.score - a.score) - .slice(0, 2) - .map((entry) => entry.label) - )).slice(0, 4); - - return summarizeResult(task, summary, score, { - matchedOn: selectedFields - }); -} - -export function clearSearchCachesForSlug(workspace, slug) { - semanticIndexCache.delete(cacheKey(workspace, slug)); -} - -function shouldHideSemanticRuntimeDetail(error) { - const message = fullErrorMessage(error); - return ( - isOnnxNativeBindingFailure(error) || - /unsupported device:/i.test(message) || - /failed to parse url from \/models\//i.test(message) || - /fetch failed/i.test(message) - ); -} - -function semanticUnavailableWarning(error) { - if (shouldHideSemanticRuntimeDetail(error)) { - return "semantic search unavailable in this environment; using fuzzy fallback"; - } - return `semantic search unavailable: ${error.message}`; -} - -export function setSemanticExtractorFactoryForTests(factory) { - extractorFactoryOverride = factory; - nativeExtractorFactoryOverride = null; - wasmExtractorFactoryOverride = null; - extractorPromise = null; - semanticActivated = false; - semanticIndexCache.clear(); -} - -export function setSemanticRuntimeFactoriesForTests({ nativeFactory = null, wasmFactory = null } = {}) { - extractorFactoryOverride = null; - nativeExtractorFactoryOverride = nativeFactory; - wasmExtractorFactoryOverride = wasmFactory; - extractorPromise = null; - semanticActivated = false; - semanticIndexCache.clear(); -} - -export function setSemanticWasmModuleLoadersForTests({ - onnxWebLoader = null, - transformersWebLoader = null -} = {}) { - onnxWebModuleLoaderOverride = onnxWebLoader; - transformersWebModuleLoaderOverride = transformersWebLoader; - if (!onnxWebLoader) { - delete globalThis[ORT_SYMBOL]; - } - extractorPromise = null; - semanticActivated = false; - semanticIndexCache.clear(); -} - -export async function primeSemanticIndex({ workspace, slug, entry }) { - if (!semanticActivated || !entry?.data) return null; - try { - const runtime = await getExtractorRuntime(); - await ensureSemanticIndex({ workspace, slug, entry, runtime }); - } catch { - // Search warm-up is best-effort. Query-time paths surface actual errors. - } - return null; -} - -export async function getSearchPayload({ - workspace, - slug, - entry, - query, - limit = DEFAULT_LIMIT, - now = new Date().toISOString() -}) { - if (!entry?.data) return null; - - const cappedLimit = clampLimit(limit); - const state = queryState(query); - const rev = entry.rev ?? entry.data.meta?.rev ?? null; - if (!state.raw) return emptyPayload({ slug, rev, query: "", mode: "semantic", now, limit: cappedLimit, model: EMBEDDING_MODEL }); - - try { - const runtime = await getExtractorRuntime(); - const indexed = await ensureSemanticIndex({ workspace, slug, entry, runtime }); - const embeddedQuery = await embedText(state.raw, indexed.runtime); - const all = indexed.items - .map((item) => ({ - ...summarizeResult(item.task, item.summary, cosineSimilarity(embeddedQuery.vector, item.vector)) - })) - .filter((item) => item.score >= MIN_SEMANTIC_SCORE) - .sort(sortMatches); - - const matches = all.slice(0, cappedLimit); - return { - project: slug, - rev, - generatedAt: now, - mode: "semantic", - query: state.raw, - model: EMBEDDING_MODEL, - backend: embeddedQuery.runtime.backend || "semantic", - ...(embeddedQuery.runtime.warning ? { warning: embeddedQuery.runtime.warning } : {}), - matches, - truncation: { - applied: matches.length < all.length, - returned: matches.length, - totalAvailable: all.length, - maxCount: cappedLimit - } - }; - } catch (error) { - const fallback = getFuzzyPayload({ - slug, - entry, - query: state.raw, - limit: cappedLimit, - now - }); - return { - ...fallback, - mode: "semantic", - model: EMBEDDING_MODEL, - backend: "fuzzy_fallback", - warning: semanticUnavailableWarning(error) - }; - } -} - -export function getFuzzyPayload({ - slug, - entry, - query, - limit = DEFAULT_LIMIT, - now = new Date().toISOString() -}) { - if (!entry?.data) return null; - - const cappedLimit = clampLimit(limit); - const state = queryState(query); - const rev = entry.rev ?? entry.data.meta?.rev ?? null; - if (!state.raw) return emptyPayload({ slug, rev, query: "", mode: "fuzzy", now, limit: cappedLimit }); - - const context = buildProjectTaskContext({ data: entry.data }); - const all = (entry.data.tasks || []) - .map((task) => fuzzyTaskMatch(task, summarizeTask(task, context), state)) - .filter(Boolean) - .sort(sortMatches); - - const matches = all.slice(0, cappedLimit); - return { - project: slug, - rev, - generatedAt: now, - mode: "fuzzy", - query: state.raw, - backend: "fuzzy", - matches, - truncation: { - applied: matches.length < all.length, - returned: matches.length, - totalAvailable: all.length, - maxCount: cappedLimit - } - }; -} - -export async function buildSearchPayload({ slug, data, query, limit, now, workspace = null }) { - return getSearchPayload({ - workspace, - slug, - entry: { - data, - rev: data?.meta?.rev ?? null - }, - query, - limit, - now - }); -} - -export function buildFuzzySearchPayload({ slug, data, query, limit, now }) { - return getFuzzyPayload({ - slug, - entry: { - data, - rev: data?.meta?.rev ?? null - }, - query, - limit, - now - }); -} diff --git a/hub/server.js b/hub/server.js index f34a3f3..8eb3419 100644 --- a/hub/server.js +++ b/hub/server.js @@ -1,144 +1,30 @@ import { createServer } from "node:http"; -import { randomBytes } from "node:crypto"; -import { lstatSync, readFileSync, existsSync, writeFileSync, renameSync, readdirSync, realpathSync } from "node:fs"; +import { readFileSync, existsSync, writeFileSync, renameSync } from "node:fs"; import { join, extname, dirname } from "node:path"; import { createRequire } from "node:module"; import express from "express"; +import cors from "cors"; import chokidar from "chokidar"; import { WebSocketServer } from "ws"; -import { buildTrackerErrorBody } from "./error-payload.js"; -import { registerIntelligenceRoutes } from "./routes/intelligence.js"; -import { clearSearchCachesForSlug, primeSemanticIndex } from "./search.js"; import { Store, slugFromFile } from "./store.js"; -// Watcher tuning: ignore obviously-irrelevant paths anywhere in the tree. The -// main trackers/ watcher uses native events and is already depth:0, but these -// patterns defend against accidental deep recursion via linked repo paths. -const WATCHER_IGNORED = /(^|[\\/])(node_modules|\.git|\.llm-tracker|\.runtime|\.snapshots|\.history)([\\/]|$)/; - -const LOCAL_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i; -const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); -const UI_SESSION_COOKIE = "llm_tracker_ui_session"; -const UI_SESSION_TTL_MS = 12 * 60 * 60 * 1000; - -const MAX_SCRATCHPAD_LEN = 5000; -const MAX_COMMENT_LEN = 500; -const MAX_BLOCKER_REASON_LEN = 2000; - -function isLocalOrigin(origin) { - if (!origin) return false; - return LOCAL_ORIGIN_RE.test(origin); -} - -function requestOrigin(req) { - const host = req.headers.host; - if (!host) return null; - return `http://${host}`; -} - -function isAllowedMutatingOrigin(req, origin) { - if (!origin) return false; - if (isLocalOrigin(origin)) return true; - return origin === requestOrigin(req); -} - -function parseCookies(header) { - const out = {}; - if (!header || typeof header !== "string") return out; - for (const part of header.split(";")) { - const idx = part.indexOf("="); - if (idx === -1) continue; - const key = part.slice(0, idx).trim(); - const value = part.slice(idx + 1).trim(); - if (!key) continue; - out[key] = decodeURIComponent(value); - } - return out; -} - -function patchTaskEntries(patch) { - if (!patch || typeof patch !== "object" || !patch.tasks) return []; - if (Array.isArray(patch.tasks)) { - return patch.tasks - .filter((t) => t && typeof t === "object") - .map((t) => [t.id || "", t]); - } - return Object.entries(patch.tasks).filter(([, t]) => t && typeof t === "object"); -} - -// Route-level rejection for oversized mutable fields. Runs before merge/schema -// validation so hallucinated payloads are dropped cheaply. -function checkPatchSize(body) { - if (!body || typeof body !== "object") return null; - const scratchpad = body?.meta?.scratchpad; - if (typeof scratchpad === "string" && scratchpad.length > MAX_SCRATCHPAD_LEN) { - return { - field: "meta.scratchpad", - max: MAX_SCRATCHPAD_LEN, - actual: scratchpad.length - }; - } - for (const [id, task] of patchTaskEntries(body)) { - if (typeof task.comment === "string" && task.comment.length > MAX_COMMENT_LEN) { - return { - field: `tasks[${id}].comment`, - max: MAX_COMMENT_LEN, - actual: task.comment.length - }; - } - if ( - typeof task.blocker_reason === "string" && - task.blocker_reason.length > MAX_BLOCKER_REASON_LEN - ) { - return { - field: `tasks[${id}].blocker_reason`, - max: MAX_BLOCKER_REASON_LEN, - actual: task.blocker_reason.length - }; - } - } - return null; -} - -function rejectOversizedMutableFields(req, res, next) { - const violation = checkPatchSize(req.body); - if (!violation) return next(); - return res.status(413).json({ - error: `${violation.field} is ${violation.actual} chars; limit is ${violation.max}`, - type: "field.too.large", - field: violation.field, - max: violation.max, - actual: violation.actual, - hint: "Trim the field before resubmitting. Limits exist to keep tracker files human-reviewable." - }); -} - const MIME = { ".html": "text/html; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".mjs": "application/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", - ".png": "image/png", - ".ico": "image/x-icon", ".svg": "image/svg+xml" }; function projectPayload(slug, entry) { - if (!entry) return { slug, data: null, derived: null, error: null, file: null }; - let file = entry.path || null; - if (file) { - try { - file = realpathSync(file); - } catch {} - } + if (!entry) return { slug, data: null, derived: null, error: null }; return { slug, data: entry.data, derived: entry.derived, rev: entry.rev, - error: entry.error || null, - file + error: entry.error || null }; } @@ -150,138 +36,14 @@ function snapshot(store) { return projects; } -export async function startHub({ workspace, port, uiDir, host, token } = {}) { +export async function startHub({ workspace, port, uiDir }) { const store = new Store(workspace); const app = express(); - const uiSessions = new Map(); - - const bindHost = host || process.env.LLM_TRACKER_HOST || "127.0.0.1"; - const bearerToken = - typeof token === "string" && token.length > 0 - ? token - : process.env.LLM_TRACKER_TOKEN || ""; - - const pruneUiSessions = () => { - const now = Date.now(); - for (const [id, expiresAt] of uiSessions) { - if (expiresAt <= now) uiSessions.delete(id); - } - }; - - const issueUiSession = () => { - pruneUiSessions(); - const id = randomBytes(24).toString("hex"); - uiSessions.set(id, Date.now() + UI_SESSION_TTL_MS); - return id; - }; - - const hasValidUiSession = (req) => { - pruneUiSessions(); - const cookies = parseCookies(req.headers.cookie); - const id = cookies[UI_SESSION_COOKIE]; - if (!id) return false; - const expiresAt = uiSessions.get(id); - if (!expiresAt) return false; - if (expiresAt <= Date.now()) { - uiSessions.delete(id); - return false; - } - return true; - }; - - // Periodic sweep to evict expired UI-session entries so the Map does not - // grow unbounded on a long-running hub. Each page load issues a new entry, - // so without this a multi-day hub accumulates O(page-loads) stale entries. - // The interval is unref()d so it does not keep the Node event loop alive - // when the hub shuts down cleanly (tests that spawn a daemon won't hang). - const UI_SESSION_SWEEP_INTERVAL_MS = Math.max(60_000, UI_SESSION_TTL_MS / 10); - const uiSessionSweepTimer = setInterval(pruneUiSessions, UI_SESSION_SWEEP_INTERVAL_MS); - uiSessionSweepTimer.unref(); - - // CSRF / cross-origin guard. The hub binds to loopback by default, but a - // malicious web page in a local browser can still fire cross-origin POSTs - // at the hub. Reject any mutating request whose Origin/Referer is neither - // loopback nor the exact origin serving this request. Browser fetch always - // attaches Origin on cross-origin POST, so this is a strict check. CLI/MCP/ - // curl requests usually have no Origin at all and are treated as trusted. - app.use((req, res, next) => { - if (SAFE_METHODS.has(req.method)) return next(); - const origin = req.headers.origin; - if (origin) { - if (!isAllowedMutatingOrigin(req, origin)) { - return res.status(403).json({ - error: "cross-origin request blocked", - hint: "Mutating requests must come from this hub origin or a loopback origin." - }); - } - return next(); - } - const referer = req.headers.referer; - if (referer) { - try { - const u = new URL(referer); - if (!isAllowedMutatingOrigin(req, u.origin)) { - return res.status(403).json({ - error: "cross-origin request blocked", - hint: "Mutating requests must come from this hub origin or a loopback origin." - }); - } - } catch {} - } - next(); - }); - - // Optional bearer-token guard. When LLM_TRACKER_TOKEN is set, every mutating - // request must carry a matching Authorization: Bearer header (or - // X-LLM-Tracker-Token). Same-origin browser UI requests are also allowed via - // a short-lived HttpOnly session cookie minted from the UI shell route, so - // the raw bearer token never has to be exposed to page scripts. - if (bearerToken) { - app.use((req, res, next) => { - if (SAFE_METHODS.has(req.method)) return next(); - const auth = req.headers.authorization || ""; - const m = /^Bearer\s+(.+)$/i.exec(auth); - const x = req.headers["x-llm-tracker-token"]; - if ((m && m[1] === bearerToken) || x === bearerToken || hasValidUiSession(req)) { - return next(); - } - return res.status(401).json({ - error: "missing or invalid auth", - hint: - "Set Authorization: Bearer $LLM_TRACKER_TOKEN (or X-LLM-Tracker-Token header) on mutating requests, or load the browser UI from this hub origin first." - }); - }); - } - - // Default JSON body limit. Real patches are tiny (< 10 KB is typical); - // 1 MB is already generous for full project files. Can be overridden via - // LLM_TRACKER_BODY_LIMIT for the rare case of a very large full-project PUT. - const bodyLimit = process.env.LLM_TRACKER_BODY_LIMIT || "1mb"; - app.use(express.json({ limit: bodyLimit })); - const readmePath = join(workspace, "README.md"); - - const sendWorkspaceHelp = (res) => { - if (!existsSync(readmePath)) return res.status(404).send("No README"); - res.type("text/markdown").send(readFileSync(readmePath, "utf-8")); - }; - - // Health-check endpoint — no auth required (bearer middleware only guards - // mutating methods; GET passes through unconditionally). Safe for Docker / - // Kubernetes readiness probes even when LLM_TRACKER_TOKEN is set. - app.get("/healthz", (_req, res) => { - res.json({ - ok: true, - projects: store.list().length, - uptimeSeconds: Math.floor(process.uptime()) - }); - }); + app.use(cors()); + app.use(express.json({ limit: "16mb" })); app.get("/api/workspace", (_req, res) => { - res.json({ workspace, readme: readmePath, help: "/help" }); - }); - - app.get("/help", (_req, res) => { - sendWorkspaceHelp(res); + res.json({ workspace, readme: join(workspace, "README.md") }); }); const settingsFile = join(workspace, "settings.json"); @@ -302,9 +64,6 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { }); }); - let reloadProject = async () => ({ ok: false, status: 503, message: "hub not ready" }); - let reloadAllProjects = async () => ({ reloaded: [], errors: [] }); - app.put("/api/settings", (req, res) => { const body = req.body || {}; const current = readWsSettings(); @@ -333,26 +92,17 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { }); }); - app.get("/api/projects", async (_req, res) => { - await reloadAllProjects({ broadcastUpdate: false }); + app.get("/api/projects", (_req, res) => { res.json({ projects: store.list() }); }); - app.param("slug", async (req, _res, next, slug) => { - if (!slug || store.get(slug)) return next(); - const result = await reloadProject(slug, { broadcastUpdate: true }); - if (result?.ok) req.projectReloadedFromDisk = true; - next(); - }); - app.get("/api/projects/:slug", (req, res) => { const entry = store.get(req.params.slug); if (!entry) return res.status(404).json({ error: "not found" }); res.json(projectPayload(req.params.slug, entry)); }); - registerIntelligenceRoutes(app, { workspace, store }); - app.put("/api/projects/:slug", rejectOversizedMutableFields, async (req, res) => { + app.put("/api/projects/:slug", async (req, res) => { const r = await store.createOrReplace(req.params.slug, req.body || {}); if (!r.ok) return res.status(r.status || 400).json({ error: r.message }); res.json({ ok: true, slug: req.params.slug }); @@ -361,80 +111,14 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { app.delete("/api/projects/:slug", async (req, res) => { const r = await store.deleteProject(req.params.slug); if (!r.ok) return res.status(r.status || 400).json({ error: r.message }); - broadcast({ type: "REMOVE", slug: req.params.slug }); - res.json({ ok: true, slug: req.params.slug, rev: r.deletedRev ?? null }); - }); - - app.post("/api/projects/:slug/restore", async (req, res) => { - const { rev } = req.body || {}; - const parsedRev = rev === undefined ? undefined : parseInt(rev, 10); - if (rev !== undefined && (!Number.isInteger(parsedRev) || parsedRev < 1)) { - return res.status(400).json({ error: "body.rev must be a positive integer (or omit to restore latest)" }); - } - const result = await store.restoreProject(req.params.slug, { rev: parsedRev }); - if (!result.ok) return res.status(result.status || 400).json({ error: result.message }); - const entry = store.get(req.params.slug); - primeSemanticIndex({ - workspace, - slug: req.params.slug, - entry - }); - broadcast({ - type: "UPDATE", - slug: req.params.slug, - project: projectPayload(req.params.slug, entry) - }); - res.json({ - ok: true, - slug: req.params.slug, - restoredFromRev: result.restoredFromRev, - rev: entry?.rev ?? result.newRev ?? null, - file: result.file, - loaded: !!entry - }); + res.json({ ok: true, slug: req.params.slug }); }); app.post("/api/projects/:slug/symlink", async (req, res) => { const { target } = req.body || {}; const r = await store.symlinkProject(req.params.slug, target); if (!r.ok) return res.status(r.status || 400).json({ error: r.message }); - const loaded = await reloadProject(req.params.slug); - if (!loaded?.ok) { - return res.status(500).json({ - error: `symlink created but eager load failed: ${loaded?.message || "unknown error"}`, - linkPath: r.linkPath, - target: r.target - }); - } - res.json({ - ok: true, - slug: req.params.slug, - linkPath: r.linkPath, - target: r.target, - loaded: true, - rev: store.get(req.params.slug)?.rev ?? null - }); - }); - - app.post("/api/projects/:slug/reload", async (req, res) => { - const result = await reloadProject(req.params.slug); - if (!result?.ok) return res.status(result?.status || 400).json({ error: result?.message || "reload failed" }); - res.json({ - ok: true, - slug: req.params.slug, - rev: store.get(req.params.slug)?.rev ?? null, - event: result.event, - noop: result.noop === true - }); - }); - - app.post("/api/reload", async (_req, res) => { - const result = await reloadAllProjects(); - res.json({ - ok: result.errors.length === 0, - reloaded: result.reloaded, - errors: result.errors - }); + res.json({ ok: true, slug: req.params.slug, linkPath: r.linkPath, target: r.target }); }); app.delete("/api/projects/:slug/tasks/:taskId", async (req, res) => { @@ -458,24 +142,6 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { res.json({ slug: req.params.slug, revisions }); }); - app.get("/api/projects/:slug/history", (req, res) => { - const limitRaw = Array.isArray(req.query.limit) ? req.query.limit[0] : req.query.limit; - const fromRevRaw = Array.isArray(req.query.fromRev) ? req.query.fromRev[0] : req.query.fromRev; - const limit = limitRaw === undefined ? 50 : parseInt(limitRaw, 10); - const fromRev = fromRevRaw === undefined ? 0 : parseInt(fromRevRaw, 10); - - if (isNaN(limit) || limit < 1 || limit > 200) { - return res.status(400).json({ error: "limit must be an integer between 1 and 200" }); - } - if (isNaN(fromRev) || fromRev < 0) { - return res.status(400).json({ error: "fromRev must be a non-negative integer" }); - } - - const history = store.history(req.params.slug, { fromRev, limit }); - if (!history) return res.status(404).json({ error: "project not found" }); - res.json(history); - }); - app.post("/api/projects/:slug/rollback", async (req, res) => { const { to } = req.body || {}; const toRev = parseInt(to, 10); @@ -493,56 +159,17 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { res.json({ ok: true, from: r.from, to: r.to, newRev: r.newRev }); }); - app.post("/api/projects/:slug/undo", async (req, res) => { - const result = await store.undo(req.params.slug); - if (!result.ok) return res.status(result.status || 400).json({ error: result.message }); - const entry = store.get(req.params.slug); - broadcast({ - type: "UPDATE", - slug: req.params.slug, - project: projectPayload(req.params.slug, entry) - }); - res.json({ ok: true, from: result.from, to: result.to, newRev: result.newRev, action: "undo" }); - }); - - app.post("/api/projects/:slug/redo", async (req, res) => { - const result = await store.redo(req.params.slug); - if (!result.ok) return res.status(result.status || 400).json({ error: result.message }); - const entry = store.get(req.params.slug); - broadcast({ - type: "UPDATE", - slug: req.params.slug, - project: projectPayload(req.params.slug, entry) - }); - res.json({ ok: true, from: result.from, to: result.to, newRev: result.newRev, action: "redo" }); - }); - - app.post("/api/projects/:slug/patch", rejectOversizedMutableFields, async (req, res) => { + app.post("/api/projects/:slug/patch", async (req, res) => { const patch = req.body || {}; const r = await store.applyPatch(req.params.slug, patch); if (!r.ok) { - return res.status(r.status || 400).json({ - error: r.message, - type: r.type || null, - hint: r.hint || null, - notes: r.notes || null - }); + return res.status(r.status || 400).json({ error: r.message, notes: r.notes || null }); } const entry = store.get(req.params.slug); - if (r.noop !== true && entry) { - broadcast({ - type: "UPDATE", - slug: req.params.slug, - project: projectPayload(req.params.slug, entry) - }); - } res.json({ ok: true, - rev: r.rev ?? entry?.rev ?? null, - updatedAt: entry?.data?.meta?.updatedAt ?? null, - file: projectPayload(req.params.slug, entry).file, - notes: r.notes, - noop: r.noop === true + rev: entry?.rev ?? null, + notes: r.notes }); }); @@ -553,29 +180,7 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { } const r = await store.applyCollapse(req.params.slug, { swimlaneId, collapsed }); if (!r.ok) return res.status(r.status || 400).json({ error: r.message }); - const entry = store.get(req.params.slug); - broadcast({ - type: "UPDATE", - slug: req.params.slug, - project: projectPayload(req.params.slug, entry) - }); - res.json({ ok: true, noop: r.noop === true }); - }); - - app.post("/api/projects/:slug/swimlane-move", async (req, res) => { - const { swimlaneId, direction } = req.body || {}; - if (!swimlaneId || (direction !== "up" && direction !== "down")) { - return res.status(400).json({ error: "swimlaneId and direction (up|down) required" }); - } - const r = await store.applySwimlaneMove(req.params.slug, { swimlaneId, direction }); - if (!r.ok) return res.status(r.status || 400).json({ error: r.message }); - const entry = store.get(req.params.slug); - broadcast({ - type: "UPDATE", - slug: req.params.slug, - project: projectPayload(req.params.slug, entry) - }); - res.json({ ok: true, noop: r.noop === true }); + res.json({ ok: true }); }); app.post("/api/projects/:slug/move", async (req, res) => { @@ -594,12 +199,27 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { }); app.get("/README.md", (_req, res) => { - sendWorkspaceHelp(res); + const readme = join(workspace, "README.md"); + if (!existsSync(readme)) return res.status(404).send("No README"); + res.type("text/markdown").send(readFileSync(readme, "utf-8")); }); app.get("/api/history/:slug", (req, res) => { - const history = store.history(req.params.slug, { fromRev: 0, limit: 50 }); - res.json({ lines: history?.events || [] }); + const file = join(workspace, ".history", `${req.params.slug}.jsonl`); + if (!existsSync(file)) return res.json({ lines: [] }); + const lines = readFileSync(file, "utf-8") + .split("\n") + .filter(Boolean) + .slice(-50) + .map((l) => { + try { + return JSON.parse(l); + } catch { + return null; + } + }) + .filter(Boolean); + res.json({ lines }); }); // Vendor UI deps resolved from the hub's node_modules (works for local dev @@ -622,7 +242,6 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { const f = VENDOR[request.path]; if (existsSync(f)) { res.setHeader("Content-Type", "application/javascript; charset=utf-8"); - res.setHeader("X-Content-Type-Options", "nosniff"); return res.send(readFileSync(f)); } } @@ -632,26 +251,6 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { if (!existsSync(file) || !file.startsWith(uiDir)) return next(); const type = MIME[extname(file)] || "application/octet-stream"; res.setHeader("Content-Type", type); - // All UI assets get nosniff; the HTML shell gets the full set of - // framing / referrer headers as well. - res.setHeader("X-Content-Type-Options", "nosniff"); - if (file.endsWith("index.html")) { - res.setHeader("X-Frame-Options", "DENY"); - res.setHeader("Referrer-Policy", "no-referrer"); - res.setHeader("Permissions-Policy", "interest-cohort=()"); - } - if (bearerToken && file.endsWith("index.html")) { - const existing = parseCookies(request.headers.cookie)[UI_SESSION_COOKIE]; - const reuse = existing && uiSessions.get(existing) > Date.now(); - const sessionId = reuse ? existing : issueUiSession(); - res.setHeader( - "Set-Cookie", - `${UI_SESSION_COOKIE}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${Math.floor( - UI_SESSION_TTL_MS / 1000 - )}` - ); - res.setHeader("Cache-Control", "no-store"); - } res.send(readFileSync(file)); }); @@ -668,7 +267,7 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { type: err.type || null, hint: err.type === "entity.too.large" - ? `Request body exceeds ${bodyLimit}. Split the patch into smaller chunks, or set LLM_TRACKER_BODY_LIMIT if your project is legitimately this large.` + ? "Request body exceeds 16 MB. Split the patch into smaller chunks, or open a GitHub issue if your project is legitimately this large." : err.type === "entity.parse.failed" ? "Body is not valid JSON. Check quoting / escaping / trailing commas." : undefined @@ -676,34 +275,7 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { }); const httpServer = createServer(app); - // WebSocket upgrade guard. Matches the HTTP cross-origin policy: cross-origin - // browsers cannot open /ws, so a malicious page cannot subscribe to project - // broadcasts. When a bearer token is active, also require a valid - // Authorization: Bearer header OR a live UI session cookie. - const verifyWsClient = (info, done) => { - const origin = info.req.headers.origin; - if (origin && !isAllowedMutatingOrigin(info.req, origin)) { - return done(false, 403, "cross-origin websocket rejected"); - } - if (bearerToken) { - const auth = info.req.headers.authorization || ""; - const m = /^Bearer\s+(.+)$/i.exec(auth); - const x = info.req.headers["x-llm-tracker-token"]; - if ((m && m[1] === bearerToken) || x === bearerToken || hasValidUiSession(info.req)) { - return done(true); - } - return done(false, 401, "auth required"); - } - done(true); - }; - const wss = new WebSocketServer({ server: httpServer, path: "/ws", verifyClient: verifyWsClient }); - const sockets = new Set(); - let shuttingDown = false; - - httpServer.on("connection", (socket) => { - sockets.add(socket); - socket.on("close", () => sockets.delete(socket)); - }); + const wss = new WebSocketServer({ server: httpServer, path: "/ws" }); function broadcast(msg) { const data = JSON.stringify(msg); @@ -718,72 +290,25 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { const trackersDir = join(workspace, "trackers"); const patchesDir = join(workspace, "patches"); - // Main trackers watcher uses native filesystem events. depth:0 + the ignored - // pattern prevent recursion into node_modules / .git / hub-owned sibling - // directories even if the workspace lives next to them. + // usePolling is required so symlinked trackers (Option C registration) pick + // up changes the LLM makes to the target file, which lives in a different + // directory than our native fsevents subscription. const watcher = chokidar.watch(trackersDir, { - ignoreInitial: true, + ignoreInitial: false, depth: 0, - followSymlinks: false, - ignored: WATCHER_IGNORED, - awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 40 } - }); - - // Polling watcher for symlinked tracker targets outside the workspace - // (Option C registration). Native fsevents does not forward changes from - // outside the watched tree, so we poll only the individual target file — - // not its parent directory — to keep CPU cost bounded. - const linkedTargetsWatcher = chokidar.watch([], { - ignoreInitial: true, - followSymlinks: false, + followSymlinks: true, usePolling: true, interval: 300, binaryInterval: 500, - ignored: WATCHER_IGNORED, awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 40 } }); - // slug → absolute target path currently polled via linkedTargetsWatcher - const linkedTargetsBySlug = new Map(); - const linkedSlugByTarget = new Map(); - - const trackLinkedTarget = (slug) => { - const trackerFile = join(trackersDir, `${slug}.json`); - let real; - try { - const l = lstatSync(trackerFile); - if (!l.isSymbolicLink()) return; - real = realpathSync(trackerFile); - } catch { - return; - } - if (!real || real === trackerFile) return; - const existing = linkedTargetsBySlug.get(slug); - if (existing === real) return; - if (existing) { - linkedTargetsWatcher.unwatch(existing); - linkedSlugByTarget.delete(existing); - } - linkedTargetsWatcher.add(real); - linkedTargetsBySlug.set(slug, real); - linkedSlugByTarget.set(real, slug); - }; - - const untrackLinkedTarget = (slug) => { - const target = linkedTargetsBySlug.get(slug); - if (!target) return; - linkedTargetsWatcher.unwatch(target); - linkedTargetsBySlug.delete(slug); - linkedSlugByTarget.delete(target); - }; - // Patch-file watcher (bash-less write path): any file dropped into patches/ // is applied via store.applyPatch using the slug from the filename prefix, // then removed. Errors surface as .errors.json next to the patch. const patchesWatcher = chokidar.watch(patchesDir, { ignoreInitial: true, depth: 0, - ignored: WATCHER_IGNORED, awaitWriteFinish: { stabilityThreshold: 120, pollInterval: 30 } }); @@ -802,7 +327,7 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { const errPath = patchPath.replace(/\.json$/, ".errors.json"); writeFileSync( errPath, - JSON.stringify(buildTrackerErrorBody({ message: e.message, kind: "parse", path: patchPath }), null, 2) + JSON.stringify({ timestamp: new Date().toISOString(), kind: "parse", message: e.message, path: patchPath }, null, 2) ); } catch {} return; @@ -814,13 +339,7 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { writeFileSync( errPath, JSON.stringify( - buildTrackerErrorBody({ - message: r.message, - kind: "schema", - type: r.type || "schema", - path: patchPath, - notes: r.notes || null - }), + { timestamp: new Date().toISOString(), kind: "schema", message: r.message, notes: r.notes || null, path: patchPath }, null, 2 ) @@ -828,13 +347,6 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { } catch {} return; } - if (r.noop !== true) { - broadcast({ - type: "UPDATE", - slug, - project: projectPayload(slug, store.get(slug)) - }); - } try { const { unlinkSync } = await import("node:fs"); unlinkSync(patchPath); @@ -851,7 +363,7 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { return true; }; - const ingestTrackerFile = async (filePath, { broadcastUpdate = true } = {}) => { + const handleFile = (filePath) => { if (!isTrackerFile(filePath)) return; const slug = slugFromFile(filePath); if (!slug) return; @@ -861,197 +373,49 @@ export async function startHub({ workspace, port, uiDir, host, token } = {}) { } catch { return; } - // Ingestion must hold the per-slug lock so it serializes with HTTP patch, - // UI move/drag, undo/redo, rollback, and deleteTask paths. Otherwise a - // chokidar event firing mid-write could interleave with an in-flight - // mutation and clobber state. - const result = await store.ingestLocked(filePath, raw); - if (result?.ok && result?.slug) { - const entry = store.get(result.slug); - primeSemanticIndex({ - workspace, - slug: result.slug, - entry - }); - trackLinkedTarget(result.slug); - } - if (broadcastUpdate && result?.slug && result?.event && result?.noop !== true) { - broadcast({ - type: result.event, - slug: result.slug, - project: projectPayload(result.slug, store.get(result.slug)) - }); - } - return result; - }; - - reloadProject = async (slug, { broadcastUpdate = true } = {}) => { - const filePath = join(trackersDir, `${slug}.json`); - if (!existsSync(filePath)) { - return { ok: false, status: 404, message: "project not found on disk" }; - } - return ( - (await ingestTrackerFile(filePath, { broadcastUpdate })) || { - ok: false, - status: 404, - message: "project not found on disk" - } - ); - }; - - reloadAllProjects = async ({ broadcastUpdate = true } = {}) => { - const files = existsSync(trackersDir) - ? readdirSync(trackersDir) - .filter((name) => isTrackerFile(name)) - .map((name) => join(trackersDir, name)) - : []; - - const reloaded = []; - const errors = []; - for (const filePath of files) { - const result = await ingestTrackerFile(filePath, { broadcastUpdate }); - if (!result) continue; - if (result.ok) { - reloaded.push({ - slug: result.slug, - rev: store.get(result.slug)?.rev ?? null, - event: result.event, - noop: result.noop === true - }); - } else { - errors.push({ - slug: result.slug || slugFromFile(filePath), - message: result.reason || result.message || "reload failed" - }); - } - } - return { reloaded, errors }; + const result = store.ingest(filePath, raw); + broadcast({ + type: result.event, + slug: result.slug, + project: projectPayload(result.slug, store.get(result.slug)) + }); }; - await reloadAllProjects({ broadcastUpdate: false }); - watcher - .on("add", (filePath) => ingestTrackerFile(filePath)) - .on("change", (filePath) => ingestTrackerFile(filePath)) + .on("add", handleFile) + .on("change", handleFile) .on("unlink", (filePath) => { if (!isTrackerFile(filePath)) return; - const slug = slugFromFile(filePath); - if (slug) { - clearSearchCachesForSlug(workspace, slug); - untrackLinkedTarget(slug); - } const r = store.remove(filePath); if (r) broadcast({ type: "REMOVE", slug: r.slug }); }); - linkedTargetsWatcher.on("change", (targetPath) => { - const slug = linkedSlugByTarget.get(targetPath); - if (!slug) return; - const trackerFile = join(trackersDir, `${slug}.json`); - if (existsSync(trackerFile)) ingestTrackerFile(trackerFile); - }); - linkedTargetsWatcher.on("unlink", (targetPath) => { - const slug = linkedSlugByTarget.get(targetPath); - if (!slug) return; - // Target deleted out from under us — clear tracking; the symlink in - // trackers/ is now dangling and the next read will surface that. - untrackLinkedTarget(slug); + httpServer.listen(port, () => { + const url = `http://localhost:${port}`; + console.log("─────────────────────────────────────────────────────────"); + console.log(` LLM Project Tracker — hub running`); + console.log(` UI: ${url}`); + console.log(` Workspace: ${workspace}`); + console.log(` README: ${join(workspace, "README.md")}`); + console.log("─────────────────────────────────────────────────────────"); + console.log(" Paste into your LLM to register a project:"); + console.log(""); + console.log(` Read ${join(workspace, "README.md")} and register this project as .`); + console.log(""); + console.log("─────────────────────────────────────────────────────────"); }); const shutdown = async () => { - if (shuttingDown) return; - shuttingDown = true; - - clearInterval(uiSessionSweepTimer); - try { await watcher.close(); } catch {} - try { - await linkedTargetsWatcher.close(); - } catch {} try { await patchesWatcher.close(); } catch {} - try { - for (const client of wss.clients) { - try { - client.terminate(); - } catch {} - } - await new Promise((resolve) => wss.close(() => resolve())); - } catch {} - try { - if (typeof httpServer.closeIdleConnections === "function") { - httpServer.closeIdleConnections(); - } - } catch {} - - const forceTimer = setTimeout(() => { - try { - if (typeof httpServer.closeAllConnections === "function") { - httpServer.closeAllConnections(); - } - } catch {} - for (const socket of sockets) { - try { - socket.destroy(); - } catch {} - } - }, 1500); - forceTimer.unref?.(); - - await new Promise((resolve) => httpServer.close(() => resolve())); - clearTimeout(forceTimer); - process.exit(0); + httpServer.close(() => process.exit(0)); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); - await new Promise((resolve, reject) => { - const onError = async (err) => { - httpServer.off("listening", onListening); - try { - await watcher.close(); - } catch {} - try { - await linkedTargetsWatcher.close(); - } catch {} - try { - await patchesWatcher.close(); - } catch {} - try { - wss.close(); - } catch {} - reject(err); - }; - - const onListening = () => { - httpServer.off("error", onError); - const url = `http://localhost:${port}`; - console.log("─────────────────────────────────────────────────────────"); - console.log(` LLM Project Tracker — hub running`); - console.log(` UI: ${url}`); - console.log(` Bind host: ${bindHost}`); - console.log(` Workspace: ${workspace}`); - console.log(` Help: ${url}/help`); - console.log(` README: ${readmePath}`); - if (bearerToken) { - console.log(` Auth: bearer header or local UI session required for mutating requests`); - } - console.log("─────────────────────────────────────────────────────────"); - console.log(" Paste into your LLM to register a project:"); - console.log(""); - console.log(` Read ${join(workspace, "README.md")} and register this project as .`); - console.log(""); - console.log("─────────────────────────────────────────────────────────"); - resolve(); - }; - - httpServer.once("error", onError); - httpServer.once("listening", onListening); - httpServer.listen(port, bindHost); - }); - return { httpServer, wss, store, watcher }; } diff --git a/hub/snippets.js b/hub/snippets.js deleted file mode 100644 index 982ed29..0000000 --- a/hub/snippets.js +++ /dev/null @@ -1,264 +0,0 @@ -import { createHash } from "node:crypto"; -import { - existsSync, - mkdirSync, - readFileSync, - realpathSync, - renameSync, - statSync, - writeFileSync -} from "node:fs"; -import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; -import { runtimeDir } from "./runtime.js"; - -export const SNIPPET_MAX_COUNT = 5; -export const SNIPPET_MAX_BYTES = 8 * 1024; - -const REFERENCE_CAPTURE_PATTERN = /^(.*):(\d+)(?:-(\d+))?$/; - -function atomicWriteJson(file, data) { - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tmp, JSON.stringify(data, null, 2)); - renameSync(tmp, file); -} - -function stableStringify(value) { - return JSON.stringify(value); -} - -function trackerRealPath(trackerPath) { - try { - return realpathSync(trackerPath); - } catch { - return resolve(trackerPath); - } -} - -function canonicalPath(targetPath) { - try { - return realpathSync(targetPath); - } catch { - return resolve(targetPath); - } -} - -function isWithinPath(parent, child) { - const base = resolve(parent); - const target = resolve(child); - return target === base || target.startsWith(`${base}${sep}`); -} - -function isHiddenDirectoryName(name) { - return Boolean(name) && name.startsWith(".") && name !== "." && name !== ".."; -} - -function ensureSnippetCacheDir(workspace) { - const dir = join(runtimeDir(workspace), "snippets"); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function readSnippetCache(workspace, slug) { - const file = snippetCachePath(workspace, slug); - if (!existsSync(file)) return { project: slug, snippets: {} }; - try { - return JSON.parse(readFileSync(file, "utf-8")); - } catch { - return { project: slug, snippets: {} }; - } -} - -function writeSnippetCache(workspace, slug, cache) { - ensureSnippetCacheDir(workspace); - atomicWriteJson(snippetCachePath(workspace, slug), cache); -} - -function toFileLines(text) { - if (!text) return []; - const lines = text.split(/\r?\n/); - if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); - return lines; -} - -function buildSnippetId(path, startLine, endLine) { - const safe = path.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "snippet"; - return `${safe}_${startLine}_${endLine}`; -} - -function hashSnippetText(text) { - return `sha256:${createHash("sha256").update(text).digest("hex")}`; -} - -function toSnippetRecord(parsed, indexedAtRev, text, extra = {}) { - return { - id: buildSnippetId(parsed.path, parsed.startLine, parsed.endLine), - reference: parsed.reference, - path: parsed.path, - startLine: parsed.startLine, - endLine: parsed.endLine, - text, - hash: text ? hashSnippetText(text) : null, - indexedAtRev, - ...extra - }; -} - -function cacheKey(parsed) { - return parsed.reference; -} - -function stripCacheFields(record) { - const { fileMtimeMs, fileSize, ...rest } = record; - return rest; -} - -function extractSnippetFromFile(parsed, absolutePath, indexedAtRev) { - let stat; - try { - stat = statSync(absolutePath); - } catch { - return { - snippet: toSnippetRecord(parsed, indexedAtRev, "", { error: "missing file" }), - stat: null - }; - } - - if (!stat.isFile()) { - return { - snippet: toSnippetRecord(parsed, indexedAtRev, "", { error: "referenced path is not a file" }), - stat: null - }; - } - - const lines = toFileLines(readFileSync(absolutePath, "utf-8")); - if (parsed.endLine > lines.length) { - return { - snippet: toSnippetRecord(parsed, indexedAtRev, "", { - error: `stale reference: file has ${lines.length} lines` - }), - stat - }; - } - - const text = lines.slice(parsed.startLine - 1, parsed.endLine).join("\n"); - return { - snippet: toSnippetRecord(parsed, indexedAtRev, text), - stat - }; -} - -function maybeReuseCachedSnippet(cached, parsed, indexedAtRev, stat) { - if (!cached || !stat) return null; - if (cached.reference !== parsed.reference) return null; - if (cached.path !== parsed.path) return null; - if (cached.startLine !== parsed.startLine || cached.endLine !== parsed.endLine) return null; - if (cached.fileMtimeMs !== stat.mtimeMs || cached.fileSize !== stat.size) return null; - return { - ...cached, - indexedAtRev - }; -} - -function cacheRecord(snippet, stat) { - return { - ...snippet, - fileMtimeMs: stat?.mtimeMs ?? null, - fileSize: stat?.size ?? null - }; -} - -export function parseReference(reference) { - if (typeof reference !== "string") return null; - const match = reference.match(REFERENCE_CAPTURE_PATTERN); - if (!match) return null; - - const startLine = parseInt(match[2], 10); - const endLine = parseInt(match[3] || match[2], 10); - if (isNaN(startLine) || isNaN(endLine) || startLine < 1 || endLine < startLine) return null; - - return { - reference, - path: match[1], - startLine, - endLine - }; -} - -export function snippetCachePath(workspace, slug) { - return join(runtimeDir(workspace), "snippets", `${slug}.json`); -} - -export function inferProjectRoot(workspace, trackerPath) { - const resolvedWorkspace = canonicalPath(workspace); - const resolvedTracker = trackerRealPath(trackerPath); - - if (isWithinPath(resolvedWorkspace, resolvedTracker)) { - return resolvedWorkspace; - } - - const trackerDir = dirname(resolvedTracker); - if (isHiddenDirectoryName(basename(trackerDir))) { - return dirname(trackerDir); - } - - const parentDir = dirname(trackerDir); - if (isHiddenDirectoryName(basename(parentDir))) { - return dirname(parentDir); - } - - return dirname(resolvedTracker); -} - -export function loadReferenceSnippets({ - workspace, - slug, - trackerPath, - references = [], - indexedAtRev = null -}) { - const projectRoot = inferProjectRoot(workspace, trackerPath); - const resolvedTrackerPath = trackerRealPath(trackerPath); - const existingCache = readSnippetCache(workspace, slug); - const nextCache = { - project: slug, - trackerPath: resolvedTrackerPath, - projectRoot, - snippets: { ...(existingCache?.snippets || {}) } - }; - - const snippets = []; - for (const reference of references) { - const parsed = parseReference(reference); - if (!parsed) continue; - - const key = cacheKey(parsed); - const absolutePath = isAbsolute(parsed.path) ? parsed.path : resolve(projectRoot, parsed.path); - let stat = null; - - try { - stat = statSync(absolutePath); - } catch {} - - const cached = maybeReuseCachedSnippet(nextCache.snippets[key], parsed, indexedAtRev, stat); - if (cached) { - nextCache.snippets[key] = cached; - snippets.push(stripCacheFields(cached)); - continue; - } - - const extracted = extractSnippetFromFile(parsed, absolutePath, indexedAtRev); - nextCache.snippets[key] = cacheRecord(extracted.snippet, extracted.stat); - snippets.push(stripCacheFields(extracted.snippet)); - } - - if (references.length > 0 && stableStringify(existingCache) !== stableStringify(nextCache)) { - writeSnippetCache(workspace, slug, nextCache); - } - - return { - projectRoot, - trackerPath: resolvedTrackerPath, - cachePath: snippetCachePath(workspace, slug), - snippets - }; -} diff --git a/hub/status-vocabulary.js b/hub/status-vocabulary.js deleted file mode 100644 index 118f704..0000000 --- a/hub/status-vocabulary.js +++ /dev/null @@ -1,50 +0,0 @@ -export const STATUS_VALUES = ["not_started", "in_progress", "complete", "deferred"]; -export { TASK_OUTCOME_VALUES } from "../ui/task-outcomes.js"; - -export const LEGACY_STATUS_ALIASES = { - partial: "in_progress" -}; - -function legacyStatusToCanonical(status) { - if (typeof status !== "string") return null; - return LEGACY_STATUS_ALIASES[status.trim().toLowerCase()] || null; -} - -function noteNormalization(notes, taskId, from, to) { - if (!notes?.warnings) return; - notes.warnings.push(`tasks[${taskId}].status normalized legacy value "${from}" -> "${to}"`); -} - -function normalizeTaskStatus(task, taskId, notes) { - if (!task || typeof task !== "object" || !("status" in task)) return false; - const normalized = legacyStatusToCanonical(task.status); - if (!normalized || normalized === task.status) return false; - noteNormalization(notes, taskId, task.status, normalized); - task.status = normalized; - return true; -} - -export function normalizeProjectStatuses(input, notes = null) { - if (!input || typeof input !== "object") return { data: input, changed: false }; - - const cloned = JSON.parse(JSON.stringify(input)); - let changed = false; - - if (Array.isArray(cloned.tasks)) { - cloned.tasks = cloned.tasks.map((task, index) => { - const next = task && typeof task === "object" ? { ...task } : task; - if (normalizeTaskStatus(next, next?.id || String(index), notes)) changed = true; - return next; - }); - } else if (cloned.tasks && typeof cloned.tasks === "object") { - const nextTasks = {}; - for (const [taskId, task] of Object.entries(cloned.tasks)) { - const next = task && typeof task === "object" ? { ...task } : task; - if (normalizeTaskStatus(next, taskId, notes)) changed = true; - nextTasks[taskId] = next; - } - cloned.tasks = nextTasks; - } - - return { data: cloned, changed }; -} diff --git a/hub/store.js b/hub/store.js index d3567ec..21145b1 100644 --- a/hub/store.js +++ b/hub/store.js @@ -11,27 +11,18 @@ import { } from "node:fs"; import { isAbsolute } from "node:path"; import { basename, join } from "node:path"; -import { buildTrackerErrorBody, inferTrackerErrorHint } from "./error-payload.js"; import { validateProject } from "./validator.js"; import { deriveProject } from "./progress.js"; import { mergeProject, stableEq } from "./merge.js"; import { computeDelta, hasChanges, summarize } from "./versioning.js"; -import { normalizeProjectStatuses } from "./status-vocabulary.js"; -import { - clearRuntimeOverlay, - loadProjectWithRuntimeOverlay, - persistProjectWithRuntimeOverlay -} from "./runtime-overlay.js"; import { writeSnapshot, readSnapshot, maxRev, appendHistoryEntry, historySince, - readHistory, - listRevs + readHistory } from "./snapshots.js"; -import { buildPickedPayload, resolvePickSelection } from "./pick.js"; export function slugFromFile(filePath) { const base = basename(filePath); @@ -50,11 +41,12 @@ export function errorPath(workspace, slug) { export function writeErrorFile(workspace, slug, err) { const file = errorPath(workspace, slug); - const body = buildTrackerErrorBody({ - message: err.message, + const body = { + timestamp: new Date().toISOString(), kind: err.kind, + message: err.message, path: trackerPath(workspace, slug) - }); + }; try { writeFileSync(file, JSON.stringify(body, null, 2)); } catch {} @@ -93,51 +85,6 @@ function normalizeForCompare(obj) { return copy; } -function collectPatchTaskArray(patch) { - if (!patch || typeof patch !== "object" || !patch.tasks) return []; - if (Array.isArray(patch.tasks)) return patch.tasks.filter((task) => task && typeof task === "object"); - return Object.entries(patch.tasks).map(([id, task]) => ({ id, ...(task || {}) })); -} - -function validatePatchGuardrails(existing, patch) { - if (!existing || !patch || typeof patch !== "object") return { ok: true }; - const existingIds = new Set((existing.tasks || []).map((task) => task.id)); - const forbiddenStatuses = new Set(["complete", "deferred"]); - const violations = []; - - for (const task of collectPatchTaskArray(patch)) { - if (!task?.id || existingIds.has(task.id)) continue; - if (!forbiddenStatuses.has(task.status)) continue; - violations.push(`${task.id} (${task.status})`); - } - - if (violations.length === 0) return { ok: true }; - - const message = - `new tasks added through patch mode must start as not_started or in_progress; ` + - `rejecting ${violations.join(", ")}`; - - return { - ok: false, - status: 400, - type: "schema", - message, - hint: inferTrackerErrorHint(message) - }; -} - -function latestRecordedRev(workspace, slug) { - const snapRev = maxRev(workspace, slug); - const historyRev = readHistory(workspace, slug).reduce((max, entry) => { - return Number.isInteger(entry?.rev) && entry.rev > max ? entry.rev : max; - }, 0); - return Math.max(snapRev, historyRev); -} - -function defaultNotes() { - return { ignored: [], warnings: [], appended: [], updated: [] }; -} - export class Store { constructor(workspace) { this.workspace = workspace; @@ -145,94 +92,6 @@ export class Store { this.locks = new Map(); } - _entry(slug, filePath, data, base, rev, notes = defaultNotes()) { - return { - data, - base, - derived: deriveProject(data), - path: filePath, - rev, - error: null, - notes - }; - } - - _persistProject(slug, filePath, baseProject, effectiveProject, overlayEnabled) { - return persistProjectWithRuntimeOverlay({ - workspace: this.workspace, - slug, - trackerPath: filePath, - baseProject, - effectiveProject, - overlayEnabled - }); - } - - _loadProjectState(slug, filePath, rawContents = null, notes = null) { - const raw = typeof rawContents === "string" ? rawContents : readFileSync(filePath, "utf-8"); - const parsed = JSON.parse(raw); - const normalized = normalizeProjectStatuses(parsed, notes).data; - return loadProjectWithRuntimeOverlay({ - workspace: this.workspace, - slug, - trackerPath: filePath, - baseProject: normalized - }); - } - - _restoreRevisionUnlocked(slug, current, toRev, historyMeta = {}) { - const snap = readSnapshot(this.workspace, slug, toRev); - if (!snap) { - return { ok: false, status: 404, message: `no snapshot for rev ${toRev}` }; - } - if (!current || !current.data) { - return { ok: false, status: 404, message: "project not in memory" }; - } - - const baseRev = current.rev; - const newRev = baseRev + 1; - - const newState = JSON.parse(JSON.stringify(snap)); - newState.meta.rev = newRev; - newState.meta.updatedAt = new Date().toISOString(); - - const { ok, errors } = validateProject(newState); - if (!ok) { - return { - ok: false, - status: 500, - message: `snapshot validation failed: ${errors.join("; ")}` - }; - } - - const delta = computeDelta(current.data, newState); - const persisted = this._persistProject( - slug, - current.path, - current.base || current.data, - newState, - current.overlayEnabled === true - ); - - this.projects.set( - slug, - this._entry(slug, current.path, newState, persisted.base, newRev, defaultNotes()) - ); - - writeSnapshot(this.workspace, slug, newRev, newState); - appendHistoryEntry(this.workspace, slug, { - rev: newRev, - ts: newState.meta.updatedAt, - delta, - summary: summarize(delta), - rolledBackFrom: baseRev, - rolledBackTo: toRev, - ...historyMeta - }); - - return { ok: true, from: baseRev, to: toRev, newRev }; - } - list() { const out = []; for (const [slug, p] of this.projects) { @@ -254,130 +113,104 @@ export class Store { return this.projects.get(slug) || null; } - async ingestLocked(filePath, rawContents) { - const slug = slugFromFile(filePath); - if (!slug) return { ok: false, reason: "not-a-tracker" }; - return this.withLock(slug, () => this.ingest(filePath, rawContents)); - } - ingest(filePath, rawContents) { const slug = slugFromFile(filePath); if (!slug) return { ok: false, reason: "not-a-tracker" }; - let loadedIncoming; + let incoming; try { - const normalizationNotes = { warnings: [] }; - loadedIncoming = this._loadProjectState(slug, filePath, rawContents, normalizationNotes); - let incoming = normalizeProjectStatuses(loadedIncoming.data, normalizationNotes).data; - const incomingBase = loadedIncoming.base; - const overlayEnabled = loadedIncoming.overlayEnabled; - - let prev = this.projects.get(slug); - - // Cold-start resume: if we don't have in-memory state but incoming matches - // a known snapshot at incoming.meta.rev, adopt without bumping. - if (!prev || !prev.data) { - const incomingRev = incoming?.meta?.rev ?? 0; - if (incomingRev > 0) { - const snap = readSnapshot(this.workspace, slug, incomingRev); - if (snap) { - if (stableEq(normalizeForCompare(snap), normalizeForCompare(incoming))) { - const { ok: vOk, errors: vErr } = validateProject(incoming); - if (!vOk) { - return this._recordError(slug, filePath, { kind: "schema", message: vErr.join("; ") }); - } - const entry = this._entry( - slug, - filePath, - incoming, - incomingBase, - incomingRev, - { - ignored: [], - warnings: [...normalizationNotes.warnings], - appended: [], - updated: [] - } - ); - entry.overlayEnabled = overlayEnabled; - this.projects.set(slug, entry); - clearErrorFile(this.workspace, slug); - return { ok: true, slug, event: "UPDATE", project: entry, resumed: true }; + incoming = JSON.parse(rawContents); + } catch (e) { + return this._recordError(slug, filePath, { kind: "parse", message: e.message }); + } + + let prev = this.projects.get(slug); + + // Cold-start resume: if we don't have in-memory state but incoming matches + // a known snapshot at incoming.meta.rev, adopt without bumping. + if (!prev || !prev.data) { + const incomingRev = incoming?.meta?.rev ?? 0; + if (incomingRev > 0) { + const snap = readSnapshot(this.workspace, slug, incomingRev); + if (snap) { + if (stableEq(normalizeForCompare(snap), normalizeForCompare(incoming))) { + const { ok: vOk, errors: vErr } = validateProject(incoming); + if (!vOk) { + return this._recordError(slug, filePath, { kind: "schema", message: vErr.join("; ") }); } - // Snapshot differs from incoming — treat snapshot as the prev baseline. - prev = { - data: snap, - base: incomingBase, + const derived = deriveProject(incoming); + const entry = { + data: incoming, + derived, path: filePath, rev: incomingRev, - overlayEnabled + error: null, + notes: { ignored: [], warnings: [], appended: [], updated: [] } }; + this.projects.set(slug, entry); + clearErrorFile(this.workspace, slug); + return { ok: true, slug, event: "UPDATE", project: entry, resumed: true }; } + // Snapshot differs from incoming — treat snapshot as the prev baseline. + prev = { data: snap, rev: incomingRev }; } } + } - const prevData = prev?.data || null; - const { merged, notes } = mergeProject(prevData, incoming); - if (normalizationNotes.warnings.length > 0) { - notes.warnings.push(...normalizationNotes.warnings); - } + const prevData = prev?.data || null; + const { merged, notes } = mergeProject(prevData, incoming); - const { ok, errors } = validateProject(merged); - if (!ok) { - return this._recordError(slug, filePath, { kind: "schema", message: errors.join("; ") }); - } + const { ok, errors } = validateProject(merged); + if (!ok) { + return this._recordError(slug, filePath, { kind: "schema", message: errors.join("; ") }); + } - const delta = computeDelta(prevData, merged); - if (!hasChanges(delta) && prev?.data) { - // In-memory state is unchanged. If the file on disk differs from what we - // want (e.g., LLM tried to reorder or flip collapsed), correct it without - // bumping rev. - if (!stableEq(normalizeForCompare(merged), normalizeForCompare(incoming))) { - merged.meta.rev = prev.rev; - merged.meta.updatedAt = prev.data.meta.updatedAt; - try { - this._persistProject( - slug, - filePath, - incomingBase, - merged, - overlayEnabled - ); - } catch {} - } - return { ok: true, slug, event: "UPDATE", project: prev, noop: true }; - } + const delta = computeDelta(prevData, merged); + if (!hasChanges(delta) && prev?.data) { + // In-memory state is unchanged. If the file on disk differs from what we + // want (e.g., LLM tried to reorder or flip collapsed), correct it without + // bumping rev. + if (!stableEq(normalizeForCompare(merged), normalizeForCompare(incoming))) { + merged.meta.rev = prev.rev; + merged.meta.updatedAt = prev.data.meta.updatedAt; + try { + atomicWriteJson(filePath, merged); + } catch {} + } + return { ok: true, slug, event: "UPDATE", project: prev, noop: true }; + } - const baseRev = prev?.rev ?? incoming?.meta?.rev ?? 0; - const newRev = baseRev + 1; + const baseRev = prev?.rev ?? incoming?.meta?.rev ?? 0; + const newRev = baseRev + 1; - merged.meta.rev = newRev; - merged.meta.updatedAt = new Date().toISOString(); - const persisted = this._persistProject( - slug, - filePath, - incomingBase, - merged, - overlayEnabled - ); + merged.meta.rev = newRev; + merged.meta.updatedAt = new Date().toISOString(); - writeSnapshot(this.workspace, slug, newRev, merged); - appendHistoryEntry(this.workspace, slug, { - rev: newRev, - ts: merged.meta.updatedAt, - delta, - summary: summarize(delta) - }); + try { + atomicWriteJson(filePath, merged); + } catch {} - const entry = this._entry(slug, filePath, merged, persisted.base, newRev, notes); - entry.overlayEnabled = persisted.overlayEnabled; - this.projects.set(slug, entry); - clearErrorFile(this.workspace, slug); + writeSnapshot(this.workspace, slug, newRev, merged); + appendHistoryEntry(this.workspace, slug, { + rev: newRev, + ts: merged.meta.updatedAt, + delta, + summary: summarize(delta) + }); - return { ok: true, slug, event: "UPDATE", project: entry, delta, notes }; - } catch (e) { - return this._recordError(slug, filePath, { kind: "parse", message: e.message }); - } + const derived = deriveProject(merged); + const entry = { + data: merged, + derived, + path: filePath, + rev: newRev, + error: null, + notes + }; + this.projects.set(slug, entry); + clearErrorFile(this.workspace, slug); + + return { ok: true, slug, event: "UPDATE", project: entry, delta, notes }; } _recordError(slug, filePath, err) { @@ -399,7 +232,6 @@ export class Store { async createOrReplace(slug, data) { return this.withLock(slug, async () => { - data = normalizeProjectStatuses(data).data; if (!data?.meta?.slug) { return { ok: false, status: 400, message: "meta.slug is required in body" }; } @@ -413,7 +245,6 @@ export class Store { const { ok, errors } = validateProject(data); if (!ok) return { ok: false, status: 400, message: errors.join("; ") }; const file = trackerPath(this.workspace, slug); - clearRuntimeOverlay(this.workspace, slug); atomicWriteJson(file, data); // chokidar fires → ingest stamps rev, writes snapshot, broadcasts. return { ok: true }; @@ -453,7 +284,6 @@ export class Store { } catch (e) { return { ok: false, status: 400, message: `target is not valid JSON: ${e.message}` }; } - data = normalizeProjectStatuses(data).data; const { ok, errors } = validateProject(data); if (!ok) { return { ok: false, status: 400, message: `target fails schema: ${errors.join("; ")}` }; @@ -511,15 +341,6 @@ export class Store { } } - // Tombstone the deleted id so a later full-file write from a stale LLM - // context cannot silently resurrect it. The list is hub-owned; merge.js - // strips incoming meta.deleted_tasks and refuses to re-add tombstoned ids. - const tombstones = Array.isArray(newState.meta.deleted_tasks) - ? [...newState.meta.deleted_tasks] - : []; - if (!tombstones.includes(taskId)) tombstones.push(taskId); - newState.meta.deleted_tasks = tombstones; - const baseRev = current.rev; const newRev = baseRev + 1; newState.meta.rev = newRev; @@ -529,106 +350,27 @@ export class Store { if (!ok) return { ok: false, status: 400, message: errors.join("; ") }; const delta = computeDelta(current.data, newState); - const persisted = this._persistProject( - slug, - file, - current.base || current.data, - newState, - current.overlayEnabled === true - ); - const entry = this._entry(slug, file, newState, persisted.base, newRev, defaultNotes()); - entry.overlayEnabled = persisted.overlayEnabled; - this.projects.set(slug, entry); + const derived = deriveProject(newState); - writeSnapshot(this.workspace, slug, newRev, newState); - appendHistoryEntry(this.workspace, slug, { + this.projects.set(slug, { + data: newState, + derived, + path: file, rev: newRev, - ts: newState.meta.updatedAt, - delta, - summary: summarize(delta) + error: null, + notes: { ignored: [], warnings: [], appended: [], updated: [] } }); - return { ok: true, newRev }; - }); - } - - async restoreProject(slug, { rev } = {}) { - return this.withLock(slug, async () => { - if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) { - return { - ok: false, - status: 400, - message: "slug must match ^[a-z0-9][a-z0-9-]*$" - }; - } - const file = trackerPath(this.workspace, slug); - if (existsSync(file)) { - return { - ok: false, - status: 409, - message: `project already registered at ${file} — nothing to restore` - }; - } - - const revs = listRevs(this.workspace, slug); - if (revs.length === 0) { - return { - ok: false, - status: 404, - message: `no snapshots for slug "${slug}" — register with PUT /api/projects/${slug} instead` - }; - } - const targetRev = Number.isInteger(rev) ? rev : revs[revs.length - 1]; - if (!revs.includes(targetRev)) { - return { - ok: false, - status: 404, - message: `no snapshot at rev ${targetRev} for slug "${slug}" (available: ${revs.join(", ")})` - }; - } - - const snap = readSnapshot(this.workspace, slug, targetRev); - if (!snap) { - return { ok: false, status: 500, message: `snapshot at rev ${targetRev} is unreadable` }; - } - - const { ok, errors } = validateProject(snap); - if (!ok) { - return { - ok: false, - status: 500, - message: `snapshot failed validation: ${errors.join("; ")}` - }; - } - - const newRev = latestRecordedRev(this.workspace, slug) + 1; - const newState = JSON.parse(JSON.stringify(snap)); - newState.meta.rev = newRev; - newState.meta.updatedAt = new Date().toISOString(); - - const entry = this._entry(slug, file, newState, newState, newRev, defaultNotes()); - entry.overlayEnabled = false; - this.projects.set(slug, entry); - - try { - clearRuntimeOverlay(this.workspace, slug); - atomicWriteJson(file, newState); - } catch (e) { - this.projects.delete(slug); - return { ok: false, status: 500, message: `restore write failed: ${e.message}` }; - } - writeSnapshot(this.workspace, slug, newRev, newState); appendHistoryEntry(this.workspace, slug, { rev: newRev, ts: newState.meta.updatedAt, - action: "restore", - restoredFromRev: targetRev, - summary: [`restored project from snapshot rev ${targetRev}`] + delta, + summary: summarize(delta) }); - clearErrorFile(this.workspace, slug); - return { ok: true, slug, restoredFromRev: targetRev, file, newRev }; + atomicWriteJson(file, newState); + return { ok: true, newRev }; }); } @@ -638,39 +380,15 @@ export class Store { if (!existsSync(file)) { return { ok: false, status: 404, message: "project not found" }; } - const current = this.projects.get(slug); - const priorEntry = current ? { - data: current.data ? JSON.parse(JSON.stringify(current.data)) : null, - base: current.base ? JSON.parse(JSON.stringify(current.base)) : null, - derived: current.derived, - path: current.path, - rev: current.rev, - error: current.error, - notes: current.notes, - overlayEnabled: current.overlayEnabled === true - } : null; try { - if (current) this.projects.delete(slug); unlinkSync(file); - clearRuntimeOverlay(this.workspace, slug); } catch (e) { - if (current && priorEntry) this.projects.set(slug, priorEntry); return { ok: false, status: 500, message: `delete failed: ${e.message}` }; } - const deletedFromRev = Number.isInteger(current?.rev) - ? current.rev - : latestRecordedRev(this.workspace, slug); - const deletedRev = deletedFromRev + 1; - appendHistoryEntry(this.workspace, slug, { - rev: deletedRev, - ts: new Date().toISOString(), - action: "delete", - deletedFromRev, - summary: ["deleted project registration"] - }); clearErrorFile(this.workspace, slug); + // chokidar fires unlink → store.remove() → broadcast REMOVE. // Snapshots and history are preserved for audit. - return { ok: true, deletedRev, deletedFromRev }; + return { ok: true }; }); } @@ -684,229 +402,75 @@ export class Store { message: "project not found — register by writing the full tracker file first" }; } - const current = this.projects.get(slug); - let existing = current?.data || null; - let existingBase = current?.base || null; - let overlayEnabled = current?.overlayEnabled === true; - let currentRev = current?.rev ?? null; + let existing; try { - if (!existing || !existingBase) { - const loaded = this._loadProjectState(slug, file); - existing = loaded.data; - existingBase = loaded.base; - overlayEnabled = loaded.overlayEnabled; - } + existing = JSON.parse(readFileSync(file, "utf-8")); } catch (e) { return { ok: false, status: 500, message: `tracker file not parseable: ${e.message}` }; } - if (!existing) { - return { ok: false, status: 500, message: "project state is unavailable" }; - } - if (!Number.isInteger(currentRev)) { - currentRev = Number.isInteger(existing?.meta?.rev) ? existing.meta.rev : 0; - } - - const guard = validatePatchGuardrails(existing, patch); - if (!guard.ok) return guard; - const normalizationNotes = { warnings: [] }; - patch = normalizeProjectStatuses(patch, normalizationNotes).data; const { merged, notes } = mergeProject(existing, patch); - if (normalizationNotes.warnings.length > 0) { - notes.warnings.push(...normalizationNotes.warnings); - } const { ok, errors } = validateProject(merged); - if (!ok) { - const message = errors.join("; "); - return { - ok: false, - status: 400, - type: "schema", - message, - hint: inferTrackerErrorHint(message), - notes - }; - } - - const delta = computeDelta(existing, merged); - if (!hasChanges(delta)) { - if (!current || !current.data) { - const entry = this._entry(slug, file, existing, existingBase, currentRev, defaultNotes()); - entry.overlayEnabled = overlayEnabled; - this.projects.set(slug, entry); - clearErrorFile(this.workspace, slug); - } - return { ok: true, notes, noop: true, rev: currentRev }; - } - - const newRev = currentRev + 1; - merged.meta.rev = newRev; - merged.meta.updatedAt = new Date().toISOString(); - const persisted = this._persistProject(slug, file, existingBase, merged, overlayEnabled); - const entry = this._entry(slug, file, merged, persisted.base, newRev, notes); - entry.overlayEnabled = persisted.overlayEnabled; - this.projects.set(slug, entry); - - writeSnapshot(this.workspace, slug, newRev, merged); - appendHistoryEntry(this.workspace, slug, { - rev: newRev, - ts: merged.meta.updatedAt, - delta, - summary: summarize(delta) - }); + if (!ok) return { ok: false, status: 400, message: errors.join("; "), notes }; - clearErrorFile(this.workspace, slug); - return { ok: true, notes, rev: newRev, noop: false }; + atomicWriteJson(file, merged); + return { ok: true, notes }; }); } - async pickTask(slug, { taskId, assignee = null, force = false, comment } = {}) { + async rollback(slug, toRev) { return this.withLock(slug, async () => { + const snap = readSnapshot(this.workspace, slug, toRev); + if (!snap) { + return { ok: false, status: 404, message: `no snapshot for rev ${toRev}` }; + } const current = this.projects.get(slug); if (!current || !current.data) { - return { ok: false, status: 404, message: "project not found" }; + return { ok: false, status: 404, message: "project not in memory" }; } - const file = trackerPath(this.workspace, slug); - const history = readHistory(this.workspace, slug); - const selection = resolvePickSelection({ - slug, - data: current.data, - history, - taskId, - assignee, - force - }); - if (!selection.ok) return selection; - - const newState = JSON.parse(JSON.stringify(current.data)); - const target = newState.tasks.find((task) => task.id === selection.taskId); - if (!target) { - return { ok: false, status: 404, message: `task "${selection.taskId}" not found` }; - } + const baseRev = current.rev; + const newRev = baseRev + 1; - const effectiveAssignee = assignee ?? target.assignee ?? null; - const shouldRefreshClaim = - target.status !== "in_progress" || - target.assignee !== effectiveAssignee || - target.blocker_reason != null || - comment !== undefined; - target.status = "in_progress"; - target.assignee = effectiveAssignee; - if (shouldRefreshClaim) { - target.blocker_reason = null; - } - if (comment !== undefined) target.comment = comment; + const newState = JSON.parse(JSON.stringify(snap)); + newState.meta.rev = newRev; + newState.meta.updatedAt = new Date().toISOString(); const { ok, errors } = validateProject(newState); - if (!ok) return { ok: false, status: 400, message: errors.join("; ") }; - - const delta = computeDelta(current.data, newState); - if (!hasChanges(delta)) { + if (!ok) { return { - ok: true, - noop: true, - payload: buildPickedPayload({ - slug, - data: current.data, - history, - taskId: selection.taskId, - autoSelected: selection.autoSelected, - selectedBecause: selection.selectedBecause, - noop: true - }) + ok: false, + status: 500, + message: `snapshot validation failed: ${errors.join("; ")}` }; } - const newRev = current.rev + 1; - newState.meta.rev = newRev; - newState.meta.updatedAt = new Date().toISOString(); + const delta = computeDelta(current.data, newState); + const derived = deriveProject(newState); - const persisted = this._persistProject( - slug, - file, - current.base || current.data, - newState, - current.overlayEnabled === true - ); - const entry = this._entry(slug, file, newState, persisted.base, newRev, defaultNotes()); - entry.overlayEnabled = persisted.overlayEnabled; - this.projects.set(slug, entry); + // In-memory first so the chokidar event from the file write is a no-op. + this.projects.set(slug, { + data: newState, + derived, + path: current.path, + rev: newRev, + error: null, + notes: { ignored: [], warnings: [], appended: [], updated: [] } + }); writeSnapshot(this.workspace, slug, newRev, newState); appendHistoryEntry(this.workspace, slug, { rev: newRev, ts: newState.meta.updatedAt, delta, - summary: summarize(delta) + summary: summarize(delta), + rolledBackFrom: baseRev, + rolledBackTo: toRev }); - return { - ok: true, - noop: false, - payload: buildPickedPayload({ - slug, - data: newState, - history: [...history, { rev: newRev, delta }], - taskId: selection.taskId, - autoSelected: selection.autoSelected, - selectedBecause: selection.selectedBecause, - noop: false, - now: newState.meta.updatedAt - }) - }; - }); - } + atomicWriteJson(current.path, newState); - async rollback(slug, toRev) { - return this.withLock(slug, async () => { - const current = this.projects.get(slug); - return this._restoreRevisionUnlocked(slug, current, toRev, { action: "rollback" }); - }); - } - - async undo(slug) { - return this.withLock(slug, async () => { - const current = this.projects.get(slug); - if (!current || !current.data) { - return { ok: false, status: 404, message: "project not in memory" }; - } - - const history = readHistory(this.workspace, slug); - const latest = history[history.length - 1]; - if (!latest) { - return { ok: false, status: 409, message: "no history to undo" }; - } - - const targetRev = Number.isInteger(latest.rolledBackFrom) ? latest.rolledBackFrom : current.rev - 1; - if (!Number.isInteger(targetRev) || targetRev < 1 || targetRev === current.rev) { - return { ok: false, status: 409, message: "no undo target available" }; - } - - return this._restoreRevisionUnlocked(slug, current, targetRev, { - action: "undo", - undoOfRev: current.rev - }); - }); - } - - async redo(slug) { - return this.withLock(slug, async () => { - const current = this.projects.get(slug); - if (!current || !current.data) { - return { ok: false, status: 404, message: "project not in memory" }; - } - - const history = readHistory(this.workspace, slug); - const latest = history[history.length - 1]; - if (!latest || latest.action !== "undo" || !Number.isInteger(latest.undoOfRev)) { - return { ok: false, status: 409, message: "no undo available to redo" }; - } - - return this._restoreRevisionUnlocked(slug, current, latest.undoOfRev, { - action: "redo", - redoOfRev: latest.rev - }); + return { ok: true, from: baseRev, to: toRev, newRev }; }); } @@ -923,46 +487,16 @@ export class Store { rev: e.rev, ts: e.ts, summary: e.summary || [], - action: e.action || null, - undoOfRev: e.undoOfRev, - redoOfRev: e.redoOfRev, rolledBackFrom: e.rolledBackFrom, - rolledBackTo: e.rolledBackTo, - deletedFromRev: e.deletedFromRev, - restoredFromRev: e.restoredFromRev + rolledBackTo: e.rolledBackTo })); } - history(slug, { fromRev = 0, limit = 50 } = {}) { - const entry = this.projects.get(slug); - const all = readHistory(this.workspace, slug).filter((event) => event.rev > fromRev); - if ((!entry || !entry.data) && all.length === 0) return null; - const clampedLimit = Math.max(1, Math.min(limit, 200)); - const events = all.slice(-clampedLimit); - const currentRev = entry?.rev ?? all.reduce((max, event) => { - return Number.isInteger(event?.rev) && event.rev > max ? event.rev : max; - }, 0); - return { - slug, - fromRev, - currentRev, - deleted: !entry?.data, - events, - truncation: { - applied: events.length < all.length, - returned: events.length, - totalAvailable: all.length, - maxCount: clampedLimit - } - }; - } - remove(filePath) { const slug = slugFromFile(filePath); if (!slug) return null; if (!this.projects.has(slug)) return null; this.projects.delete(slug); - clearRuntimeOverlay(this.workspace, slug); return { slug, event: "REMOVE" }; } @@ -1075,77 +609,17 @@ export class Store { if (!ok) return { ok: false, status: 400, message: errors.join("; ") }; const delta = computeDelta(current.data, newState); - const persisted = this._persistProject( - slug, - file, - current.base || current.data, - newState, - current.overlayEnabled === true - ); - const entry = this._entry(slug, file, newState, persisted.base, newRev, defaultNotes()); - entry.overlayEnabled = persisted.overlayEnabled; - this.projects.set(slug, entry); + const derived = deriveProject(newState); - writeSnapshot(this.workspace, slug, newRev, newState); - appendHistoryEntry(this.workspace, slug, { + this.projects.set(slug, { + data: newState, + derived, + path: file, rev: newRev, - ts: newState.meta.updatedAt, - delta, - summary: summarize(delta) + error: null, + notes: { ignored: [], warnings: [], appended: [], updated: [] } }); - return { ok: true, newRev }; - }); - } - - async applySwimlaneMove(slug, { swimlaneId, direction }) { - return this.withLock(slug, async () => { - const current = this.projects.get(slug); - if (!current || !current.data) { - return { ok: false, status: 404, message: "project not found" }; - } - if (!swimlaneId || (direction !== "up" && direction !== "down")) { - return { ok: false, status: 400, message: "swimlaneId and direction (up|down) required" }; - } - - const file = trackerPath(this.workspace, slug); - const newState = JSON.parse(JSON.stringify(current.data)); - const lanes = newState.meta?.swimlanes; - if (!Array.isArray(lanes)) { - return { ok: false, status: 500, message: "project has no swimlanes" }; - } - - const index = lanes.findIndex((lane) => lane.id === swimlaneId); - if (index === -1) return { ok: false, status: 404, message: "swimlane not found" }; - - const targetIndex = direction === "up" ? index - 1 : index + 1; - if (targetIndex < 0 || targetIndex >= lanes.length) { - return { ok: true, noop: true, newRev: current.rev }; - } - - const [lane] = lanes.splice(index, 1); - lanes.splice(targetIndex, 0, lane); - - const baseRev = current.rev; - const newRev = baseRev + 1; - newState.meta.rev = newRev; - newState.meta.updatedAt = new Date().toISOString(); - - const { ok, errors } = validateProject(newState); - if (!ok) return { ok: false, status: 400, message: errors.join("; ") }; - - const delta = computeDelta(current.data, newState); - const persisted = this._persistProject( - slug, - file, - current.base || current.data, - newState, - current.overlayEnabled === true - ); - const entry = this._entry(slug, file, newState, persisted.base, newRev, defaultNotes()); - entry.overlayEnabled = persisted.overlayEnabled; - this.projects.set(slug, entry); - writeSnapshot(this.workspace, slug, newRev, newState); appendHistoryEntry(this.workspace, slug, { rev: newRev, @@ -1154,6 +628,7 @@ export class Store { summary: summarize(delta) }); + atomicWriteJson(file, newState); return { ok: true, newRev }; }); } diff --git a/hub/task-metadata.js b/hub/task-metadata.js deleted file mode 100644 index 6225e92..0000000 --- a/hub/task-metadata.js +++ /dev/null @@ -1,112 +0,0 @@ -import { normalizeEffort, normalizeTaskReferences } from "./references.js"; - -const KNOWN_PRIORITY_WEIGHTS = { - p0: 100, - p1: 70, - p2: 40, - p3: 10 -}; - -export function priorityWeight(priorityId, priorities = []) { - if (KNOWN_PRIORITY_WEIGHTS[priorityId] !== undefined) { - return KNOWN_PRIORITY_WEIGHTS[priorityId]; - } - - const idx = priorities.findIndex((p) => p.id === priorityId); - if (idx === -1) return 0; - return Math.max(10, 100 - idx * 20); -} - -export function buildTaskTouchMap(history = []) { - const out = new Map(); - for (const entry of history) { - for (const taskId of Object.keys(entry?.delta?.tasks || {})) { - out.set(taskId, entry.rev); - } - } - return out; -} - -export function lastTouchedRevFor(task, touchMap) { - if (Number.isInteger(task?.rev)) return task.rev; - return touchMap.get(task?.id) ?? null; -} - -export function taskComment(task) { - return typeof task?.comment === "string" && task.comment.trim() ? task.comment : null; -} - -export function taskBlockerReason(task) { - return typeof task?.blocker_reason === "string" && task.blocker_reason.trim() - ? task.blocker_reason - : null; -} - -export function taskRequiresApproval(task) { - if (!Array.isArray(task?.approval_required_for)) return []; - return task.approval_required_for.filter((value) => typeof value === "string" && value.trim()); -} - -export function taskTags(task) { - if (!Array.isArray(task?.context?.tags)) return []; - return task.context.tags.filter((value) => typeof value === "string" && value.trim()); -} - -export function isAggregateTask(task) { - const context = task?.context || {}; - const tags = taskTags(task); - const hasSubtaskCounts = - Number.isInteger(context.task_count) || - Number.isInteger(context.open_subtasks) || - Number.isInteger(context.completed_subtasks); - const hasRoadmapEnvelope = - tags.includes("roadmap") && - typeof context.source_title === "string" && - context.source_title.trim().length > 0; - - return hasSubtaskCounts || hasRoadmapEnvelope; -} - -export function blockingDependencies(task, byId) { - return (task?.dependencies || []).filter((dep) => { - const depTask = byId.get(dep); - return depTask && depTask.status !== "complete" && !isAggregateTask(depTask); - }); -} - -export function buildProjectTaskContext({ data, history = [] }) { - return { - byId: new Map((data?.tasks || []).map((task) => [task.id, task])), - currentRev: data?.meta?.rev ?? null, - priorities: data?.meta?.priorities || [], - touchMap: buildTaskTouchMap(history) - }; -} - -export function summarizeTask(task, context) { - const blockingOn = blockingDependencies(task, context.byId); - const dependenciesResolved = blockingOn.length === 0; - const aggregate = isAggregateTask(task); - - return { - id: task.id, - title: task.title, - goal: task.goal || null, - status: task.status, - assignee: task.assignee ?? null, - priorityId: task.placement?.priorityId ?? null, - swimlaneId: task.placement?.swimlaneId ?? null, - effort: normalizeEffort(task.effort), - aggregate, - ready: dependenciesResolved, - blocked_kind: dependenciesResolved ? null : "deps", - blocking_on: blockingOn, - blocker_reason: taskBlockerReason(task), - requires_approval: taskRequiresApproval(task), - dependenciesResolved, - references: normalizeTaskReferences(task), - comment: taskComment(task), - lastTouchedRev: lastTouchedRevFor(task, context.touchMap), - priorityWeight: priorityWeight(task.placement?.priorityId, context.priorities) - }; -} diff --git a/hub/validator.js b/hub/validator.js index b7a944f..76f114b 100644 --- a/hub/validator.js +++ b/hub/validator.js @@ -1,7 +1,7 @@ import Ajv from "ajv"; import addFormats from "ajv-formats"; -import { EFFORT_VALUES, REFERENCE_PATTERN_SOURCE } from "./references.js"; -import { STATUS_VALUES, TASK_OUTCOME_VALUES } from "./status-vocabulary.js"; + +export const STATUS_VALUES = ["not_started", "in_progress", "complete", "deferred"]; const schema = { type: "object", @@ -42,12 +42,7 @@ const schema = { } }, scratchpad: { type: "string", maxLength: 5000 }, - updatedAt: { type: ["string", "null"] }, - deleted_tasks: { - type: ["array", "null"], - items: { type: "string", minLength: 1 }, - uniqueItems: true - } + updatedAt: { type: ["string", "null"] } } }, tasks: { @@ -61,9 +56,6 @@ const schema = { title: { type: "string", minLength: 1 }, goal: { type: "string" }, status: { enum: STATUS_VALUES }, - outcome: { - enum: [...TASK_OUTCOME_VALUES, null] - }, placement: { type: "object", required: ["swimlaneId", "priorityId"], @@ -76,47 +68,13 @@ const schema = { assignee: { type: ["string", "null"] }, reference: { type: ["string", "null"], - pattern: REFERENCE_PATTERN_SOURCE - }, - references: { - type: ["array", "null"], - items: { - type: "string", - pattern: REFERENCE_PATTERN_SOURCE - } - }, - effort: { - enum: [...EFFORT_VALUES, null] - }, - related: { - type: ["array", "null"], - items: { type: "string" } + pattern: "^.+:\\d+(-\\d+)?$" }, comment: { type: ["string", "null"], maxLength: 500 }, - blocker_reason: { type: ["string", "null"], maxLength: 2000 }, - definition_of_done: { - type: ["array", "null"], - items: { type: "string" } - }, - constraints: { - type: ["array", "null"], - items: { type: "string" } - }, - expected_changes: { - type: ["array", "null"], - items: { type: "string" } - }, - allowed_paths: { - type: ["array", "null"], - items: { type: "string" } - }, - approval_required_for: { - type: ["array", "null"], - items: { type: "string" } - }, + blocker_reason: { type: ["string", "null"] }, context: { type: "object" }, updatedAt: { type: ["string", "null"] }, rev: { type: ["integer", "null"] } @@ -139,9 +97,6 @@ function formatAjvError(err) { return `${path}: missing required field "${err.params.missingProperty}"`; } if (err.keyword === "pattern") { - if (path.includes("/reference") || path.includes("/references/")) { - return `${path}: reference must use path:line or path:line-line; bare URLs are invalid`; - } return `${path}: does not match pattern ${err.params.pattern}`; } return `${path}: ${err.message}`; diff --git a/hub/verify.js b/hub/verify.js deleted file mode 100644 index b6718f6..0000000 --- a/hub/verify.js +++ /dev/null @@ -1,144 +0,0 @@ -import { buildBriefPayload, getBriefPayload } from "./briefs.js"; - -function verificationContract(task) { - return { - definition_of_done: task.definition_of_done || [], - constraints: task.constraints || [], - expected_changes: task.expected_changes || [], - allowed_paths: task.allowed_paths || [] - }; -} - -function dependencyEvidence(dependencies = []) { - return dependencies.map((dependency) => ({ - id: dependency.id, - title: dependency.title, - status: dependency.status, - ready: dependency.ready, - selectedBecause: "dependency state" - })); -} - -function buildChecks(task, dependencies = []) { - const checks = []; - - for (const item of task.definition_of_done || []) { - checks.push({ - kind: "definition_of_done", - text: item, - status: task.status === "complete" ? "ready_to_review" : "pending_completion", - evidenceFrom: ["taskState", "recentHistory"] - }); - } - - for (const expectedChange of task.expected_changes || []) { - checks.push({ - kind: "expected_change", - text: expectedChange, - status: "needs_manual_confirmation", - evidenceFrom: ["taskContract"] - }); - } - - for (const allowedPath of task.allowed_paths || []) { - checks.push({ - kind: "allowed_path", - text: allowedPath, - status: "constraint", - evidenceFrom: ["taskContract"] - }); - } - - for (const dependency of dependencies) { - checks.push({ - kind: "dependency_state", - text: `${dependency.id} is ${dependency.status}`, - status: dependency.status === "complete" ? "satisfied" : "open", - evidenceFrom: ["dependencyState"] - }); - } - - if (checks.length === 0) { - checks.push({ - kind: "task_status", - text: "Task status should reflect the real outcome before sign-off", - status: task.status === "complete" ? "ready_to_review" : "pending_completion", - evidenceFrom: ["taskState"] - }); - } - - return checks; -} - -export function buildVerifyPayload({ - slug, - data, - history = [], - taskId, - references = null, - snippets = [], - now = new Date().toISOString() -}) { - const brief = buildBriefPayload({ - slug, - data, - history, - taskId, - references, - snippets, - now - }); - if (!brief) return null; - - const dependencyState = dependencyEvidence(brief.dependencies); - - return { - ...brief, - packType: "verify", - verificationContract: verificationContract(brief.task), - evidenceSources: { - taskState: { - id: brief.task.id, - status: brief.task.status, - assignee: brief.task.assignee, - lastTouchedRev: brief.task.lastTouchedRev, - selectedBecause: "current task state" - }, - dependencyState, - references: brief.references, - snippets: brief.snippets, - recentHistory: brief.recentHistory - }, - checks: buildChecks(brief.task, dependencyState) - }; -} - -export function getVerifyPayload({ workspace, slug, entry, taskId, now }) { - const result = getBriefPayload({ workspace, slug, entry, taskId, now }); - if (!result.ok) return result; - - const dependencyState = dependencyEvidence(result.payload.dependencies); - - return { - ok: true, - payload: { - ...result.payload, - packType: "verify", - verificationContract: verificationContract(result.payload.task), - evidenceSources: { - taskState: { - id: result.payload.task.id, - status: result.payload.task.status, - assignee: result.payload.task.assignee, - lastTouchedRev: result.payload.task.lastTouchedRev, - selectedBecause: "current task state" - }, - dependencyState, - references: result.payload.references, - snippets: result.payload.snippets, - recentHistory: result.payload.recentHistory - }, - checks: buildChecks(result.payload.task, dependencyState) - } - }; -} diff --git a/hub/why.js b/hub/why.js deleted file mode 100644 index b6728d7..0000000 --- a/hub/why.js +++ /dev/null @@ -1,187 +0,0 @@ -import { readHistory } from "./snapshots.js"; -import { selectBriefReferences } from "./briefs.js"; -import { buildProjectTaskContext, summarizeTask } from "./task-metadata.js"; - -const WHY_HISTORY_LIMIT = 3; - -function stringArray(values) { - if (!Array.isArray(values)) return []; - return values.filter((value) => typeof value === "string" && value.trim()); -} - -function summarizeTaskForWhy(task, context) { - const summary = summarizeTask(task, context); - return { - id: summary.id, - title: summary.title, - goal: task.goal || null, - status: summary.status, - assignee: summary.assignee, - priorityId: summary.priorityId, - swimlaneId: summary.swimlaneId, - effort: summary.effort, - ready: summary.ready, - blocked_kind: summary.blocked_kind, - blocking_on: summary.blocking_on, - blocker_reason: summary.blocker_reason, - requires_approval: summary.requires_approval, - comment: summary.comment, - lastTouchedRev: summary.lastTouchedRev, - dependencies: stringArray(task.dependencies), - related: stringArray(task.related) - }; -} - -function summarizeTaskIds(taskIds, context) { - return taskIds - .map((taskId) => context.byId.get(taskId)) - .filter(Boolean) - .map((task) => summarizeTaskForWhy(task, context)); -} - -function collectTaskHistory(history = [], taskId) { - return history - .filter((entry) => Object.prototype.hasOwnProperty.call(entry?.delta?.tasks || {}, taskId)) - .sort((a, b) => (b.rev ?? -1) - (a.rev ?? -1)) - .map((entry) => { - const delta = entry?.delta?.tasks?.[taskId] || {}; - return { - rev: entry.rev, - ts: entry.ts, - summary: Array.isArray(entry.summary) ? entry.summary : [], - changedKeys: delta.__added__ ? ["__added__"] : delta.__removed__ ? ["__removed__"] : Object.keys(delta).sort() - }; - }); -} - -function reverseDependencies(data = {}) { - const reverse = new Map(); - for (const task of data?.tasks || []) { - for (const dependencyId of task?.dependencies || []) { - const current = reverse.get(dependencyId) || []; - current.push(task.id); - reverse.set(dependencyId, current); - } - } - return reverse; -} - -function buildWhyReasons(task, summary, downstreamIds) { - const reasons = []; - - if (summary.comment) { - reasons.push({ - kind: "decision_note", - text: summary.comment - }); - } - - if (task.goal) { - reasons.push({ - kind: "goal", - text: task.goal - }); - } - - if (summary.priorityId || summary.swimlaneId) { - reasons.push({ - kind: "priority", - text: `${summary.priorityId || "p?"} priority in ${summary.swimlaneId || "unknown swimlane"}` - }); - } - - if (summary.blocking_on.length > 0) { - reasons.push({ - kind: "blocked", - text: `Blocked on ${summary.blocking_on.join(", ")}` - }); - } else if (summary.ready) { - reasons.push({ - kind: "ready", - text: "Ready for work now" - }); - } - - if (downstreamIds.length > 0) { - reasons.push({ - kind: "unblocks", - text: `Unblocks ${downstreamIds.join(", ")}` - }); - } - - if (summary.requires_approval.length > 0) { - reasons.push({ - kind: "approval", - text: `Requires approval for ${summary.requires_approval.join(", ")}` - }); - } - - if (summary.effort) { - reasons.push({ - kind: "effort", - text: `Estimated effort ${summary.effort}` - }); - } - - if (summary.lastTouchedRev !== null) { - reasons.push({ - kind: "freshness", - text: `Last touched in rev ${summary.lastTouchedRev}` - }); - } - - return reasons; -} - -export function buildWhyPayload({ - slug, - data, - history = [], - taskId, - now = new Date().toISOString() -}) { - const context = buildProjectTaskContext({ data, history }); - const task = context.byId.get(taskId); - if (!task) return null; - - const taskPack = summarizeTaskForWhy(task, context); - const downstreamIds = (reverseDependencies(data).get(taskId) || []).sort(); - const references = selectBriefReferences(task, context); - const recentHistory = collectTaskHistory(history, taskId).slice(0, WHY_HISTORY_LIMIT); - - return { - packType: "why", - project: slug, - taskId, - rev: context.currentRev, - generatedAt: now, - task: taskPack, - why: buildWhyReasons(task, taskPack, downstreamIds), - blockedBy: summarizeTaskIds(taskPack.blocking_on, context), - unblocks: summarizeTaskIds(downstreamIds, context), - references, - recentHistory, - truncation: { - history: { - applied: collectTaskHistory(history, taskId).length > recentHistory.length, - returned: recentHistory.length, - totalAvailable: collectTaskHistory(history, taskId).length, - maxCount: WHY_HISTORY_LIMIT - } - } - }; -} - -export function getWhyPayload({ workspace, slug, entry, taskId, now }) { - if (!entry?.data) return { ok: false, status: 404, message: "not found" }; - const history = readHistory(workspace, slug); - const payload = buildWhyPayload({ - slug, - data: entry.data, - history, - taskId, - now - }); - if (!payload) return { ok: false, status: 404, message: "task not found" }; - return { ok: true, payload }; -} diff --git a/package-lock.json b/package-lock.json index 7820e2e..fa50b0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,13 +7,12 @@ "": { "name": "llm-tracker", "version": "0.2.0", - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@huggingface/transformers": "^4.1.0", - "@modelcontextprotocol/sdk": "^1.29.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chokidar": "^3.6.0", + "cors": "^2.8.5", "express": "^4.19.2", "htm": "^3.1.1", "preact": "^10.22.0", @@ -26,934 +25,6 @@ "node": ">=18" } }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@huggingface/jinja": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.6.tgz", - "integrity": "sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@huggingface/tokenizers": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", - "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", - "license": "Apache-2.0" - }, - "node_modules/@huggingface/transformers": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.1.0.tgz", - "integrity": "sha512-WiMf9eyvF6V2pj4gs12A7GQV3svyFIBtB/W+Hn5lT5E5DyqWUno1ZrWoAfJv69X1RNv/0GoOo6DFmL6NOYd+rg==", - "license": "Apache-2.0", - "dependencies": { - "@huggingface/jinja": "^0.5.6", - "@huggingface/tokenizers": "^0.1.3", - "onnxruntime-node": "1.24.3", - "onnxruntime-web": "1.26.0-dev.20260410-5e55544225", - "sharp": "^0.34.5" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.19.0" - } - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -967,15 +38,6 @@ "node": ">= 0.6" } }, - "node_modules/adm-zip": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", - "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", - "license": "MIT", - "engines": { - "node": ">=12.0" - } - }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -1064,13 +126,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT" - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1198,61 +253,13 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "ms": "2.0.0" } }, "node_modules/depd": { @@ -1274,21 +281,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT" - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1348,30 +340,12 @@ "node": ">= 0.4" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "license": "MIT" - }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1381,33 +355,11 @@ "node": ">= 0.6" } }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -1449,24 +401,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", - "license": "MIT", - "dependencies": { - "ip-address": "10.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1519,12 +453,6 @@ "node": ">= 0.8" } }, - "node_modules/flatbuffers": { - "version": "25.9.23", - "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", - "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", - "license": "Apache-2.0" - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1615,39 +543,6 @@ "node": ">= 6" } }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "license": "BSD-3-Clause", - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1660,24 +555,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/guid-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", - "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", - "license": "ISC" - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1702,16 +579,6 @@ "node": ">= 0.4" } }, - "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/htm": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz", @@ -1756,15 +623,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1816,63 +674,12 @@ "node": ">=0.12.0" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1987,15 +794,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2008,58 +806,6 @@ "node": ">= 0.8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onnxruntime-common": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", - "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", - "license": "MIT" - }, - "node_modules/onnxruntime-node": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", - "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", - "hasInstallScript": true, - "license": "MIT", - "os": [ - "win32", - "darwin", - "linux" - ], - "dependencies": { - "adm-zip": "^0.5.16", - "global-agent": "^3.0.0", - "onnxruntime-common": "1.24.3" - } - }, - "node_modules/onnxruntime-web": { - "version": "1.26.0-dev.20260410-5e55544225", - "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260410-5e55544225.tgz", - "integrity": "sha512-hHd9n8DzIfGSAjM4Dvslesc8i6h9HEEcl8qt7X3LfhUxMgls6FBJ32j2xrDtJjKJFEehFeJmyB/pvad1I8KS8w==", - "license": "MIT", - "dependencies": { - "flatbuffers": "^25.1.24", - "guid-typescript": "^1.0.9", - "long": "^5.2.3", - "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", - "platform": "^1.3.6", - "protobufjs": "^7.2.4" - } - }, - "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { - "version": "1.24.0-dev.20251116-b39e144322", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", - "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", - "license": "MIT" - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2069,15 +815,6 @@ "node": ">= 0.8" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -2096,21 +833,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/platform": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", - "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", - "license": "MIT" - }, "node_modules/preact": { "version": "10.29.1", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", @@ -2121,30 +843,6 @@ "url": "https://opencollective.com/preact" } }, - "node_modules/protobufjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", - "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2218,72 +916,6 @@ "node": ">=0.10.0" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "license": "BSD-3-Clause", - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/router/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2310,24 +942,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "license": "MIT" - }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -2358,21 +972,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/serve-static": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", @@ -2394,71 +993,6 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -2531,12 +1065,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2567,25 +1095,6 @@ "node": ">=0.6" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true - }, - "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2599,12 +1108,6 @@ "node": ">= 0.6" } }, - "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "license": "MIT" - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2632,27 +1135,6 @@ "node": ">= 0.8" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -2673,25 +1155,6 @@ "optional": true } } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } } } } diff --git a/package.json b/package.json index 8ff32e8..8ba2fc7 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "bugs": { "url": "https://github.com/justguy/llm-tracker/issues" }, - "license": "Apache-2.0", + "license": "MIT", "author": "justguy", "keywords": [ "llm", @@ -41,11 +41,10 @@ "dashboard" ], "dependencies": { - "@huggingface/transformers": "^4.1.0", - "@modelcontextprotocol/sdk": "^1.29.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chokidar": "^3.6.0", + "cors": "^2.8.5", "express": "^4.19.2", "htm": "^3.1.1", "preact": "^10.22.0", @@ -59,9 +58,6 @@ "img", "README.md", "ARCHITECTURE.md", - "CHANGELOG.md", - "MIGRATING.md", - "LICENSE", - "NOTICE" + "LICENSE" ] } diff --git a/test/blockers.test.js b/test/blockers.test.js deleted file mode 100644 index 587a885..0000000 --- a/test/blockers.test.js +++ /dev/null @@ -1,46 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { buildBlockersPayload } from "../hub/blockers.js"; -import { validProject } from "./fixtures.js"; - -test("buildBlockersPayload returns blocked tasks and reverse blocking detail", () => { - const project = validProject(); - project.tasks[1].comment = "Waiting on task 1"; - project.tasks[1].blocker_reason = "Need task 1 complete first"; - - const payload = buildBlockersPayload({ - slug: "test-project", - data: project, - history: [{ rev: 3, delta: { tasks: { t2: { blocker_reason: "Need task 1 complete first" } } } }] - }); - - assert.equal(payload.blocked.length, 1); - assert.equal(payload.blocked[0].id, "t2"); - assert.deepEqual(payload.blocked[0].blocking_on, ["t1"]); - assert.equal(payload.blocked[0].blocking_task_details[0].id, "t1"); - - assert.equal(payload.blocking.length, 1); - assert.equal(payload.blocking[0].id, "t1"); - assert.equal(payload.blocking[0].blockedCount, 1); - assert.deepEqual(payload.blocking[0].blocks.map((task) => task.id), ["t2"]); -}); - -test("buildBlockersPayload ignores complete and deferred tasks", () => { - const project = validProject(); - project.tasks[1].status = "complete"; - project.tasks.push({ - id: "t4", - title: "Deferred blocked task", - status: "deferred", - placement: { swimlaneId: "ops", priorityId: "p1" }, - dependencies: ["t1"] - }); - - const payload = buildBlockersPayload({ - slug: "test-project", - data: project - }); - - assert.deepEqual(payload.blocked, []); - assert.deepEqual(payload.blocking, []); -}); diff --git a/test/brief-cli.test.js b/test/brief-cli.test.js deleted file mode 100644 index d637336..0000000 --- a/test/brief-cli.test.js +++ /dev/null @@ -1,98 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-brief-cli-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history", "docs"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# test workspace\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 10000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "::", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : null; - server.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(port); - }); - }); - }); -} - -async function waitForProject(port, slug) { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - try { - const res = await fetch(`http://localhost:${port}/api/projects/${slug}`); - if (res.status === 200) return; - } catch {} - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`timed out waiting for project ${slug}`); -} - -test("llm-tracker brief renders a focused task pack from the hub", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - const project = validProject(); - project.meta.rev = 3; - project.tasks[0].reference = "docs/guide.md:1-2"; - project.tasks[0].comment = "Read the brief pack instead of the repo"; - writeFileSync(join(workspace, "docs", "guide.md"), "line one\nline two\nline three\n"); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(project, null, 2)); - writeFileSync( - join(workspace, ".history", "test-project.jsonl"), - JSON.stringify({ - rev: 2, - ts: "2026-04-15T00:01:00.000Z", - delta: { tasks: { t1: { comment: "Read the brief pack instead of the repo" } } }, - summary: ["task 1 note updated"] - }) + "\n" - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - await waitForProject(port, "test-project"); - - const brief = runCli(["brief", "test-project", "t1", "--path", workspace]); - assert.equal(brief.status, 0, brief.stderr || brief.stdout); - assert.match(brief.stdout, /SNIPPETS/); - assert.match(brief.stdout, /docs\/guide\.md:1-2/); - assert.match(brief.stdout, /line one/); - assert.match(brief.stdout, /HISTORY/); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/briefs.test.js b/test/briefs.test.js deleted file mode 100644 index edaedbd..0000000 --- a/test/briefs.test.js +++ /dev/null @@ -1,116 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { buildBriefPayload, getBriefPayload } from "../hub/briefs.js"; -import { validProject } from "./fixtures.js"; - -test("buildBriefPayload caps snippets and history within deterministic budgets", () => { - const project = validProject(); - project.meta.rev = 12; - project.tasks[0].goal = "Focus the work on one deterministic pack"; - project.tasks[0].related = ["t2"]; - - const references = Array.from({ length: 6 }, (_, index) => ({ - value: `src/file${index}.js:1-1`, - selectedBecause: "explicit task reference" - })); - const snippets = references.map((reference, index) => ({ - id: `snippet-${index}`, - reference: reference.value, - path: `src/file${index}.js`, - startLine: 1, - endLine: 1, - text: "x".repeat(2000), - hash: `sha256:${index}`, - indexedAtRev: 12 - })); - const history = Array.from({ length: 4 }, (_, index) => ({ - rev: index + 1, - ts: `2026-04-15T00:0${index}:00.000Z`, - delta: { tasks: { t1: { comment: `note ${index}` } } }, - summary: [`update ${index}`] - })); - - const payload = buildBriefPayload({ - slug: "test-project", - data: project, - history, - taskId: "t1", - references, - snippets, - now: "2026-04-15T12:00:00.000Z" - }); - - assert.equal(payload.packType, "brief"); - assert.equal(payload.task.id, "t1"); - assert.equal(payload.relatedTasks[0].id, "t2"); - assert.equal(payload.snippets.length, 4); - assert.equal(payload.truncation.snippets.applied, true); - assert.equal(payload.truncation.snippets.byteCapped, true); - assert.equal(payload.recentHistory.length, 3); - assert.equal(payload.truncation.history.applied, true); - assert.equal(payload.snippets[0].selectedBecause, "explicit task reference"); -}); - -test("getBriefPayload falls back to dependency references and writes snippet cache", () => { - const workspace = mkdtempSync(join(tmpdir(), "llm-tracker-briefs-ws-")); - const repoRoot = mkdtempSync(join(tmpdir(), "llm-tracker-briefs-repo-")); - - try { - for (const sub of [".history", ".runtime"]) { - mkdirSync(join(workspace, sub), { recursive: true }); - } - mkdirSync(join(repoRoot, "src"), { recursive: true }); - mkdirSync(join(repoRoot, ".llm-tracker", "trackers"), { recursive: true }); - - const sourcePath = join(repoRoot, "src", "example.js"); - writeFileSync(sourcePath, "alpha\nbeta\ngamma\ndelta\n"); - - const project = validProject(); - project.meta.rev = 7; - project.tasks[0].references = ["src/example.js:2-3"]; - delete project.tasks[1].reference; - delete project.tasks[1].references; - project.tasks[1].related = ["t1"]; - - const trackerPath = join(repoRoot, ".llm-tracker", "trackers", "test-project.json"); - writeFileSync(trackerPath, JSON.stringify(project, null, 2)); - writeFileSync( - join(workspace, ".history", "test-project.jsonl"), - [ - JSON.stringify({ - rev: 5, - ts: "2026-04-15T00:00:00.000Z", - delta: { tasks: { t2: { status: "in_progress" } } }, - summary: ["task 2 started"] - }), - JSON.stringify({ - rev: 6, - ts: "2026-04-15T00:01:00.000Z", - delta: { tasks: { t2: { comment: "Need task 1 context" } } }, - summary: ["task 2 note updated"] - }) - ].join("\n") + "\n" - ); - - const result = getBriefPayload({ - workspace, - slug: "test-project", - entry: { data: project, path: trackerPath, rev: 7 }, - taskId: "t2", - now: "2026-04-15T12:00:00.000Z" - }); - - assert.equal(result.ok, true); - assert.equal(result.payload.references[0].selectedBecause, "dependency reference from t1"); - assert.equal(result.payload.snippets[0].text, "beta\ngamma"); - assert.equal(result.payload.recentHistory.length, 2); - assert.equal(result.payload.relatedTasks[0].id, "t1"); - assert.equal(existsSync(join(workspace, ".runtime", "snippets", "test-project.json")), true); - } finally { - rmSync(workspace, { recursive: true, force: true }); - rmSync(repoRoot, { recursive: true, force: true }); - } -}); diff --git a/test/changed.test.js b/test/changed.test.js deleted file mode 100644 index 83f9bc2..0000000 --- a/test/changed.test.js +++ /dev/null @@ -1,52 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { buildChangedPayload } from "../hub/changed.js"; -import { validProject } from "./fixtures.js"; - -test("buildChangedPayload summarizes task changes since a rev", () => { - const project = validProject(); - project.meta.rev = 6; - project.tasks[0].status = "complete"; - project.tasks[0].comment = "Finished"; - - const payload = buildChangedPayload({ - slug: "test-project", - data: project, - fromRev: 3, - history: [ - { rev: 2, delta: { tasks: { t1: { status: "in_progress" } } } }, - { rev: 4, delta: { meta: { scratchpad: "updated" }, tasks: { t1: { status: "complete", comment: "Finished" } } } }, - { rev: 5, delta: { tasks: { t2: { __removed__: true } }, order: ["t1", "t3"] } } - ] - }); - - assert.equal(payload.fromRev, 3); - assert.equal(payload.changed.length, 2); - assert.equal(payload.changed[0].id, "t2"); - assert.equal(payload.changed[0].removed, true); - assert.equal(payload.changed[1].id, "t1"); - assert.ok(payload.changed[1].changeKinds.includes("status")); - assert.ok(payload.changed[1].changedKeys.includes("comment")); - assert.deepEqual(payload.metaChanges, [{ key: "scratchpad", lastChangedRev: 4 }]); - assert.deepEqual(payload.orderChangedRevs, [5]); -}); - -test("buildChangedPayload limits output and keeps newest revisions first", () => { - const project = validProject(); - project.meta.rev = 10; - - const payload = buildChangedPayload({ - slug: "test-project", - data: project, - fromRev: 0, - limit: 1, - history: [ - { rev: 8, delta: { tasks: { t1: { comment: "updated" } } } }, - { rev: 9, delta: { tasks: { t2: { assignee: "codex" } } } } - ] - }); - - assert.equal(payload.changed.length, 1); - assert.equal(payload.changed[0].id, "t2"); - assert.deepEqual(payload.changed[0].changedInRevs, [9]); -}); diff --git a/test/daemon.test.js b/test/daemon.test.js deleted file mode 100644 index 963dff1..0000000 --- a/test/daemon.test.js +++ /dev/null @@ -1,270 +0,0 @@ -import { spawn, spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { WebSocket } from "ws"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-daemon-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# test workspace\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 10000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function isPidRunning(pid) { - try { - process.kill(pid, 0); - if (process.platform !== "win32") { - const state = spawnSync("ps", ["-p", String(pid), "-o", "state="], { - encoding: "utf-8" - }); - const raw = (state.stdout || "").trim(); - if (raw.split(/\s+/).some((value) => value.startsWith("Z"))) return false; - } - return true; - } catch { - return false; - } -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "::", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : null; - server.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(port); - }); - }); - }); -} - -test("daemon mode starts in the background, creates .runtime, and stops cleanly", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - const daemonMeta = join(workspace, ".runtime", "daemon.json"); - const daemonLog = join(workspace, ".runtime", "daemon.log"); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - assert.match(started.stdout, /Background hub started/); - assert.equal(existsSync(daemonMeta), true); - assert.equal(existsSync(daemonLog), true); - - const meta = JSON.parse(readFileSync(daemonMeta, "utf-8")); - assert.equal(meta.port, port); - assert.ok(meta.pid > 0); - - const health = await fetch(`http://localhost:${port}/api/workspace`); - assert.equal(health.status, 200); - const workspacePayload = await health.json(); - assert.equal(workspacePayload.workspace, workspace); - assert.equal(workspacePayload.help, "/help"); - - const help = await fetch(`http://localhost:${port}/help`); - assert.equal(help.status, 200); - assert.match(await help.text(), /test workspace/); - - const status = runCli(["daemon", "status", "--path", workspace]); - assert.equal(status.status, 0, status.stderr || status.stdout); - assert.match(status.stdout, /Background hub is running/); - assert.match(status.stdout, new RegExp(String(port))); - - const stopped = runCli(["daemon", "stop", "--path", workspace]); - assert.equal(stopped.status, 0, stopped.stderr || stopped.stdout); - assert.match(stopped.stdout, /Stopped background hub/); - assert.equal(existsSync(daemonMeta), false); - - const afterStop = runCli(["daemon", "status", "--path", workspace]); - assert.equal(afterStop.status, 0, afterStop.stderr || afterStop.stdout); - assert.match(afterStop.stdout, /not running/); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("daemon stop succeeds with an active websocket client attached", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const ws = new WebSocket(`ws://localhost:${port}/ws`); - await new Promise((resolve, reject) => { - ws.once("open", resolve); - ws.once("error", reject); - }); - - const stopped = runCli(["daemon", "stop", "--path", workspace]); - assert.equal(stopped.status, 0, stopped.stderr || stopped.stdout); - assert.match(stopped.stdout, /Stopped background hub|Force-stopped background hub/); - - await new Promise((resolve) => { - if (ws.readyState === WebSocket.CLOSED) return resolve(); - ws.once("close", resolve); - setTimeout(resolve, 1000).unref?.(); - }); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("daemon restart restarts the same workspace on the recorded port", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - const daemonMeta = join(workspace, ".runtime", "daemon.json"); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const before = JSON.parse(readFileSync(daemonMeta, "utf-8")); - const restarted = runCli(["daemon", "restart", "--path", workspace]); - assert.equal(restarted.status, 0, restarted.stderr || restarted.stdout); - assert.match(restarted.stdout, /Background hub started/); - - const after = JSON.parse(readFileSync(daemonMeta, "utf-8")); - assert.equal(after.port, port); - assert.notEqual(after.pid, before.pid); - - const health = await fetch(`http://localhost:${port}/api/workspace`); - assert.equal(health.status, 200); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("history, undo, and redo endpoints work through the running hub", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(validProject(), null, 2)); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const pick = await fetch(`http://localhost:${port}/api/projects/test-project/pick`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ assignee: "codex-live" }) - }); - assert.equal(pick.status, 200); - - const history = await fetch(`http://localhost:${port}/api/projects/test-project/history?limit=5`); - assert.equal(history.status, 200); - const historyPayload = await history.json(); - assert.ok(historyPayload.events.length >= 1); - - const undo = await fetch(`http://localhost:${port}/api/projects/test-project/undo`, { - method: "POST" - }); - assert.equal(undo.status, 200); - const undoPayload = await undo.json(); - assert.equal(undoPayload.action, "undo"); - - const redo = await fetch(`http://localhost:${port}/api/projects/test-project/redo`, { - method: "POST" - }); - assert.equal(redo.status, 200); - const redoPayload = await redo.json(); - assert.equal(redoPayload.action, "redo"); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("daemon stop force-kills a process that ignores SIGTERM", () => { - const workspace = setupWorkspace(); - const runtime = join(workspace, ".runtime"); - const daemonMeta = join(runtime, "daemon.json"); - const daemonLog = join(runtime, "daemon.log"); - mkdirSync(runtime, { recursive: true }); - - const child = spawn( - process.execPath, - ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"], - { - detached: true, - stdio: "ignore" - } - ); - child.unref(); - - writeFileSync( - daemonMeta, - JSON.stringify( - { - pid: child.pid, - port: 4400, - workspace, - startedAt: new Date().toISOString(), - logFile: daemonLog - }, - null, - 2 - ) - ); - - try { - const stopped = runCli(["daemon", "stop", "--path", workspace]); - assert.equal(stopped.status, 0, stopped.stderr || stopped.stdout); - assert.match(stopped.stdout, /Force-stopped background hub/); - assert.equal(existsSync(daemonMeta), false); - assert.equal(isPidRunning(child.pid), false); - } finally { - try { - process.kill(child.pid, "SIGKILL"); - } catch {} - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("help output documents daemon commands, reload, retrieval and execution packs, and the --daemon flag", () => { - const res = runCli(["help"]); - assert.equal(res.status, 0, res.stderr || res.stdout); - assert.match(res.stdout, /--daemon/); - assert.match(res.stdout, /daemon start/); - assert.match(res.stdout, /daemon stop/); - assert.match(res.stdout, /daemon restart/); - assert.match(res.stdout, /daemon logs/); - assert.match(res.stdout, /reload \[\\]/); - assert.match(res.stdout, /brief /); - assert.match(res.stdout, /why /); - assert.match(res.stdout, /decisions /); - assert.match(res.stdout, /execute /); - assert.match(res.stdout, /verify /); - assert.match(res.stdout, /shortcuts \[--alias NAME\]/); -}); diff --git a/test/decisions.test.js b/test/decisions.test.js deleted file mode 100644 index be68283..0000000 --- a/test/decisions.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { buildDecisionsPayload } from "../hub/decisions.js"; -import { validProject } from "./fixtures.js"; - -test("buildDecisionsPayload returns recent decision notes with references and truncation", () => { - const project = validProject(); - project.meta.rev = 11; - project.tasks[0].comment = "Keep the endpoint deterministic"; - project.tasks[0].references = ["hub/next.js:1-40"]; - project.tasks[1].comment = "Handle linked trackers before semantic search"; - project.tasks[1].references = ["hub/store.js:1-20"]; - project.tasks.push({ - id: "t4", - title: "Task 4", - status: "not_started", - placement: { swimlaneId: "ops", priorityId: "p1" }, - comment: "Do not break old patch files" - }); - - const history = [ - { rev: 7, delta: { tasks: { t1: { comment: "Keep the endpoint deterministic" } } } }, - { rev: 8, delta: { tasks: { t2: { comment: "Handle linked trackers before semantic search" } } } }, - { rev: 9, delta: { tasks: { t4: { comment: "Do not break old patch files" } } } } - ]; - - const payload = buildDecisionsPayload({ - slug: "test-project", - data: project, - history, - limit: 2, - now: "2026-04-15T12:00:00.000Z" - }); - - assert.equal(payload.packType, "decisions"); - assert.equal(payload.decisions.length, 2); - assert.equal(payload.decisions[0].id, "t4"); - assert.equal(payload.decisions[0].selectedBecause, "task comment present"); - assert.equal(payload.decisions[1].references[0].selectedBecause, "task reference attached to the decision"); - assert.equal(payload.truncation.decisions.applied, true); - assert.equal(payload.truncation.decisions.totalAvailable, 3); -}); diff --git a/test/error-payload.test.js b/test/error-payload.test.js deleted file mode 100644 index 3fa01eb..0000000 --- a/test/error-payload.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { buildTrackerErrorBody, inferTrackerErrorHint } from "../hub/error-payload.js"; - -test("inferTrackerErrorHint explains invalid reference syntax", () => { - const hint = inferTrackerErrorHint( - "/tasks/0/references/0: reference must use path:line or path:line-line; bare URLs are invalid" - ); - assert.match(hint, /path:line/); - assert.match(hint, /Bare URLs are invalid/); -}); - -test("inferTrackerErrorHint explains patch-mode task creation guardrail", () => { - const hint = inferTrackerErrorHint( - "new tasks added through patch mode must start as not_started or in_progress; rejecting t-019 (complete)" - ); - assert.match(hint, /not_started/); - assert.match(hint, /owning row/); -}); - -test("buildTrackerErrorBody emits error, type, hint, and legacy compatibility fields", () => { - const body = buildTrackerErrorBody({ - message: "/tasks/0/references/0: reference must use path:line or path:line-line; bare URLs are invalid", - kind: "schema", - path: "/tmp/example.json" - }); - - assert.equal(body.error, body.message); - assert.equal(body.type, "schema"); - assert.equal(body.kind, "schema"); - assert.equal(body.path, "/tmp/example.json"); - assert.match(body.hint, /path:line/); -}); diff --git a/test/execute-cli.test.js b/test/execute-cli.test.js deleted file mode 100644 index a74c506..0000000 --- a/test/execute-cli.test.js +++ /dev/null @@ -1,90 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-execute-cli-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history", "docs"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# test workspace\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 10000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "::", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : null; - server.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(port); - }); - }); - }); -} - -async function waitForProject(port, slug) { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - try { - const res = await fetch(`http://localhost:${port}/api/projects/${slug}`); - if (res.status === 200) return; - } catch {} - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`timed out waiting for project ${slug}`); -} - -test("llm-tracker execute renders the deterministic execution pack from the hub", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - const project = validProject(); - project.tasks[0].reference = "docs/guide.md:1-2"; - project.tasks[0].definition_of_done = ["Guide text reviewed"]; - project.tasks[0].constraints = ["Keep wording stable"]; - project.tasks[0].expected_changes = ["docs/guide.md"]; - project.tasks[0].allowed_paths = ["docs/guide.md"]; - writeFileSync(join(workspace, "docs", "guide.md"), "line one\nline two\n"); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(project, null, 2)); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - await waitForProject(port, "test-project"); - - const execute = runCli(["execute", "test-project", "t1", "--path", workspace]); - assert.equal(execute.status, 0, execute.stderr || execute.stdout); - assert.match(execute.stdout, /EXECUTION PLAN/); - assert.match(execute.stdout, /DONE WHEN/); - assert.match(execute.stdout, /EXPECTED CHANGES/); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/execute.test.js b/test/execute.test.js deleted file mode 100644 index 5b17499..0000000 --- a/test/execute.test.js +++ /dev/null @@ -1,44 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { buildExecutePayload } from "../hub/execute.js"; -import { validProject } from "./fixtures.js"; - -test("buildExecutePayload augments brief context with execution contract and plan", () => { - const project = validProject(); - project.meta.rev = 15; - project.tasks[0].goal = "Ship execution packs"; - project.tasks[0].comment = "Use deterministic execution data"; - project.tasks[0].references = ["hub/briefs.js:1-40"]; - project.tasks[0].definition_of_done = ["CLI and HTTP output match"]; - project.tasks[0].constraints = ["Do not break legacy trackers"]; - project.tasks[0].expected_changes = ["hub/execute.js", "bin/commands/execute.js"]; - project.tasks[0].allowed_paths = ["hub/execute.js", "bin/commands/execute.js"]; - project.tasks[0].approval_required_for = ["new dependencies"]; - - const payload = buildExecutePayload({ - slug: "test-project", - data: project, - history: [{ rev: 14, delta: { tasks: { t1: { comment: "Use deterministic execution data" } } }, summary: ["task 1 note"] }], - taskId: "t1", - references: [{ value: "hub/briefs.js:1-40", selectedBecause: "explicit task reference" }], - snippets: [ - { - id: "briefs_1_40", - reference: "hub/briefs.js:1-40", - path: "hub/briefs.js", - startLine: 1, - endLine: 40, - text: "export function buildBriefPayload() {}", - hash: "sha256:test", - indexedAtRev: 15 - } - ], - now: "2026-04-16T00:00:00.000Z" - }); - - assert.equal(payload.packType, "execute"); - assert.equal(payload.executionContract.definition_of_done[0], "CLI and HTTP output match"); - assert.ok(payload.executionPlan.some((item) => item.kind === "expected_change")); - assert.ok(payload.executionPlan.some((item) => item.kind === "approval")); - assert.equal(payload.references[0].selectedBecause, "explicit task reference"); -}); diff --git a/test/field-limits.test.js b/test/field-limits.test.js deleted file mode 100644 index 38110c1..0000000 --- a/test/field-limits.test.js +++ /dev/null @@ -1,189 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { Store, trackerPath } from "../hub/store.js"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-limits-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# limits test\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 15000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - server.close(() => resolve(port)); - }); - }); -} - -test("oversized meta.scratchpad in patch is rejected at the route layer", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const big = "x".repeat(6000); - const res = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/patch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ meta: { scratchpad: big } }) - }); - assert.equal(res.status, 413); - const body = await res.json(); - assert.equal(body.type, "field.too.large"); - assert.equal(body.field, "meta.scratchpad"); - assert.equal(body.max, 5000); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("oversized task.comment in patch is rejected at the route layer", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const big = "c".repeat(600); - const res = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/patch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tasks: { "t1": { comment: big } } }) - }); - assert.equal(res.status, 413); - const body = await res.json(); - assert.equal(body.type, "field.too.large"); - assert.match(body.field, /tasks\[t1\]\.comment/); - assert.equal(body.max, 500); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("oversized task.blocker_reason in patch is rejected at the route layer", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const big = "b".repeat(2100); - const res = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/patch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tasks: { "t1": { blocker_reason: big } } }) - }); - assert.equal(res.status, 413); - const body = await res.json(); - assert.equal(body.type, "field.too.large"); - assert.match(body.field, /tasks\[t1\]\.blocker_reason/); - assert.equal(body.max, 2000); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("JSON body limit is enforced with machine-readable response", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - - try { - const started = runCli([ - "--path", - workspace, - "--port", - String(port), - "--daemon" - ], { - env: { ...process.env, LLM_TRACKER_BODY_LIMIT: "4kb" } - }); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const payload = JSON.stringify({ meta: { scratchpad: "y".repeat(5000) } }); - const res = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/patch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: payload - }); - assert.equal(res.status, 413); - const body = await res.json(); - assert.match(body.error || "", /too large|body/i); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("oversized blocker_reason is rejected in store validation so file patch mode cannot bypass it", async () => { - const workspace = setupWorkspace(); - try { - const store = new Store(workspace); - const file = trackerPath(workspace, "test-project"); - writeFileSync(file, JSON.stringify(validProject(), null, 2)); - store.ingest(file, readFileSync(file, "utf-8")); - - const res = await store.applyPatch("test-project", { - tasks: { - t1: { blocker_reason: "b".repeat(2100) } - } - }); - assert.equal(res.ok, false); - assert.equal(res.status, 400); - assert.match(res.message, /blocker_reason/); - } finally { - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/fixtures.js b/test/fixtures.js index 78abaa7..e8395d1 100644 --- a/test/fixtures.js +++ b/test/fixtures.js @@ -1,16 +1,3 @@ -import { mkdirSync, mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -// Creates a temp workspace with the standard hub subdirectories. -export function makeWorkspace(prefix = "llm-tracker-test-") { - const ws = mkdtempSync(join(tmpdir(), prefix)); - for (const sub of ["trackers", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - return ws; -} - export function validProject(overrides = {}) { return { meta: { diff --git a/test/healthz.test.js b/test/healthz.test.js deleted file mode 100644 index 309c8aa..0000000 --- a/test/healthz.test.js +++ /dev/null @@ -1,92 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-healthz-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# healthz test\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 15000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - server.close(() => resolve(port)); - }); - }); -} - -test("GET /healthz returns 200 with ok/projects/uptimeSeconds — no auth needed", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const res = await fetch(`http://127.0.0.1:${port}/healthz`); - assert.equal(res.status, 200); - - const body = await res.json(); - assert.equal(body.ok, true); - assert.equal(typeof body.projects, "number"); - assert.equal(typeof body.uptimeSeconds, "number"); - // An empty workspace has zero projects — the endpoint must still succeed. - assert.ok(body.projects >= 0); - assert.ok(body.uptimeSeconds >= 0); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("GET /healthz is reachable without bearer token when LLM_TRACKER_TOKEN is set", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"], { - env: { ...process.env, LLM_TRACKER_TOKEN: "s3cret" } - }); - assert.equal(started.status, 0, started.stderr || started.stdout); - - // No Authorization header — must still get 200. - const res = await fetch(`http://127.0.0.1:${port}/healthz`); - assert.equal(res.status, 200); - - const body = await res.json(); - assert.equal(body.ok, true); - assert.equal(typeof body.projects, "number"); - assert.equal(typeof body.uptimeSeconds, "number"); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/helpers/fake-embedder.mjs b/test/helpers/fake-embedder.mjs deleted file mode 100644 index 9eaedca..0000000 --- a/test/helpers/fake-embedder.mjs +++ /dev/null @@ -1,26 +0,0 @@ -function normalize(vector) { - let norm = 0; - for (const value of vector) norm += value * value; - if (norm === 0) return vector.slice(); - const scale = 1 / Math.sqrt(norm); - return vector.map((value) => value * scale); -} - -const KEYWORDS = ["parallel", "route", "flow", "investor", "cost", "approval"]; - -function embedText(text) { - const lower = String(text || "").toLowerCase(); - const vector = KEYWORDS.map((keyword) => (lower.includes(keyword) ? 1 : 0)); - return normalize(vector); -} - -export default async function createEmbedder() { - return async (input) => { - const values = Array.isArray(input) ? input : [input]; - const rows = values.map((value) => embedText(value)); - return { - dims: [rows.length, KEYWORDS.length], - data: Float32Array.from(rows.flat()) - }; - }; -} diff --git a/test/ingest-lock.test.js b/test/ingest-lock.test.js deleted file mode 100644 index 73ea5a6..0000000 --- a/test/ingest-lock.test.js +++ /dev/null @@ -1,47 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Store, trackerPath } from "../hub/store.js"; -import { validProject } from "./fixtures.js"; - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-lock-")); - for (const sub of ["trackers", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - return ws; -} - -test("ingestLocked and applyPatch serialize through the same per-slug lock", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const p = validProject(); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(p)); - store.ingest(file, readFileSync(file, "utf-8")); - - const ingestPromise = store.ingestLocked(file, JSON.stringify({ - ...p, - meta: { ...p.meta, scratchpad: "from-ingest" } - })); - const patchPromise = store.applyPatch("test-project", { - meta: { scratchpad: "from-patch" } - }); - - const [ingestResult, patchResult] = await Promise.all([ingestPromise, patchPromise]); - assert.equal(ingestResult.ok, true); - assert.equal(patchResult.ok, true); - - const finalState = JSON.parse(readFileSync(file, "utf-8")); - // Final scratchpad must be from whichever of the two ran last — we don't - // care which, only that both ran cleanly without clobbering rev or tasks. - assert.ok(["from-ingest", "from-patch"].includes(finalState.meta.scratchpad)); - assert.ok(Number.isInteger(finalState.meta.rev)); - assert.equal(finalState.tasks.length, p.tasks.length); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); diff --git a/test/mcp-tools.test.js b/test/mcp-tools.test.js deleted file mode 100644 index 8c48a49..0000000 --- a/test/mcp-tools.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { createTools } from "../bin/mcp-tools.js"; -import { workspaceRuntimePayload } from "../bin/mcp-context-data.js"; -import { getPrompt } from "../bin/mcp-prompts.js"; -import { validProject } from "./fixtures.js"; - -function setupWorkspace(prefix = "llm-tracker-mcp-tools-") { - const workspace = mkdtempSync(join(tmpdir(), prefix)); - for (const sub of ["trackers", "patches", ".snapshots", ".history", ".runtime"]) { - mkdirSync(join(workspace, sub), { recursive: true }); - } - writeFileSync(join(workspace, "README.md"), "# MCP tool test workspace\n"); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - return workspace; -} - -test("tracker_patch is exposed across MCP tools, runtime metadata, and prompts", () => { - const workspace = setupWorkspace(); - try { - const tools = createTools(workspace); - assert.ok(tools.has("tracker_patch")); - - const runtime = workspaceRuntimePayload(workspace); - assert.ok(runtime.daemonRule.writeTools.includes("tracker_patch")); - - const startHere = getPrompt(workspace, "tracker_start_here"); - assert.match(startHere.messages[0].content.text, /tracker_patch/); - - const patchWrite = getPrompt(workspace, "tracker_patch_write", { slug: "test-project" }); - assert.match(patchWrite.messages[0].content.text, /tracker_patch/); - } finally { - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("tracker_patch validates required MCP arguments before attempting hub I/O", async () => { - const workspace = setupWorkspace("llm-tracker-mcp-tools-validate-"); - try { - const tool = createTools(workspace).get("tracker_patch"); - - const missingSlug = await tool.handler({ patch: { meta: { scratchpad: "hi" } } }); - assert.equal(missingSlug.isError, true); - assert.match(missingSlug.content[0].text, /requires a project slug/i); - - const missingPatch = await tool.handler({ slug: "test-project" }); - assert.equal(missingPatch.isError, true); - assert.match(missingPatch.content[0].text, /requires a JSON object patch/i); - } finally { - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/mcp.test.js b/test/mcp.test.js deleted file mode 100644 index 931c5c1..0000000 --- a/test/mcp.test.js +++ /dev/null @@ -1,539 +0,0 @@ -import { spawn, spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); -const FAKE_EMBEDDER = join(__dirname, "helpers", "fake-embedder.mjs"); - -function setupWorkspace(prefix = "llm-tracker-mcp-") { - const ws = mkdtempSync(join(tmpdir(), prefix)); - for (const sub of ["trackers", "patches", ".snapshots", ".history", ".runtime"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# MCP test workspace\n\nUse tracker_help first.\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 10000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "::", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : null; - server.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(port); - }); - }); - }); -} - -function encodeMessage(message) { - return Buffer.from(JSON.stringify(message) + "\n", "utf8"); -} - -function parseMessages(buffer) { - const messages = []; - let rest = buffer; - while (true) { - const newlineIndex = rest.indexOf("\n"); - if (newlineIndex === -1) break; - const line = rest.subarray(0, newlineIndex).toString("utf8").trim(); - rest = rest.subarray(newlineIndex + 1); - if (!line) continue; - messages.push(JSON.parse(line)); - } - return { messages, rest }; -} - -class McpClient { - constructor(child) { - this.child = child; - this.nextId = 1; - this.pending = new Map(); - this.buffer = Buffer.alloc(0); - - child.stdout.on("data", (chunk) => { - const parsed = parseMessages(Buffer.concat([this.buffer, chunk])); - this.buffer = parsed.rest; - for (const message of parsed.messages) { - const pending = this.pending.get(message.id); - if (!pending) continue; - this.pending.delete(message.id); - pending.resolve(message); - } - }); - - child.on("exit", (code, signal) => { - for (const pending of this.pending.values()) { - pending.reject(new Error(`MCP server exited: ${code ?? "null"} ${signal || ""}`.trim())); - } - this.pending.clear(); - }); - } - - request(method, params = undefined) { - const id = this.nextId++; - const payload = { jsonrpc: "2.0", id, method }; - if (params !== undefined) payload.params = params; - - const promise = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`timed out waiting for ${method}`)); - }, 5000); - timeout.unref?.(); - - this.pending.set(id, { - resolve: (message) => { - clearTimeout(timeout); - resolve(message); - }, - reject: (error) => { - clearTimeout(timeout); - reject(error); - } - }); - }); - - this.child.stdin.write(encodeMessage(payload)); - return promise; - } - - notify(method, params = undefined) { - const payload = { jsonrpc: "2.0", method }; - if (params !== undefined) payload.params = params; - this.child.stdin.write(encodeMessage(payload)); - } - - async initialize(protocolVersion = "2024-11-05") { - const init = await this.request("initialize", { - protocolVersion, - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0.0" } - }); - this.notify("notifications/initialized"); - return init; - } - - async close() { - this.child.stdin.end(); - this.child.kill(); - await new Promise((resolve) => this.child.once("exit", resolve)); - } -} - -function startMcp(workspace, extraArgs = [], options = {}) { - const child = spawn(process.execPath, [BIN, "mcp", "--path", workspace, ...extraArgs], { - stdio: ["pipe", "pipe", "pipe"], - ...options - }); - child.stderr.resume(); - return new McpClient(child); -} - -test("llm-tracker mcp initializes and lists tracker tools", async () => { - const workspace = setupWorkspace(); - const tracker = validProject(); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(tracker, null, 2)); - - const client = startMcp(workspace); - try { - const init = await client.initialize(); - assert.equal(init.result.protocolVersion, "2024-11-05"); - assert.equal(init.result.serverInfo.name, "llm-tracker"); - assert.ok(init.result.capabilities.tools); - assert.ok(init.result.capabilities.resources); - assert.ok(init.result.capabilities.prompts); - - const tools = await client.request("tools/list"); - const names = tools.result.tools.map((tool) => tool.name).sort(); - const expected = [ - "tracker_blockers", - "tracker_brief", - "tracker_changed", - "tracker_decisions", - "tracker_execute", - "tracker_fuzzy_search", - "tracker_help", - "tracker_history", - "tracker_next", - "tracker_patch", - "tracker_pick", - "tracker_project_status", - "tracker_projects", - "tracker_projects_status", - "tracker_redo", - "tracker_reload", - "tracker_search", - "tracker_undo", - "tracker_verify", - "tracker_why" - ]; - assert.deepEqual(names, expected); - - const helpTool = tools.result.tools.find((tool) => tool.name === "tracker_help"); - assert.equal(helpTool.inputSchema.type, "object"); - - const help = await client.request("tools/call", { name: "tracker_help", arguments: {} }); - assert.notEqual(help.result.isError, true); - assert.match(help.result.content[0].text, /Use tracker_help first/); - } finally { - await client.close(); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("llm-tracker mcp negotiates newer protocol versions", async () => { - const workspace = setupWorkspace("llm-tracker-mcp-proto-"); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(validProject(), null, 2)); - - const client = startMcp(workspace); - try { - const init = await client.initialize("2025-11-25"); - assert.equal(init.result.protocolVersion, "2025-11-25"); - assert.equal(init.result.serverInfo.name, "llm-tracker"); - } finally { - await client.close(); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("MCP resources expose workspace help, runtime, and project status", async () => { - const workspace = setupWorkspace("llm-tracker-mcp-resources-"); - const tracker = validProject(); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(tracker, null, 2)); - - const client = startMcp(workspace); - try { - await client.initialize(); - - const listed = await client.request("resources/list"); - const uris = listed.result.resources.map((resource) => resource.uri).sort(); - assert.ok(uris.includes("tracker://help")); - assert.ok(uris.includes("tracker://workspace/status")); - assert.ok(uris.includes("tracker://workspace/runtime")); - assert.ok(uris.includes("tracker://projects")); - assert.ok(uris.includes("tracker://projects/test-project/status")); - - const help = await client.request("resources/read", { uri: "tracker://help" }); - assert.match(help.result.contents[0].text, /Use tracker_help first/); - - const runtime = await client.request("resources/read", { uri: "tracker://workspace/runtime" }); - const runtimePayload = JSON.parse(runtime.result.contents[0].text); - assert.equal(runtimePayload.daemonRule.readToolsRequireDaemon, false); - assert.equal(runtimePayload.daemonRule.writeToolsRequireDaemon, true); - assert.ok(runtimePayload.daemonRule.writeTools.includes("tracker_patch")); - assert.match(runtimePayload.patchWorkflow.patchDirectory, /patches$/); - - const project = await client.request("resources/read", { uri: "tracker://projects/test-project/status" }); - const projectPayload = JSON.parse(project.result.contents[0].text); - assert.equal(projectPayload.project.slug, "test-project"); - assert.equal(projectPayload.project.counts.not_started, 1); - } finally { - await client.close(); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("MCP prompts expose tracker workflows and operational guidance", async () => { - const workspace = setupWorkspace("llm-tracker-mcp-prompts-"); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(validProject(), null, 2)); - - const client = startMcp(workspace); - try { - await client.initialize(); - - const listed = await client.request("prompts/list"); - const names = listed.result.prompts.map((prompt) => prompt.name).sort(); - assert.ok(names.includes("tracker_start_here")); - assert.ok(names.includes("tracker_pick_next")); - assert.ok(names.includes("tracker_task_context")); - assert.ok(names.includes("tracker_search_project")); - assert.ok(names.includes("tracker_execute_task")); - assert.ok(names.includes("tracker_verify_task")); - assert.ok(names.includes("tracker_patch_write")); - - const startHere = await client.request("prompts/get", { name: "tracker_start_here", arguments: {} }); - const startText = startHere.result.messages[0].content.text; - assert.match(startText, /tracker:\/\/help/); - assert.match(startText, /patches/); - assert.match(startText, /tracker_patch/); - assert.match(startText, /tracker_pick/); - - const execute = await client.request("prompts/get", { - name: "tracker_execute_task", - arguments: { slug: "test-project", taskId: "t1" } - }); - const executeText = execute.result.messages[0].content.text; - assert.match(executeText, /tracker_execute/); - assert.match(executeText, /tracker_verify/); - assert.match(executeText, /test-project/); - assert.match(executeText, /t1/); - } finally { - await client.close(); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("tracker_project_status and tracker_projects_status return status payloads over MCP", async () => { - const workspace = setupWorkspace("llm-tracker-mcp-status-"); - const tracker = validProject(); - tracker.meta.scratchpad = "status banner"; - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(tracker, null, 2)); - - const client = startMcp(workspace); - try { - await client.initialize(); - - const overall = await client.request("tools/call", { - name: "tracker_projects_status", - arguments: {} - }); - const overallPayload = JSON.parse(overall.result.content[0].text); - assert.equal(overallPayload.projectCount, 1); - assert.equal(overallPayload.projects[0].slug, "test-project"); - - const project = await client.request("tools/call", { - name: "tracker_project_status", - arguments: { slug: "test-project" } - }); - const projectPayload = JSON.parse(project.result.content[0].text); - assert.equal(projectPayload.project.slug, "test-project"); - assert.equal(projectPayload.project.scratchpad, "status banner"); - assert.equal(projectPayload.project.counts.not_started, 1); - } finally { - await client.close(); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("tracker_next returns deterministic ranking over stdio MCP", async () => { - const workspace = setupWorkspace("llm-tracker-mcp-next-"); - const tracker = validProject(); - tracker.tasks[0].comment = "Highest-priority ready task"; - tracker.tasks[1].dependencies = ["t1"]; - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(tracker, null, 2)); - - const client = startMcp(workspace); - try { - await client.initialize(); - - const next = await client.request("tools/call", { - name: "tracker_next", - arguments: { slug: "test-project", limit: 3 } - }); - - assert.notEqual(next.result.isError, true); - const payload = JSON.parse(next.result.content[0].text); - assert.equal(payload.recommendedTaskId, "t1"); - assert.equal(payload.next[0].id, "t1"); - assert.equal(payload.next[1].id, "t2"); - } finally { - await client.close(); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("tracker_search and tracker_fuzzy_search return semantic and fuzzy matches over MCP", async () => { - const workspace = setupWorkspace("llm-tracker-mcp-search-"); - const tracker = validProject({ - tasks: [ - { - id: "t-017", - title: "Parallel execution branch/variant route-flow proof", - status: "in_progress", - placement: { swimlaneId: "exec", priorityId: "p0" }, - dependencies: [], - context: { tags: ["parallel-execution"] } - }, - { - id: "t-018", - title: "Investor demo cost surface", - status: "not_started", - placement: { swimlaneId: "ops", priorityId: "p1" }, - dependencies: [], - context: { tags: ["investor-demo", "cost"] } - } - ] - }); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(tracker, null, 2)); - - const client = startMcp(workspace, [], { - env: { ...process.env, LLM_TRACKER_EMBEDDER_MODULE: FAKE_EMBEDDER } - }); - try { - await client.initialize(); - - const exact = await client.request("tools/call", { - name: "tracker_search", - arguments: { slug: "test-project", query: "route flow proof" } - }); - const exactPayload = JSON.parse(exact.result.content[0].text); - assert.equal(exactPayload.mode, "semantic"); - assert.equal(exactPayload.matches[0].id, "t-017"); - - const fuzzy = await client.request("tools/call", { - name: "tracker_fuzzy_search", - arguments: { slug: "test-project", query: "paralel route" } - }); - const fuzzyPayload = JSON.parse(fuzzy.result.content[0].text); - assert.equal(fuzzyPayload.mode, "fuzzy"); - assert.equal(fuzzyPayload.matches[0].id, "t-017"); - } finally { - await client.close(); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("tracker_pick goes through the running hub from MCP", async () => { - const workspace = setupWorkspace("llm-tracker-mcp-pick-"); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(validProject(), null, 2)); - const port = await findFreePort(); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const client = startMcp(workspace); - try { - await client.initialize(); - - const picked = await client.request("tools/call", { - name: "tracker_pick", - arguments: { - slug: "test-project", - assignee: "codex-mcp" - } - }); - - assert.notEqual(picked.result.isError, true); - const payload = JSON.parse(picked.result.content[0].text); - assert.equal(payload.pickedTaskId, "t1"); - assert.equal(payload.task.assignee, "codex-mcp"); - assert.equal(payload.task.status, "in_progress"); - } finally { - await client.close(); - } - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("tracker_patch goes through the running hub from MCP", async () => { - const workspace = setupWorkspace("llm-tracker-mcp-patch-"); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(validProject(), null, 2)); - const port = await findFreePort(); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const client = startMcp(workspace); - try { - await client.initialize(); - - const patched = await client.request("tools/call", { - name: "tracker_patch", - arguments: { - slug: "test-project", - patch: { - meta: { - scratchpad: "patched via mcp" - } - } - } - }); - - assert.notEqual(patched.result.isError, true); - const payload = JSON.parse(patched.result.content[0].text); - assert.equal(payload.ok, true); - assert.equal(payload.noop, false); - assert.match(payload.file, /trackers\/test-project\.json$/); - assert.equal(typeof payload.rev, "number"); - - const project = await client.request("tools/call", { - name: "tracker_project_status", - arguments: { slug: "test-project" } - }); - const projectPayload = JSON.parse(project.result.content[0].text); - assert.equal(projectPayload.project.scratchpad, "patched via mcp"); - } finally { - await client.close(); - } - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("tracker_history, tracker_undo, and tracker_redo work through MCP", async () => { - const workspace = setupWorkspace("llm-tracker-mcp-history-"); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(validProject(), null, 2)); - const port = await findFreePort(); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const client = startMcp(workspace); - try { - await client.initialize(); - - const picked = await client.request("tools/call", { - name: "tracker_pick", - arguments: { slug: "test-project", assignee: "codex-mcp" } - }); - const pickedPayload = JSON.parse(picked.result.content[0].text); - assert.equal(pickedPayload.task.status, "in_progress"); - - const history = await client.request("tools/call", { - name: "tracker_history", - arguments: { slug: "test-project", limit: 5 } - }); - const historyPayload = JSON.parse(history.result.content[0].text); - assert.ok(historyPayload.events.length >= 1); - - const undo = await client.request("tools/call", { - name: "tracker_undo", - arguments: { slug: "test-project" } - }); - const undoPayload = JSON.parse(undo.result.content[0].text); - assert.equal(undoPayload.action, "undo"); - - const redo = await client.request("tools/call", { - name: "tracker_redo", - arguments: { slug: "test-project" } - }); - const redoPayload = JSON.parse(redo.result.content[0].text); - assert.equal(redoPayload.action, "redo"); - } finally { - await client.close(); - } - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/merge.test.js b/test/merge.test.js index 86e52d6..6ad8e76 100644 --- a/test/merge.test.js +++ b/test/merge.test.js @@ -76,18 +76,6 @@ test("mergeProject preserves human-owned collapsed on swimlane", () => { assert.ok(notes.ignored.some((s) => s.includes("collapsed"))); }); -test("mergeProject preserves existing swimlane order when incoming rewrites rows", () => { - const existing = validProject(); - const incoming = JSON.parse(JSON.stringify(existing)); - incoming.meta.swimlanes = [incoming.meta.swimlanes[1], incoming.meta.swimlanes[0]]; - - const { merged } = mergeProject(existing, incoming); - assert.deepEqual( - merged.meta.swimlanes.map((lane) => lane.id), - ["exec", "ops"] - ); -}); - test("mergeProject strips collapsed from LLM's new swimlanes (hub-owned)", () => { const existing = validProject(); const incoming = JSON.parse(JSON.stringify(existing)); diff --git a/test/next-cli.test.js b/test/next-cli.test.js deleted file mode 100644 index 1ec7d10..0000000 --- a/test/next-cli.test.js +++ /dev/null @@ -1,88 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-next-cli-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# test workspace\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 10000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "::", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : null; - server.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(port); - }); - }); - }); -} - -async function waitForProject(port, slug) { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - try { - const res = await fetch(`http://localhost:${port}/api/projects/${slug}`); - if (res.status === 200) return; - } catch {} - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`timed out waiting for project ${slug}`); -} - -test("llm-tracker next renders ranked tasks from the hub", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - const project = validProject(); - project.tasks[0].reference = "hub/store.js:1-20"; - project.tasks[0].comment = "Top ready task"; - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(project, null, 2)); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - assert.equal(existsSync(join(workspace, ".runtime", "daemon.json")), true); - - await waitForProject(port, "test-project"); - - const next = runCli(["next", "test-project", "--path", workspace]); - assert.equal(next.status, 0, next.stderr || next.stdout); - assert.match(next.stdout, /test-project/); - assert.match(next.stdout, /t1/); - assert.match(next.stdout, /ready/); - assert.match(next.stdout, /explicit references available/); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/next.test.js b/test/next.test.js deleted file mode 100644 index 18e39ac..0000000 --- a/test/next.test.js +++ /dev/null @@ -1,230 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { buildNextPayload } from "../hub/next.js"; -import { validProject } from "./fixtures.js"; - -function projectWithPriorities() { - const project = validProject(); - project.meta.priorities = [ - { id: "p0", label: "P0 / Now" }, - { id: "p1", label: "P1 / Next" }, - { id: "p2", label: "P2 / Soon" }, - { id: "p3", label: "P3 / Later" } - ]; - project.meta.rev = 12; - return project; -} - -test("buildNextPayload ranks ready tasks before blocked tasks and normalizes legacy reference", () => { - const project = projectWithPriorities(); - project.tasks[0].comment = "Highest-priority ready task"; - project.tasks[0].reference = "hub/store.js:1-20"; - project.tasks[0].effort = "s"; - project.tasks[1].comment = "Blocked until task 1 completes"; - project.tasks[1].references = ["hub/server.js:1-40"]; - project.tasks.push({ - id: "t4", - title: "Task 4", - status: "not_started", - placement: { swimlaneId: "ops", priorityId: "p1" }, - dependencies: [], - effort: "m" - }); - - const payload = buildNextPayload({ - slug: "test-project", - data: project, - history: [ - { rev: 10, delta: { tasks: { t1: { status: "in_progress" } } } }, - { rev: 11, delta: { tasks: { t2: { comment: "blocked" } } } } - ] - }); - - assert.equal(payload.recommendedTaskId, "t1"); - assert.deepEqual( - payload.next.map((task) => task.id), - ["t1", "t4", "t2"] - ); - assert.deepEqual(payload.next[0].references, ["hub/store.js:1-20"]); - assert.equal(payload.next[0].ready, true); - assert.equal(payload.next[0].lastTouchedRev, 10); - assert.equal(payload.next[2].ready, false); - assert.equal(payload.next[2].blocked_kind, "deps"); - assert.deepEqual(payload.next[2].blocking_on, ["t1"]); -}); - -test("buildNextPayload uses effort and approval to break ties among ready tasks", () => { - const project = projectWithPriorities(); - project.tasks = [ - { - id: "t1", - title: "Fast task", - status: "not_started", - placement: { swimlaneId: "exec", priorityId: "p0" }, - dependencies: [], - effort: "xs" - }, - { - id: "t2", - title: "Slow task", - status: "not_started", - placement: { swimlaneId: "exec", priorityId: "p0" }, - dependencies: [], - effort: "xl", - approval_required_for: ["new dependencies"] - } - ]; - - const payload = buildNextPayload({ - slug: "test-project", - data: project - }); - - assert.deepEqual( - payload.next.map((task) => task.id), - ["t1", "t2"] - ); - assert.ok(payload.next[1].reason.some((reason) => reason.includes("requires approval"))); -}); - -test("buildNextPayload prefers bounded actionable work over aggregate roadmap rows", () => { - const project = projectWithPriorities(); - project.tasks = [ - { - id: "rm-investor-demo-open", - title: "Investor Demo Open Work", - status: "in_progress", - placement: { swimlaneId: "ops", priorityId: "p0" }, - dependencies: [], - context: { - tags: ["roadmap", "investor-demo-open"], - source_title: "Open Investor Demo Work", - task_count: 13, - completed_subtasks: 4, - open_subtasks: 9 - } - }, - { - id: "rm-parallel-execution", - title: "Parallel Execution", - status: "in_progress", - placement: { swimlaneId: "exec", priorityId: "p3" }, - dependencies: [], - context: { - tags: ["roadmap", "parallel-execution"], - source_title: "Current Parallel Execution Status", - task_count: 5, - completed_subtasks: 2, - open_subtasks: 3 - } - }, - { - id: "t-017", - title: "Parallel execution branch/variant route-flow proof", - status: "in_progress", - placement: { swimlaneId: "exec", priorityId: "p3" }, - dependencies: ["rm-parallel-execution"], - assignee: "codex", - references: [ - "docs/PHALANX_ROADMAP.md:619", - "docs/references/PARALLEL_EXECUTION_BRANCH_VARIANT_ROUTE_FLOW_PROOF_CLAUDE_PROMPT.md:1" - ], - context: { - tags: ["parallel-execution", "branch-variants", "route-flow-proof"] - } - } - ]; - - const payload = buildNextPayload({ - slug: "test-project", - data: project - }); - - assert.equal(payload.recommendedTaskId, "t-017"); - assert.deepEqual( - payload.next.map((task) => task.id), - ["t-017", "rm-investor-demo-open", "rm-parallel-execution"] - ); - assert.equal(payload.next[0].ready, true); - assert.equal(payload.next[0].aggregate, false); - assert.equal(payload.next[1].aggregate, true); - assert.ok(payload.next[1].reason.some((reason) => reason.includes("aggregate roadmap/container row"))); -}); - -test("buildNextPayload honestly falls back to aggregate roadmap rows when no bounded task exists", () => { - const project = projectWithPriorities(); - project.tasks = [ - { - id: "rm-live-run-progress-surface", - title: "Live Run Progress", - status: "in_progress", - placement: { swimlaneId: "ops", priorityId: "p0" }, - dependencies: [], - context: { - tags: ["roadmap", "live-run-progress-surface"], - source_title: "Next In-Line P0", - task_count: 11, - completed_subtasks: 10, - open_subtasks: 1 - } - }, - { - id: "rm-operator-trust", - title: "Operator Trust", - status: "not_started", - placement: { swimlaneId: "ops", priorityId: "p0" }, - dependencies: [], - context: { - tags: ["roadmap", "operator-trust"], - source_title: "Operator Trust Queue", - task_count: 11, - completed_subtasks: 9, - open_subtasks: 2 - } - } - ]; - - const payload = buildNextPayload({ - slug: "test-project", - data: project - }); - - assert.equal(payload.recommendedTaskId, "rm-live-run-progress-surface"); - assert.deepEqual( - payload.next.map((task) => task.id), - ["rm-live-run-progress-surface", "rm-operator-trust"] - ); - assert.equal(payload.next[0].aggregate, true); -}); - -test("buildNextPayload prefers active bounded work over starting a new bounded task", () => { - const project = projectWithPriorities(); - project.tasks = [ - { - id: "t-in-progress", - title: "Continue active seam", - status: "in_progress", - placement: { swimlaneId: "exec", priorityId: "p3" }, - dependencies: [], - assignee: "codex", - references: ["docs/plan.md:1-20"] - }, - { - id: "t-not-started", - title: "Start a fresh task", - status: "not_started", - placement: { swimlaneId: "exec", priorityId: "p2" }, - dependencies: [] - } - ]; - - const payload = buildNextPayload({ - slug: "test-project", - data: project - }); - - assert.deepEqual( - payload.next.map((task) => task.id), - ["t-in-progress", "t-not-started"] - ); -}); diff --git a/test/pick-cli.test.js b/test/pick-cli.test.js deleted file mode 100644 index b6b5677..0000000 --- a/test/pick-cli.test.js +++ /dev/null @@ -1,87 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-pick-cli-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# test workspace\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 10000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "::", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : null; - server.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(port); - }); - }); - }); -} - -async function waitForProject(port, slug) { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - try { - const res = await fetch(`http://localhost:${port}/api/projects/${slug}`); - if (res.status === 200) return; - } catch {} - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`timed out waiting for project ${slug}`); -} - -test("llm-tracker pick claims the top ready task atomically", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(validProject(), null, 2)); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - await waitForProject(port, "test-project"); - - const picked = runCli(["pick", "test-project", "--path", workspace, "--assignee", "codex"]); - assert.equal(picked.status, 0, picked.stderr || picked.stdout); - assert.match(picked.stdout, /picked t1/); - assert.match(picked.stdout, /assignee=codex/); - - const after = JSON.parse(readFileSync(join(workspace, "trackers", "test-project.json"), "utf-8")); - const t1 = after.tasks.find((task) => task.id === "t1"); - assert.equal(t1.status, "in_progress"); - assert.equal(t1.assignee, "codex"); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/pick.test.js b/test/pick.test.js deleted file mode 100644 index d860bd9..0000000 --- a/test/pick.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { buildPickedPayload, resolvePickSelection } from "../hub/pick.js"; -import { validProject } from "./fixtures.js"; - -test("resolvePickSelection auto-selects the top ready task", () => { - const project = validProject(); - project.meta.rev = 7; - - const result = resolvePickSelection({ - slug: "test-project", - data: project, - history: [] - }); - - assert.equal(result.ok, true); - assert.equal(result.autoSelected, true); - assert.equal(result.taskId, "t1"); -}); - -test("resolvePickSelection rejects blocked tasks unless forced", () => { - const project = validProject(); - - const blocked = resolvePickSelection({ - slug: "test-project", - data: project, - taskId: "t2" - }); - assert.equal(blocked.ok, false); - assert.equal(blocked.status, 409); - - const forced = resolvePickSelection({ - slug: "test-project", - data: project, - taskId: "t2", - force: true - }); - assert.equal(forced.ok, true); - assert.equal(forced.taskId, "t2"); -}); - -test("resolvePickSelection refuses in-progress tasks owned by another assignee", () => { - const project = validProject(); - - const conflict = resolvePickSelection({ - slug: "test-project", - data: project, - taskId: "t2", - assignee: "codex", - force: true - }); - assert.equal(conflict.ok, true); - - const noAssignee = resolvePickSelection({ - slug: "test-project", - data: project, - taskId: "t2" - }); - assert.equal(noAssignee.ok, false); - assert.equal(noAssignee.status, 409); -}); - -test("buildPickedPayload returns normalized task state", () => { - const project = validProject(); - project.meta.rev = 8; - project.tasks[0].status = "in_progress"; - project.tasks[0].assignee = "codex"; - project.tasks[0].reference = "hub/store.js:1-20"; - - const payload = buildPickedPayload({ - slug: "test-project", - data: project, - history: [{ rev: 8, delta: { tasks: { t1: { status: "in_progress", assignee: "codex" } } } }], - taskId: "t1", - autoSelected: true, - selectedBecause: "top ready task from next ranking" - }); - - assert.equal(payload.pickedTaskId, "t1"); - assert.equal(payload.task.assignee, "codex"); - assert.deepEqual(payload.task.references, ["hub/store.js:1-20"]); -}); diff --git a/test/progress.test.js b/test/progress.test.js index 6cf8861..4976bc3 100644 --- a/test/progress.test.js +++ b/test/progress.test.js @@ -29,15 +29,6 @@ test("pctFor returns 0 for empty or all-deferred", () => { assert.equal(pctFor([{ status: "deferred" }, { status: "deferred" }]), 0); }); -test("pctFor ignores outcome markers and still keys off status only", () => { - const tasks = [ - { status: "complete", outcome: "partial_slice_landed" }, - { status: "in_progress", outcome: "partial_slice_landed" } - ]; - - assert.equal(pctFor(tasks), 75); -}); - test("deriveBlocked finds open deps", () => { const p = validProject(); const blocked = deriveBlocked(p.tasks); diff --git a/test/references.test.js b/test/references.test.js deleted file mode 100644 index 3a553e5..0000000 --- a/test/references.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - hasNormalizedReferences, - isReferenceString, - normalizeEffort, - normalizeTaskReferences -} from "../hub/references.js"; - -test("normalizeTaskReferences prefers explicit references and folds in legacy reference", () => { - const refs = normalizeTaskReferences({ - reference: "hub/store.js:1-20", - references: ["ARCHITECTURE.md:10-40", "hub/store.js:1-20"] - }); - - assert.deepEqual(refs, ["ARCHITECTURE.md:10-40", "hub/store.js:1-20"]); -}); - -test("hasNormalizedReferences is true for legacy reference only", () => { - assert.equal(hasNormalizedReferences({ reference: "hub/server.js:1-10" }), true); - assert.equal(hasNormalizedReferences({}), false); -}); - -test("isReferenceString validates path and line ranges", () => { - assert.equal(isReferenceString("hub/server.js:1-20"), true); - assert.equal(isReferenceString("hub/server.js"), false); -}); - -test("normalizeEffort accepts only known effort values", () => { - assert.equal(normalizeEffort("m"), "m"); - assert.equal(normalizeEffort("huge"), null); - assert.equal(normalizeEffort(null), null); -}); diff --git a/test/reload-cli.test.js b/test/reload-cli.test.js deleted file mode 100644 index 68ef271..0000000 --- a/test/reload-cli.test.js +++ /dev/null @@ -1,184 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace(prefix = "llm-tracker-reload-cli-") { - const ws = mkdtempSync(join(tmpdir(), prefix)); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# test workspace\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 10000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "::", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : null; - server.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(port); - }); - }); - }); -} - -async function waitForProject(port, slug) { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - try { - const res = await fetch(`http://localhost:${port}/api/projects/${slug}`); - if (res.status === 200) return; - } catch {} - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`timed out waiting for project ${slug}`); -} - -test("llm-tracker link eagerly loads a symlinked tracker without waiting for watcher add", async () => { - const workspace = setupWorkspace("llm-tracker-link-cli-"); - const externalRoot = setupWorkspace("llm-tracker-link-target-"); - const port = await findFreePort(); - - try { - const external = validProject({ - meta: { - ...validProject().meta, - name: "External Project", - slug: "external-project" - } - }); - writeFileSync(join(externalRoot, "external-project.json"), JSON.stringify(external, null, 2)); - - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const linked = runCli([ - "link", - "external-project", - join(externalRoot, "external-project.json"), - "--path", - workspace - ]); - assert.equal(linked.status, 0, linked.stderr || linked.stdout); - assert.match(linked.stdout, /loaded: yes/); - - const project = await fetch(`http://localhost:${port}/api/projects/external-project`); - assert.equal(project.status, 200); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - rmSync(externalRoot, { recursive: true, force: true }); - } -}); - -test("slug routes auto-reload a tracker from disk before polling catches up", async () => { - const workspace = setupWorkspace("llm-tracker-auto-slug-"); - const port = await findFreePort(); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const project = validProject({ - meta: { - ...validProject().meta, - name: "Lazy Loaded", - slug: "lazy-loaded" - } - }); - writeFileSync(join(workspace, "trackers", "lazy-loaded.json"), JSON.stringify(project, null, 2)); - - const res = await fetch(`http://localhost:${port}/api/projects/lazy-loaded`); - assert.equal(res.status, 200); - - const json = await res.json(); - assert.equal(json.slug, "lazy-loaded"); - assert.equal(json.data.meta.name, "Lazy Loaded"); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("project list refreshes from disk before returning projects", async () => { - const workspace = setupWorkspace("llm-tracker-auto-list-"); - const port = await findFreePort(); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const project = validProject({ - meta: { - ...validProject().meta, - name: "List Reloaded", - slug: "list-reloaded" - } - }); - writeFileSync(join(workspace, "trackers", "list-reloaded.json"), JSON.stringify(project, null, 2)); - - const res = await fetch(`http://localhost:${port}/api/projects`); - assert.equal(res.status, 200); - - const json = await res.json(); - assert.equal(json.projects.some((item) => item.slug === "list-reloaded"), true); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("llm-tracker reload reloads a tracker from disk on demand", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - const project = validProject(); - const trackerFile = join(workspace, "trackers", "test-project.json"); - writeFileSync(trackerFile, JSON.stringify(project, null, 2)); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - await waitForProject(port, "test-project"); - - project.tasks[0].comment = "Reloaded from disk"; - writeFileSync(trackerFile, JSON.stringify(project, null, 2)); - - const reloaded = runCli(["reload", "test-project", "--path", workspace]); - assert.equal(reloaded.status, 0, reloaded.stderr || reloaded.stdout); - assert.match(reloaded.stdout, /Reloaded test-project/); - - const body = await fetch(`http://localhost:${port}/api/projects/test-project`); - const json = await body.json(); - assert.equal(json.data.tasks[0].comment, "Reloaded from disk"); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/restore.test.js b/test/restore.test.js deleted file mode 100644 index a9859bc..0000000 --- a/test/restore.test.js +++ /dev/null @@ -1,209 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { Store, trackerPath } from "../hub/store.js"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-restore-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# restore test\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 15000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - server.close(() => resolve(port)); - }); - }); -} - -test("restoreProject rehydrates the latest snapshot after delete", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const p = validProject(); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(p)); - store.ingest(file, readFileSync(file, "utf-8")); - - const deleted = await store.deleteProject("test-project"); - assert.equal(deleted.ok, true); - assert.equal(existsSync(file), false); - const afterDelete = store.history("test-project", { limit: 10 }); - assert.equal(afterDelete.deleted, true); - assert.ok(afterDelete.events.some((entry) => entry.action === "delete")); - - const restored = await store.restoreProject("test-project"); - assert.equal(restored.ok, true); - assert.ok(restored.restoredFromRev >= 1); - assert.ok(restored.newRev > deleted.deletedRev); - assert.equal(existsSync(file), true); - - const reloaded = JSON.parse(readFileSync(file, "utf-8")); - assert.equal(reloaded.meta.slug, "test-project"); - assert.equal(reloaded.meta.rev, restored.newRev); - - const revisions = store.revisions("test-project"); - assert.ok(revisions.some((entry) => entry.action === "delete")); - assert.ok( - revisions.some( - (entry) => - entry.action === "restore" && entry.restoredFromRev === restored.restoredFromRev - ) - ); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("restoreProject refuses when project still exists", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const p = validProject(); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(p)); - store.ingest(file, readFileSync(file, "utf-8")); - - const res = await store.restoreProject("test-project"); - assert.equal(res.ok, false); - assert.equal(res.status, 409); - assert.match(res.message, /already registered/); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("restoreProject errors when no snapshots exist", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const res = await store.restoreProject("never-seen"); - assert.equal(res.ok, false); - assert.equal(res.status, 404); - assert.match(res.message, /no snapshots/); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("restoreProject honors an explicit rev from snapshots", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const p = validProject(); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(p)); - store.ingest(file, readFileSync(file, "utf-8")); - - await store.applyPatch("test-project", { meta: { scratchpad: "after" } }); - const afterRev = store.get("test-project").rev; - assert.ok(afterRev >= 1); - - const deleted = await store.deleteProject("test-project"); - assert.equal(deleted.ok, true); - - const restored = await store.restoreProject("test-project", { rev: 1 }); - assert.equal(restored.ok, true); - assert.equal(restored.restoredFromRev, 1); - - const rehydrated = JSON.parse(readFileSync(file, "utf-8")); - assert.equal(rehydrated.meta.rev, restored.newRev); - assert.equal(rehydrated.meta.scratchpad || "", ""); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("restoreProject rejects unknown rev", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const p = validProject(); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(p)); - store.ingest(file, readFileSync(file, "utf-8")); - - const deleted = await store.deleteProject("test-project"); - assert.equal(deleted.ok, true); - - const res = await store.restoreProject("test-project", { rev: 999 }); - assert.equal(res.ok, false); - assert.equal(res.status, 404); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("restore endpoint + CLI round-trip through the running hub", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const del = await fetch(`http://127.0.0.1:${port}/api/projects/test-project`, { - method: "DELETE" - }); - assert.equal(del.status, 200); - assert.equal(existsSync(join(workspace, "trackers", "test-project.json")), false); - - const deletedHistory = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/history`); - assert.equal(deletedHistory.status, 200); - const deletedHistoryBody = await deletedHistory.json(); - assert.equal(deletedHistoryBody.deleted, true); - assert.ok(deletedHistoryBody.events.some((entry) => entry.action === "delete")); - - const restore = runCli(["restore", "test-project", "--path", workspace, "--port", String(port)]); - assert.equal(restore.status, 0, restore.stderr || restore.stdout); - assert.match(restore.stdout, /Restored test-project/); - assert.equal(existsSync(join(workspace, "trackers", "test-project.json")), true); - - const read = await fetch(`http://127.0.0.1:${port}/api/projects/test-project`); - assert.equal(read.status, 200); - const body = await read.json(); - assert.equal(body.data.meta.slug, "test-project"); - - const history = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/history`); - assert.equal(history.status, 200); - const historyBody = await history.json(); - assert.ok(historyBody.events.some((entry) => entry.action === "delete")); - assert.ok(historyBody.events.some((entry) => entry.action === "restore")); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/runtime-overlay.test.js b/test/runtime-overlay.test.js deleted file mode 100644 index 8502f45..0000000 --- a/test/runtime-overlay.test.js +++ /dev/null @@ -1,108 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - lstatSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - symlinkSync, - writeFileSync, - existsSync -} from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { Store, trackerPath } from "../hub/store.js"; -import { loadProjectEntry } from "../hub/project-loader.js"; -import { runtimeOverlayPath } from "../hub/runtime-overlay.js"; -import { validProject } from "./fixtures.js"; - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-overlay-")); - for (const sub of ["trackers", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# overlay test\n"); - return ws; -} - -test("linked tracker runtime fields persist through the workspace overlay without rewriting the target file", async () => { - const workspace = setupWorkspace(); - const repo = mkdtempSync(join(tmpdir(), "llm-tracker-overlay-repo-")); - const slug = "linked"; - const targetPath = join(repo, "linked.json"); - const targetBody = validProject({ - meta: { ...validProject().meta, slug, scratchpad: "" }, - tasks: validProject().tasks.map((task) => ({ ...task, assignee: null, blocker_reason: null })) - }); - writeFileSync(targetPath, JSON.stringify(targetBody, null, 2)); - symlinkSync(targetPath, trackerPath(workspace, slug)); - - try { - const store = new Store(workspace); - const linkPath = trackerPath(workspace, slug); - store.ingest(linkPath, readFileSync(linkPath, "utf-8")); - - const result = await store.applyPatch(slug, { - meta: { scratchpad: "runtime banner" }, - tasks: { - t1: { status: "complete", assignee: "codex", blocker_reason: "waiting on deploy" } - } - }); - - assert.equal(result.ok, true); - const entry = store.get(slug); - assert.equal(entry.data.meta.scratchpad, "runtime banner"); - assert.equal(entry.data.tasks.find((task) => task.id === "t1").status, "complete"); - assert.equal(entry.data.tasks.find((task) => task.id === "t1").assignee, "codex"); - - const onDiskTarget = JSON.parse(readFileSync(targetPath, "utf-8")); - assert.equal(onDiskTarget.meta.scratchpad, ""); - assert.equal(onDiskTarget.tasks.find((task) => task.id === "t1").status, "not_started"); - assert.equal(onDiskTarget.tasks.find((task) => task.id === "t1").assignee, null); - - const overlayFile = runtimeOverlayPath(workspace, slug); - assert.equal(existsSync(overlayFile), true); - const overlay = JSON.parse(readFileSync(overlayFile, "utf-8")); - assert.equal(overlay.meta.scratchpad, "runtime banner"); - assert.equal(overlay.tasks.t1.status, "complete"); - assert.equal(overlay.tasks.t1.assignee, "codex"); - assert.equal(lstatSync(linkPath).isSymbolicLink(), true); - } finally { - rmSync(workspace, { recursive: true, force: true }); - rmSync(repo, { recursive: true, force: true }); - } -}); - -test("project-loader reapplies the linked tracker runtime overlay on fresh reads", async () => { - const workspace = setupWorkspace(); - const repo = mkdtempSync(join(tmpdir(), "llm-tracker-overlay-loader-")); - const slug = "linked"; - const targetPath = join(repo, "linked.json"); - const targetBody = validProject({ - meta: { ...validProject().meta, slug, scratchpad: "" }, - tasks: validProject().tasks.map((task) => ({ ...task, assignee: null, blocker_reason: null })) - }); - writeFileSync(targetPath, JSON.stringify(targetBody, null, 2)); - symlinkSync(targetPath, trackerPath(workspace, slug)); - - try { - const store = new Store(workspace); - const linkPath = trackerPath(workspace, slug); - store.ingest(linkPath, readFileSync(linkPath, "utf-8")); - await store.applyPatch(slug, { - meta: { scratchpad: "runtime banner" }, - tasks: { t1: { status: "complete", assignee: "codex" } } - }); - - const loaded = loadProjectEntry(workspace, slug); - assert.equal(loaded.ok, true); - assert.equal(loaded.data.meta.scratchpad, "runtime banner"); - assert.equal(loaded.data.tasks.find((task) => task.id === "t1").status, "complete"); - assert.equal(loaded.data.tasks.find((task) => task.id === "t1").assignee, "codex"); - assert.equal(loaded.data.meta.rev, store.get(slug).rev); - } finally { - rmSync(workspace, { recursive: true, force: true }); - rmSync(repo, { recursive: true, force: true }); - } -}); diff --git a/test/search-cli.test.js b/test/search-cli.test.js deleted file mode 100644 index 0509490..0000000 --- a/test/search-cli.test.js +++ /dev/null @@ -1,115 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); -const FAKE_EMBEDDER = join(__dirname, "helpers", "fake-embedder.mjs"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-search-cli-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# test workspace\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 10000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "::", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : null; - server.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(port); - }); - }); - }); -} - -async function waitForProject(port, slug) { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - try { - const res = await fetch(`http://localhost:${port}/api/projects/${slug}`); - if (res.status === 200) return; - } catch {} - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`timed out waiting for project ${slug}`); -} - -test("llm-tracker search and fuzzy render matches from the hub", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - const project = validProject({ - tasks: [ - { - id: "t-017", - title: "Parallel execution branch/variant route-flow proof", - status: "in_progress", - placement: { swimlaneId: "exec", priorityId: "p0" }, - dependencies: [], - context: { tags: ["parallel-execution"] } - }, - { - id: "t-018", - title: "Investor demo cost surface", - status: "not_started", - placement: { swimlaneId: "ops", priorityId: "p1" }, - dependencies: [], - context: { tags: ["investor-demo", "cost"] } - } - ] - }); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(project, null, 2)); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"], { - env: { ...process.env, LLM_TRACKER_EMBEDDER_MODULE: FAKE_EMBEDDER } - }); - assert.equal(started.status, 0, started.stderr || started.stdout); - assert.equal(existsSync(join(workspace, ".runtime", "daemon.json")), true); - - await waitForProject(port, "test-project"); - - const semantic = runCli(["search", "test-project", "route flow proof", "--path", workspace], { - env: { ...process.env, LLM_TRACKER_EMBEDDER_MODULE: FAKE_EMBEDDER } - }); - assert.equal(semantic.status, 0, semantic.stderr || semantic.stdout); - assert.match(semantic.stdout, /semantic search/); - assert.match(semantic.stdout, /t-017/); - - const fuzzy = runCli(["fuzzy", "test-project", "paralel route", "--path", workspace], { - env: { ...process.env, LLM_TRACKER_EMBEDDER_MODULE: FAKE_EMBEDDER } - }); - assert.equal(fuzzy.status, 0, fuzzy.stderr || fuzzy.stdout); - assert.match(fuzzy.stdout, /fuzzy search/); - assert.match(fuzzy.stdout, /t-017/); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/search.test.js b/test/search.test.js deleted file mode 100644 index 8efb878..0000000 --- a/test/search.test.js +++ /dev/null @@ -1,291 +0,0 @@ -import { afterEach, test } from "node:test"; -import assert from "node:assert/strict"; -import { validProject } from "./fixtures.js"; -import { - buildFuzzySearchPayload, - buildSearchPayload, - setSemanticExtractorFactoryForTests, - setSemanticRuntimeFactoriesForTests, - setSemanticWasmModuleLoadersForTests -} from "../hub/search.js"; - -function makeKeywordEmbedderFactory(keywords) { - return async () => async (input) => { - const lower = String(input || "").toLowerCase(); - const row = keywords.map((keyword) => (lower.includes(keyword) ? 1 : 0)); - let norm = 0; - for (const value of row) norm += value * value; - const scaled = norm > 0 ? row.map((value) => value / Math.sqrt(norm)) : row; - return { data: Float32Array.from(scaled) }; - }; -} - -afterEach(() => { - setSemanticExtractorFactoryForTests(null); - setSemanticRuntimeFactoriesForTests(); - setSemanticWasmModuleLoadersForTests(); -}); - -test("buildSearchPayload returns semantic matches from the local embedder", async () => { - setSemanticExtractorFactoryForTests( - makeKeywordEmbedderFactory(["parallel", "route", "flow", "investor", "cost", "approval"]) - ); - - const project = validProject({ - tasks: [ - { - id: "t-017", - title: "Parallel execution branch/variant route-flow proof", - goal: "Prove the branch and route flow.", - status: "in_progress", - placement: { swimlaneId: "exec", priorityId: "p0" }, - dependencies: [], - context: { tags: ["parallel-execution"] } - }, - { - id: "t-018", - title: "Investor demo cost surface", - goal: "Show cost savings honestly.", - status: "not_started", - placement: { swimlaneId: "ops", priorityId: "p1" }, - dependencies: [], - context: { tags: ["investor-demo", "cost"] } - } - ] - }); - project.meta.rev = 21; - - const payload = await buildSearchPayload({ - slug: "test-project", - data: project, - query: "parallel route flow", - limit: 5, - workspace: "/tmp/search-test" - }); - - assert.equal(payload.project, "test-project"); - assert.equal(payload.mode, "semantic"); - assert.equal(payload.model, "Xenova/all-MiniLM-L6-v2"); - assert.equal(payload.matches.length, 1); - assert.equal(payload.matches[0].id, "t-017"); - assert.ok(payload.matches[0].score >= 0.9); -}); - -test("buildFuzzySearchPayload returns approximate lexical matches", () => { - const project = validProject(); - project.meta.rev = 12; - project.tasks[0].title = "Approval manifest validator"; - project.tasks[0].goal = "Validate the approval manifest before execution."; - project.tasks[0].comment = "Needed before shipping the approval flow."; - project.tasks[0].references = ["src/manifest.js:1-20"]; - project.tasks[1].context = { - tags: ["background-daemon", "runtime"], - notes: "Needs daemon runtime cleanup before restart." - }; - - const payload = buildFuzzySearchPayload({ - slug: "test-project", - data: project, - query: "approvl manfest", - limit: 5 - }); - - assert.equal(payload.project, "test-project"); - assert.equal(payload.mode, "fuzzy"); - assert.equal(payload.matches[0].id, "t1"); - assert.ok(payload.matches[0].matchedOn.includes("title") || payload.matches[0].matchedOn.includes("goal")); - - const tagPayload = buildFuzzySearchPayload({ - slug: "test-project", - data: project, - query: "background daemon", - limit: 5 - }); - assert.equal(tagPayload.matches[0].id, "t2"); - assert.ok(tagPayload.matches[0].matchedOn.includes("tag") || tagPayload.matches[0].matchedOn.includes("notes")); -}); - -test("buildSearchPayload falls back to fuzzy matches on unexpected semantic runtime errors", async () => { - setSemanticExtractorFactoryForTests(async () => { - throw new Error("unexpected embedder failure"); - }); - - const project = validProject({ - tasks: [ - { - id: "t-017", - title: "Parallel execution branch/variant route-flow proof", - goal: "Prove the branch and route flow.", - status: "in_progress", - placement: { swimlaneId: "exec", priorityId: "p0" }, - dependencies: [] - } - ] - }); - project.meta.rev = 22; - - const payload = await buildSearchPayload({ - slug: "test-project", - data: project, - query: "parallel route flow", - limit: 5, - workspace: "/tmp/search-test" - }); - - assert.equal(payload.mode, "semantic"); - assert.equal(payload.backend, "fuzzy_fallback"); - assert.equal(payload.warning, "semantic search unavailable: unexpected embedder failure"); - assert.equal(payload.matches[0].id, "t-017"); -}); - -test("buildSearchPayload falls back from native backend to local wasm semantic runtime", async () => { - setSemanticRuntimeFactoriesForTests({ - nativeFactory: async () => async () => { - throw new Error("onnxruntime-node native binding missing"); - }, - wasmFactory: makeKeywordEmbedderFactory(["parallel", "route", "flow", "investor"]) - }); - - const project = validProject({ - tasks: [ - { - id: "t-017", - title: "Parallel execution branch/variant route-flow proof", - goal: "Prove the branch and route flow.", - status: "in_progress", - placement: { swimlaneId: "exec", priorityId: "p0" }, - dependencies: [] - }, - { - id: "t-018", - title: "Investor demo cost surface", - goal: "Show cost savings honestly.", - status: "not_started", - placement: { swimlaneId: "ops", priorityId: "p1" }, - dependencies: [] - } - ] - }); - project.meta.rev = 23; - - const payload = await buildSearchPayload({ - slug: "test-project", - data: project, - query: "parallel route flow", - limit: 5, - workspace: "/tmp/search-test" - }); - - assert.equal(payload.mode, "semantic"); - assert.equal(payload.backend, "semantic"); - assert.match(payload.warning, /local wasm runtime/i); - assert.equal(payload.matches[0].id, "t-017"); -}); - -test("buildSearchPayload falls back from unavailable model runtimes to bundled local hash semantic runtime", async () => { - setSemanticRuntimeFactoriesForTests({ - nativeFactory: async () => async () => { - throw new Error("onnxruntime-node native binding missing"); - }, - wasmFactory: async () => async () => { - throw new TypeError("fetch failed"); - } - }); - - const project = validProject({ - tasks: [ - { - id: "t-017", - title: "Parallel execution branch/variant route-flow proof", - goal: "Prove the branch and route flow.", - status: "in_progress", - placement: { swimlaneId: "exec", priorityId: "p0" }, - dependencies: [] - }, - { - id: "t-018", - title: "Investor demo cost surface", - goal: "Show cost savings honestly.", - status: "not_started", - placement: { swimlaneId: "ops", priorityId: "p1" }, - dependencies: [] - } - ] - }); - project.meta.rev = 24; - - const payload = await buildSearchPayload({ - slug: "test-project", - data: project, - query: "parallel route flow", - limit: 5, - workspace: "/tmp/search-test" - }); - - assert.equal(payload.mode, "semantic"); - assert.equal(payload.backend, "semantic_hash_fallback"); - assert.match(payload.warning, /bundled local hash runtime/i); - assert.equal(payload.matches[0].id, "t-017"); - assert.ok(payload.matches[0].score > 0.3); -}); - -test("buildSearchPayload does not force an unsupported wasm device in the local fallback path", async () => { - const fakeEnv = { backends: { onnx: { wasm: {} } } }; - const pipelineCalls = []; - setSemanticRuntimeFactoriesForTests({ - nativeFactory: async () => async () => { - throw new Error("onnxruntime-node native binding missing"); - } - }); - setSemanticWasmModuleLoadersForTests({ - onnxWebLoader: async () => ({ default: { fake: true } }), - transformersWebLoader: async () => ({ - env: fakeEnv, - pipeline: async (...args) => { - pipelineCalls.push(args); - return makeKeywordEmbedderFactory(["parallel", "route", "flow", "investor"])(); - } - }) - }); - - const project = validProject({ - tasks: [ - { - id: "t-017", - title: "Parallel execution branch/variant route-flow proof", - goal: "Prove the branch and route flow.", - status: "in_progress", - placement: { swimlaneId: "exec", priorityId: "p0" }, - dependencies: [] - }, - { - id: "t-018", - title: "Investor demo cost surface", - goal: "Show cost savings honestly.", - status: "not_started", - placement: { swimlaneId: "ops", priorityId: "p1" }, - dependencies: [] - } - ] - }); - project.meta.rev = 24; - - const payload = await buildSearchPayload({ - slug: "test-project", - data: project, - query: "parallel route flow", - limit: 5, - workspace: "/tmp/search-test" - }); - - assert.equal(payload.mode, "semantic"); - assert.equal(payload.backend, "semantic"); - assert.match(payload.warning, /local wasm runtime/i); - assert.equal(payload.matches[0].id, "t-017"); - assert.equal(fakeEnv.allowLocalModels, false); - assert.deepEqual(pipelineCalls, [[ - "feature-extraction", - "Xenova/all-MiniLM-L6-v2", - { device: "auto" } - ]]); -}); diff --git a/test/security.test.js b/test/security.test.js deleted file mode 100644 index df0a4ba..0000000 --- a/test/security.test.js +++ /dev/null @@ -1,337 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { request as httpRequest } from "node:http"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { WebSocket } from "ws"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-sec-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# sec test\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 15000, - ...options - }); -} - -function sendHttpRequest({ port, method = "GET", path = "/", headers = {}, body }) { - return new Promise((resolve, reject) => { - const req = httpRequest( - { - host: "127.0.0.1", - port, - method, - path, - headers - }, - (res) => { - let text = ""; - res.setEncoding("utf-8"); - res.on("data", (chunk) => { - text += chunk; - }); - res.on("end", () => resolve({ status: res.statusCode || 0, headers: res.headers, text })); - } - ); - req.on("error", reject); - if (body) req.write(body); - req.end(); - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - server.close(() => resolve(port)); - }); - }); -} - -test("cross-origin POST is blocked with 403", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const res = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/patch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://evil.example.com" - }, - body: JSON.stringify({ meta: { scratchpad: "hacked" } }) - }); - assert.equal(res.status, 403); - const body = await res.json(); - assert.match(body.error, /cross-origin/); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("same-origin and no-origin POST are allowed", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const sameOrigin = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/patch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: `http://localhost:${port}` - }, - body: JSON.stringify({ meta: { scratchpad: "hi" } }) - }); - assert.equal(sameOrigin.status, 200); - - const noOrigin = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/patch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ meta: { scratchpad: "cli" } }) - }); - assert.equal(noOrigin.status, 200); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("same-origin POST is allowed for an explicit non-loopback host origin", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"], { - env: { ...process.env, LLM_TRACKER_HOST: "0.0.0.0" } - }); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const res = await sendHttpRequest({ - port, - method: "POST", - path: "/api/projects/test-project/patch", - headers: { - "Content-Type": "application/json", - Host: `devbox.lan:${port}`, - Origin: `http://devbox.lan:${port}` - }, - body: JSON.stringify({ meta: { scratchpad: "lan" } }) - }); - assert.equal(res.status, 200); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("bearer token is required and browser UI uses a session cookie without exposing the raw token", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"], { - env: { ...process.env, LLM_TRACKER_TOKEN: "s3cret" } - }); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const unauth = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/patch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ meta: { scratchpad: "x" } }) - }); - assert.equal(unauth.status, 401); - - const wrong = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/patch`, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: "Bearer wrong" }, - body: JSON.stringify({ meta: { scratchpad: "x" } }) - }); - assert.equal(wrong.status, 401); - - const good = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/patch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer s3cret" - }, - body: JSON.stringify({ meta: { scratchpad: "ok" } }) - }); - assert.equal(good.status, 200); - const goodBody = await good.json(); - assert.equal(goodBody.ok, true); - assert.equal(goodBody.noop, false); - assert.ok(Number.isInteger(goodBody.rev) && goodBody.rev >= 1); - assert.match(goodBody.updatedAt || "", /^\d{4}-\d{2}-\d{2}T/); - assert.equal(goodBody.file, realpathSync(join(workspace, "trackers", "test-project.json"))); - - const read = await fetch(`http://127.0.0.1:${port}/api/projects/test-project`); - assert.equal(read.status, 200); - const readBody = await read.json(); - assert.equal(readBody.file, realpathSync(join(workspace, "trackers", "test-project.json"))); - - const html = await fetch(`http://127.0.0.1:${port}/index.html`); - assert.equal(html.status, 200); - const text = await html.text(); - assert.doesNotMatch(text, /window\.__LLM_TRACKER_TOKEN=/); - assert.doesNotMatch(text, /s3cret/); - - const cookie = html.headers.get("set-cookie"); - assert.match(cookie || "", /llm_tracker_ui_session=/); - - const uiAuthed = await fetch(`http://127.0.0.1:${port}/api/projects/test-project/patch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: `http://localhost:${port}`, - Cookie: (cookie || "").split(";")[0] - }, - body: JSON.stringify({ meta: { scratchpad: "ui" } }) - }); - assert.equal(uiAuthed.status, 200); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("websocket rejects cross-origin upgrade with 403", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { - headers: { Origin: "http://evil.example.com" } - }); - const outcome = await new Promise((resolve) => { - ws.once("open", () => { - ws.close(); - resolve({ status: "open" }); - }); - ws.once("unexpected-response", (_req, res) => { - resolve({ status: "rejected", code: res.statusCode }); - }); - ws.once("error", (err) => resolve({ status: "error", message: err.message })); - }); - assert.equal(outcome.status, "rejected"); - assert.equal(outcome.code, 403); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("websocket allows same-origin upgrade", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { - headers: { Origin: `http://localhost:${port}` } - }); - await new Promise((resolve, reject) => { - ws.once("open", resolve); - ws.once("error", reject); - }); - ws.close(); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("websocket requires bearer token when LLM_TRACKER_TOKEN is set", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - writeFileSync( - join(workspace, "trackers", "test-project.json"), - JSON.stringify(validProject(), null, 2) - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"], { - env: { ...process.env, LLM_TRACKER_TOKEN: "wsauth" } - }); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const unauth = new WebSocket(`ws://127.0.0.1:${port}/ws`); - const unauthResult = await new Promise((resolve) => { - unauth.once("open", () => resolve({ status: "open" })); - unauth.once("unexpected-response", (_req, res) => resolve({ status: "rejected", code: res.statusCode })); - unauth.once("error", (err) => resolve({ status: "error", message: err.message })); - }); - assert.equal(unauthResult.status, "rejected"); - assert.equal(unauthResult.code, 401); - - const good = new WebSocket(`ws://127.0.0.1:${port}/ws`, { - headers: { Authorization: "Bearer wsauth" } - }); - await new Promise((resolve, reject) => { - good.once("open", resolve); - good.once("error", reject); - }); - good.close(); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/shortcuts-cli.test.js b/test/shortcuts-cli.test.js deleted file mode 100644 index 3c3c942..0000000 --- a/test/shortcuts-cli.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 10000, - ...options - }); -} - -test("shortcuts prints a zero-token shell wrapper", () => { - const res = runCli(["shortcuts"]); - assert.equal(res.status, 0, res.stderr || res.stdout); - assert.match(res.stdout, /eval "\$\(npx llm-tracker shortcuts\)"/); - assert.match(res.stdout, /__llm_tracker_cli\(\)/); - assert.match(res.stdout, /lt\(\)/); - assert.match(res.stdout, /lt next /); -}); - -test("shortcuts accepts a custom shell alias name", () => { - const res = runCli(["shortcuts", "--alias", "tracker"]); - assert.equal(res.status, 0, res.stderr || res.stdout); - assert.match(res.stdout, /tracker\(\)/); - assert.doesNotMatch(res.stdout, /lt\(\)/); -}); - -test("shortcuts rejects an invalid shell alias name", () => { - const res = runCli(["shortcuts", "--alias", "bad-name"]); - assert.notEqual(res.status, 0); - assert.match(res.stderr, /Invalid alias "bad-name"/); -}); diff --git a/test/snippets.test.js b/test/snippets.test.js deleted file mode 100644 index ef502a5..0000000 --- a/test/snippets.test.js +++ /dev/null @@ -1,128 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { inferProjectRoot, loadReferenceSnippets, snippetCachePath } from "../hub/snippets.js"; - -function setupWorkspace(prefix) { - const workspace = mkdtempSync(join(tmpdir(), prefix)); - mkdirSync(join(workspace, ".runtime"), { recursive: true }); - return workspace; -} - -test("loadReferenceSnippets resolves repo-linked trackers and refreshes cache when files change", () => { - const workspace = setupWorkspace("llm-tracker-snippets-ws-"); - const repoRoot = mkdtempSync(join(tmpdir(), "llm-tracker-snippets-repo-")); - - try { - mkdirSync(join(repoRoot, "src"), { recursive: true }); - mkdirSync(join(repoRoot, ".llm-tracker", "trackers"), { recursive: true }); - - const trackerPath = join(repoRoot, ".llm-tracker", "trackers", "test-project.json"); - const sourcePath = join(repoRoot, "src", "example.js"); - - writeFileSync(trackerPath, "{}\n"); - writeFileSync(sourcePath, "one\ntwo\nthree\n"); - - assert.equal(inferProjectRoot(workspace, trackerPath), realpathSync(repoRoot)); - - const first = loadReferenceSnippets({ - workspace, - slug: "test-project", - trackerPath, - references: ["src/example.js:2-3"], - indexedAtRev: 7 - }); - - assert.equal(first.snippets.length, 1); - assert.equal(first.snippets[0].text, "two\nthree"); - assert.equal(first.snippets[0].indexedAtRev, 7); - assert.match(first.snippets[0].hash, /^sha256:/); - assert.equal(existsSync(snippetCachePath(workspace, "test-project")), true); - - const firstHash = first.snippets[0].hash; - writeFileSync(sourcePath, "one\ntwo changed\nthree\n"); - - const second = loadReferenceSnippets({ - workspace, - slug: "test-project", - trackerPath, - references: ["src/example.js:2-3"], - indexedAtRev: 8 - }); - - assert.equal(second.snippets[0].text, "two changed\nthree"); - assert.equal(second.snippets[0].indexedAtRev, 8); - assert.notEqual(second.snippets[0].hash, firstHash); - - const cache = JSON.parse(readFileSync(snippetCachePath(workspace, "test-project"), "utf-8")); - assert.equal(cache.projectRoot, realpathSync(repoRoot)); - } finally { - rmSync(workspace, { recursive: true, force: true }); - rmSync(repoRoot, { recursive: true, force: true }); - } -}); - -test("loadReferenceSnippets resolves repo-relative references for linked hidden tracker directories", () => { - const workspace = setupWorkspace("llm-tracker-snippets-hidden-ws-"); - const repoRoot = mkdtempSync(join(tmpdir(), "llm-tracker-snippets-hidden-repo-")); - - try { - mkdirSync(join(repoRoot, "docs"), { recursive: true }); - mkdirSync(join(repoRoot, ".phalanx"), { recursive: true }); - - const trackerPath = join(repoRoot, ".phalanx", "project-phalanx.json"); - const docsPath = join(repoRoot, "docs", "PHALANX_ROADMAP.md"); - - writeFileSync(trackerPath, "{}\n"); - writeFileSync(docsPath, "alpha\nbeta\ngamma\n"); - - assert.equal(inferProjectRoot(workspace, trackerPath), realpathSync(repoRoot)); - - const result = loadReferenceSnippets({ - workspace, - slug: "project-phalanx", - trackerPath, - references: ["docs/PHALANX_ROADMAP.md:2-3"], - indexedAtRev: 11 - }); - - assert.equal(result.snippets.length, 1); - assert.equal(result.projectRoot, realpathSync(repoRoot)); - assert.equal(result.snippets[0].text, "beta\ngamma"); - assert.equal(result.snippets[0].indexedAtRev, 11); - } finally { - rmSync(workspace, { recursive: true, force: true }); - rmSync(repoRoot, { recursive: true, force: true }); - } -}); - -test("loadReferenceSnippets returns graceful errors for stale references", () => { - const workspace = setupWorkspace("llm-tracker-snippets-stale-ws-"); - const repoRoot = mkdtempSync(join(tmpdir(), "llm-tracker-snippets-stale-repo-")); - - try { - mkdirSync(join(repoRoot, "src"), { recursive: true }); - mkdirSync(join(repoRoot, ".llm-tracker", "trackers"), { recursive: true }); - - const trackerPath = join(repoRoot, ".llm-tracker", "trackers", "test-project.json"); - writeFileSync(trackerPath, "{}\n"); - writeFileSync(join(repoRoot, "src", "tiny.js"), "alpha\n"); - - const result = loadReferenceSnippets({ - workspace, - slug: "test-project", - trackerPath, - references: ["src/tiny.js:5-6"], - indexedAtRev: 3 - }); - - assert.equal(result.snippets.length, 1); - assert.equal(result.snippets[0].text, ""); - assert.match(result.snippets[0].error, /stale reference/); - } finally { - rmSync(workspace, { recursive: true, force: true }); - rmSync(repoRoot, { recursive: true, force: true }); - } -}); diff --git a/test/store.test.js b/test/store.test.js index 8891f67..87c5eb7 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -161,28 +161,6 @@ test("applyPatch: updates only the fields the patch mentions", async () => { } }); -test("applyPatch: normalizes legacy partial status to in_progress", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(validProject())); - store.ingest(file, readFileSync(file, "utf-8")); - - const res = await store.applyPatch("test-project", { - tasks: { t1: { status: "partial" } } - }); - assert.equal(res.ok, true); - assert.ok(res.notes.warnings.some((warning) => warning.includes('"partial" -> "in_progress"'))); - - const after = JSON.parse(readFileSync(file, "utf-8")); - const t1 = after.tasks.find((task) => task.id === "t1"); - assert.equal(t1.status, "in_progress"); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - test("applyPatch: 404 when project doesn't exist", async () => { const ws = setupWorkspace(); try { @@ -195,124 +173,6 @@ test("applyPatch: 404 when project doesn't exist", async () => { } }); -test("applyPatch: rejects brand-new patch tasks that start complete or deferred", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(validProject())); - store.ingest(file, readFileSync(file, "utf-8")); - - const res = await store.applyPatch("test-project", { - tasks: { - t4: { - title: "Historical packaging row", - status: "complete", - placement: { swimlaneId: "ops", priorityId: "p1" } - } - } - }); - - assert.equal(res.ok, false); - assert.equal(res.status, 400); - assert.equal(res.type, "schema"); - assert.match(res.message, /new tasks added through patch mode/); - assert.match(res.hint, /not_started/); - assert.match(res.hint, /in_progress/); - - const after = JSON.parse(readFileSync(file, "utf-8")); - assert.equal(after.tasks.some((task) => task.id === "t4"), false); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("pickTask auto-selects the top ready task and updates status atomically", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(validProject())); - store.ingest(file, readFileSync(file, "utf-8")); - - const res = await store.pickTask("test-project", { assignee: "codex" }); - assert.equal(res.ok, true); - assert.equal(res.payload.pickedTaskId, "t1"); - assert.equal(res.payload.autoSelected, true); - - const after = JSON.parse(readFileSync(file, "utf-8")); - const t1 = after.tasks.find((task) => task.id === "t1"); - assert.equal(t1.status, "in_progress"); - assert.equal(t1.assignee, "codex"); - assert.equal(t1.blocker_reason ?? null, null); - assert.ok(after.meta.rev >= 2); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("pickTask rejects blocked tasks without force", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(validProject())); - store.ingest(file, readFileSync(file, "utf-8")); - - const res = await store.pickTask("test-project", { taskId: "t2", assignee: "claude" }); - assert.equal(res.ok, false); - assert.equal(res.status, 409); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("pickTask refuses to steal an in-progress task without force", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const project = validProject(); - project.tasks[1].dependencies = []; - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(project)); - store.ingest(file, readFileSync(file, "utf-8")); - - const res = await store.pickTask("test-project", { taskId: "t2", assignee: "codex" }); - assert.equal(res.ok, false); - assert.equal(res.status, 409); - - const forced = await store.pickTask("test-project", { - taskId: "t2", - assignee: "codex", - force: true - }); - assert.equal(forced.ok, true); - assert.equal(forced.payload.task.assignee, "codex"); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("pickTask is a no-op when the same assignee re-claims the same task", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const project = validProject(); - project.tasks[1].dependencies = []; - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(project)); - store.ingest(file, readFileSync(file, "utf-8")); - - const res = await store.pickTask("test-project", { taskId: "t2", assignee: "claude" }); - assert.equal(res.ok, true); - assert.equal(res.noop, true); - const after = JSON.parse(readFileSync(file, "utf-8")); - assert.equal(after.meta.rev, 1); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - test("ingest: direct-file-edit that reorders is corrected back to existing order", () => { const ws = setupWorkspace(); try { @@ -334,29 +194,6 @@ test("ingest: direct-file-edit that reorders is corrected back to existing order } }); -test("ingest: normalizes legacy partial status from tracker file", () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const file = trackerPath(ws, "test-project"); - const project = validProject(); - project.tasks[0].status = "partial"; - writeFileSync(file, JSON.stringify(project)); - - const res = store.ingest(file, readFileSync(file, "utf-8")); - assert.equal(res.ok, true); - - const entry = store.get("test-project"); - assert.equal(entry.data.tasks[0].status, "in_progress"); - assert.ok(entry.notes.warnings.some((warning) => warning.includes('"partial" -> "in_progress"'))); - - const after = JSON.parse(readFileSync(file, "utf-8")); - assert.equal(after.tasks[0].status, "in_progress"); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - test("applyCollapse: flips swimlane collapsed flag", async () => { const ws = setupWorkspace(); try { @@ -454,23 +291,6 @@ test("createOrReplace validates slug parity + schema", async () => { } }); -test("createOrReplace accepts legacy partial status and writes canonical status", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const project = validProject(); - project.tasks[0].status = "partial"; - - const res = await store.createOrReplace("test-project", project); - assert.equal(res.ok, true); - - const after = JSON.parse(readFileSync(trackerPath(ws, "test-project"), "utf-8")); - assert.equal(after.tasks[0].status, "in_progress"); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - test("deleteProject removes tracker file", async () => { const ws = setupWorkspace(); try { diff --git a/test/swimlane-move.test.js b/test/swimlane-move.test.js deleted file mode 100644 index 3b6ce2e..0000000 --- a/test/swimlane-move.test.js +++ /dev/null @@ -1,60 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync, rmSync, writeFileSync } from "node:fs"; -import { Store, trackerPath } from "../hub/store.js"; -import { makeWorkspace, validProject } from "./fixtures.js"; - -test("applySwimlaneMove reorders swimlanes in memory and on disk", async () => { - const ws = makeWorkspace("llm-tracker-swimlane-"); - try { - const store = new Store(ws); - const p = validProject(); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(p)); - store.ingest(file, readFileSync(file, "utf-8")); - - const moved = await store.applySwimlaneMove("test-project", { - swimlaneId: "ops", - direction: "up" - }); - assert.equal(moved.ok, true); - - const after = JSON.parse(readFileSync(file, "utf-8")); - assert.deepEqual( - after.meta.swimlanes.map((lane) => lane.id), - ["ops", "exec"] - ); - assert.deepEqual( - store.get("test-project").data.meta.swimlanes.map((lane) => lane.id), - ["ops", "exec"] - ); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("applySwimlaneMove no-ops at the boundary", async () => { - const ws = makeWorkspace("llm-tracker-swimlane-"); - try { - const store = new Store(ws); - const p = validProject(); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(p)); - store.ingest(file, readFileSync(file, "utf-8")); - - const moved = await store.applySwimlaneMove("test-project", { - swimlaneId: "exec", - direction: "up" - }); - assert.equal(moved.ok, true); - assert.equal(moved.noop, true); - - const after = JSON.parse(readFileSync(file, "utf-8")); - assert.deepEqual( - after.meta.swimlanes.map((lane) => lane.id), - ["exec", "ops"] - ); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); diff --git a/test/task-intel-cli.test.js b/test/task-intel-cli.test.js deleted file mode 100644 index d68d962..0000000 --- a/test/task-intel-cli.test.js +++ /dev/null @@ -1,98 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-intel-cli-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# test workspace\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 10000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "::", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : null; - server.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(port); - }); - }); - }); -} - -async function waitForProject(port, slug) { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - try { - const res = await fetch(`http://localhost:${port}/api/projects/${slug}`); - if (res.status === 200) return; - } catch {} - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`timed out waiting for project ${slug}`); -} - -test("llm-tracker blockers and changed render deterministic task intelligence", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - const project = validProject(); - project.tasks[1].comment = "Waiting on task 1"; - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(project, null, 2)); - writeFileSync( - join(workspace, ".history", "test-project.jsonl"), - [ - JSON.stringify({ rev: 1, ts: "2026-04-15T00:00:00.000Z", delta: { tasks: { t1: { __added__: project.tasks[0] } } } }), - JSON.stringify({ rev: 2, ts: "2026-04-15T00:01:00.000Z", delta: { tasks: { t2: { comment: "Waiting on task 1" } } } }) - ].join("\n") + "\n" - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - await waitForProject(port, "test-project"); - - const blockers = runCli(["blockers", "test-project", "--path", workspace]); - assert.equal(blockers.status, 0, blockers.stderr || blockers.stdout); - assert.match(blockers.stdout, /BLOCKED/); - assert.match(blockers.stdout, /t2/); - assert.match(blockers.stdout, /blocking: t1/); - - const changed = runCli(["changed", "test-project", "0", "--path", workspace]); - assert.equal(changed.status, 0, changed.stderr || changed.stdout); - assert.match(changed.stdout, /changed since rev 0/); - assert.match(changed.stdout, /t2/); - assert.match(changed.stdout, /comment/); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/tombstones.test.js b/test/tombstones.test.js deleted file mode 100644 index 80c5373..0000000 --- a/test/tombstones.test.js +++ /dev/null @@ -1,125 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { mergeProject } from "../hub/merge.js"; -import { Store, trackerPath } from "../hub/store.js"; -import { validateProject } from "../hub/validator.js"; -import { makeWorkspace, validProject } from "./fixtures.js"; - -test("validator accepts meta.deleted_tasks as string[]", () => { - const p = validProject(); - p.meta.deleted_tasks = ["t1"]; - const { ok } = validateProject(p); - assert.equal(ok, true); -}); - -test("deleteTask tombstones the id in meta.deleted_tasks", async () => { - const ws = makeWorkspace("llm-tracker-tomb-"); - try { - const store = new Store(ws); - const p = validProject(); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(p)); - store.ingest(file, readFileSync(file, "utf-8")); - - const res = await store.deleteTask("test-project", "t1"); - assert.equal(res.ok, true); - - const after = JSON.parse(readFileSync(file, "utf-8")); - assert.deepEqual(after.meta.deleted_tasks, ["t1"]); - assert.equal(after.tasks.find((t) => t.id === "t1"), undefined); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("merge drops incoming task updates that resurrect a tombstoned id", () => { - const existing = validProject(); - existing.meta.deleted_tasks = ["t1"]; - existing.tasks = existing.tasks.filter((t) => t.id !== "t1"); - - const incoming = validProject(); - // Stale LLM writes a full array still including t1 - const { merged, notes } = mergeProject(existing, incoming); - - assert.equal(merged.tasks.find((t) => t.id === "t1"), undefined); - assert.ok( - notes.ignored.some((msg) => msg.includes("t1") && msg.includes("refusing to resurrect")) - ); -}); - -test("merge drops incoming meta.deleted_tasks so LLMs can't clear tombstones", () => { - const existing = validProject(); - existing.meta.deleted_tasks = ["t1"]; - existing.tasks = existing.tasks.filter((t) => t.id !== "t1"); - - const incoming = JSON.parse(JSON.stringify(existing)); - incoming.meta.deleted_tasks = []; // LLM tries to wipe the tombstone list - - const { merged, notes } = mergeProject(existing, incoming); - assert.deepEqual(merged.meta.deleted_tasks, ["t1"]); - assert.ok(notes.ignored.some((msg) => msg.includes("deleted_tasks is hub-owned"))); -}); - -test("applyPatch cannot resurrect a tombstoned task via new-id patch", async () => { - const ws = makeWorkspace("llm-tracker-tomb-"); - try { - const store = new Store(ws); - const p = validProject(); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(p)); - store.ingest(file, readFileSync(file, "utf-8")); - - const deleted = await store.deleteTask("test-project", "t2"); - assert.equal(deleted.ok, true); - - const res = await store.applyPatch("test-project", { - tasks: { - t2: { - id: "t2", - title: "ressurected task", - status: "in_progress", - placement: { swimlaneId: "exec", priorityId: "p0" } - } - } - }); - assert.equal(res.ok, true); - assert.ok( - res.notes.ignored.some((msg) => msg.includes("t2") && msg.includes("refusing to resurrect")) - ); - - const after = JSON.parse(readFileSync(file, "utf-8")); - assert.equal(after.tasks.find((t) => t.id === "t2"), undefined); - assert.ok(after.meta.deleted_tasks.includes("t2")); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("rollback to a rev before deletion clears the tombstone (undo flow)", async () => { - const ws = makeWorkspace("llm-tracker-tomb-"); - try { - const store = new Store(ws); - const p = validProject(); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(p)); - store.ingest(file, readFileSync(file, "utf-8")); - - const baseRev = store.get("test-project").rev; - const del = await store.deleteTask("test-project", "t2"); - assert.equal(del.ok, true); - const afterDel = store.get("test-project"); - assert.ok(afterDel.data.meta.deleted_tasks.includes("t2")); - - const rb = await store.rollback("test-project", baseRev); - assert.equal(rb.ok, true); - - const restored = JSON.parse(readFileSync(file, "utf-8")); - const tomb = restored.meta.deleted_tasks || []; - assert.ok(!tomb.includes("t2"), "rollback to a pre-delete rev should clear the tombstone"); - assert.ok(restored.tasks.some((t) => t.id === "t2")); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); diff --git a/test/ui-intelligence.test.js b/test/ui-intelligence.test.js deleted file mode 100644 index f464f16..0000000 --- a/test/ui-intelligence.test.js +++ /dev/null @@ -1,80 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - buildCardMetaFacts, - buildTaskFactList, - defaultChangedFromRev, - historyActionText, - isIntelModeLoading -} from "../ui/lib/intelligence.js"; -import { humanizeTaskOutcome, TASK_OUTCOME_VALUES } from "../ui/task-outcomes.js"; - -test("defaultChangedFromRev clamps at zero", () => { - assert.equal(defaultChangedFromRev(null), 0); - assert.equal(defaultChangedFromRev(3, 10), 0); - assert.equal(defaultChangedFromRev(18, 10), 8); -}); - -test("historyActionText reports delete, restore, undo, redo, and rollback events", () => { - assert.equal(historyActionText({ action: "delete", deletedFromRev: 14 }), "delete after rev 14"); - assert.equal(historyActionText({ action: "restore", restoredFromRev: 7 }), "restore from rev 7"); - assert.equal(historyActionText({ action: "undo", undoOfRev: 14 }), "undo of rev 14"); - assert.equal(historyActionText({ action: "redo", redoOfRev: 14 }), "redo of rev 14"); - assert.equal(historyActionText({ rolledBackTo: 7 }), "rollback to rev 7"); - assert.equal(historyActionText({ rolledBackFrom: 18 }), "rollback from rev 18"); - assert.equal(historyActionText({}), "change"); -}); - -test("buildTaskFactList surfaces readiness, approvals, and freshness", () => { - const facts = buildTaskFactList({ - status: "in_progress", - priorityId: "p0", - swimlaneId: "exec", - effort: "m", - ready: false, - blocked_kind: "deps", - blocking_on: ["t-001", "t-002"], - requires_approval: ["new dependency"], - assignee: "codex", - lastTouchedRev: 12 - }); - - assert.deepEqual( - facts.map((fact) => fact.label), - ["status", "priority", "lane", "effort", "ready", "blocked", "blocking_on", "approval", "assignee", "last_touch"] - ); - assert.equal(facts.find((fact) => fact.label === "ready")?.value, "no"); - assert.equal(facts.find((fact) => fact.label === "approval")?.value, "new dependency"); - assert.equal(facts.find((fact) => fact.label === "last_touch")?.value, "rev 12"); -}); - -test("buildCardMetaFacts surfaces blocked deps, approvals, and task rev", () => { - const facts = buildCardMetaFacts( - { - approval_required_for: ["breaking API change", "new dependency"], - rev: 17 - }, - ["t-004"] - ); - - assert.deepEqual( - facts.map((fact) => fact.label), - ["deps", "approval", "rev"] - ); - assert.equal(facts[0].value, "t-004"); - assert.equal(facts[1].value, "2 gates"); - assert.equal(facts[2].value, "r17"); -}); - -test("task outcome helpers expose the canonical marker label", () => { - assert.ok(TASK_OUTCOME_VALUES.includes("partial_slice_landed")); - assert.equal(humanizeTaskOutcome("partial_slice_landed"), "partial slice landed"); - assert.equal(humanizeTaskOutcome(null), ""); -}); - -test("isIntelModeLoading derives loading per mode from cache and errors", () => { - assert.equal(isIntelModeLoading({}, {}, "why"), true); - assert.equal(isIntelModeLoading({ why: { packType: "why" } }, {}, "why"), false); - assert.equal(isIntelModeLoading({}, { why: "request failed" }, "why"), false); - assert.equal(isIntelModeLoading({}, {}, null), false); -}); diff --git a/test/validator.test.js b/test/validator.test.js index 3bc0638..64157bc 100644 --- a/test/validator.test.js +++ b/test/validator.test.js @@ -79,47 +79,12 @@ test("accepts a task reference with a line range", () => { assert.equal(ok, true); }); -test("accepts additive references[] alongside legacy reference", () => { - const p = validProject(); - p.tasks[0].reference = "hub/store.js:1-20"; - p.tasks[0].references = ["ARCHITECTURE.md:10-40", "hub/server.js:1-20"]; - const { ok, errors } = validateProject(p); - assert.equal(ok, true, errors.join("; ")); -}); - -test("accepts the partial_slice_landed outcome marker", () => { - const p = validProject(); - p.tasks[0].outcome = "partial_slice_landed"; - const { ok, errors } = validateProject(p); - assert.equal(ok, true, errors.join("; ")); -}); - -test("rejects unsupported outcome values", () => { - const p = validProject(); - p.tasks[0].outcome = "half_done"; - const { ok, errors } = validateProject(p); - assert.equal(ok, false); - assert.ok(errors.some((e) => e.includes("outcome"))); - assert.ok(errors.some((e) => e.includes("must be one of"))); -}); - -test("rejects malformed references[] entries", () => { - const p = validProject(); - p.tasks[0].references = ["hub/server.js"]; - const { ok, errors } = validateProject(p); - assert.equal(ok, false); - assert.ok(errors.some((e) => e.includes("references"))); - assert.ok(errors.some((e) => e.includes("path:line"))); - assert.ok(errors.some((e) => e.includes("bare URLs are invalid"))); -}); - test("rejects a task reference without a line number", () => { const p = validProject(); p.tasks[0].reference = "src/hub/store.js"; const { ok, errors } = validateProject(p); assert.equal(ok, false); assert.ok(errors.some((e) => e.includes("reference"))); - assert.ok(errors.some((e) => e.includes("path:line"))); }); test("allows task reference to be null (clears the field)", () => { @@ -136,21 +101,6 @@ test("accepts a task comment under 500 chars", () => { assert.equal(ok, true); }); -test("accepts supported effort values", () => { - const p = validProject(); - p.tasks[0].effort = "m"; - const { ok } = validateProject(p); - assert.equal(ok, true); -}); - -test("rejects unsupported effort values", () => { - const p = validProject(); - p.tasks[0].effort = "xxl"; - const { ok, errors } = validateProject(p); - assert.equal(ok, false); - assert.ok(errors.some((e) => e.includes("effort"))); -}); - test("rejects a task comment over 500 chars", () => { const p = validProject(); p.tasks[0].comment = "x".repeat(501); diff --git a/test/verify-cli.test.js b/test/verify-cli.test.js deleted file mode 100644 index 1817655..0000000 --- a/test/verify-cli.test.js +++ /dev/null @@ -1,88 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-verify-cli-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history", "docs"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# test workspace\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 10000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "::", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : null; - server.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(port); - }); - }); - }); -} - -async function waitForProject(port, slug) { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - try { - const res = await fetch(`http://localhost:${port}/api/projects/${slug}`); - if (res.status === 200) return; - } catch {} - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`timed out waiting for project ${slug}`); -} - -test("llm-tracker verify renders the deterministic verify pack from the hub", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - const project = validProject(); - project.tasks[0].status = "complete"; - project.tasks[0].reference = "docs/guide.md:1-2"; - project.tasks[0].definition_of_done = ["Guide text reviewed"]; - project.tasks[0].expected_changes = ["docs/guide.md"]; - writeFileSync(join(workspace, "docs", "guide.md"), "line one\nline two\n"); - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(project, null, 2)); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - await waitForProject(port, "test-project"); - - const verify = runCli(["verify", "test-project", "t1", "--path", workspace]); - assert.equal(verify.status, 0, verify.stderr || verify.stdout); - assert.match(verify.stdout, /CHECKS/); - assert.match(verify.stdout, /REFERENCES/); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/verify.test.js b/test/verify.test.js deleted file mode 100644 index b43349c..0000000 --- a/test/verify.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { buildVerifyPayload } from "../hub/verify.js"; -import { validProject } from "./fixtures.js"; - -test("buildVerifyPayload derives evidence sources and deterministic checks", () => { - const project = validProject(); - project.meta.rev = 18; - project.tasks[0].status = "complete"; - project.tasks[0].references = ["hub/verify.js:1-40"]; - project.tasks[0].definition_of_done = ["Payload includes real evidence sources"]; - project.tasks[0].expected_changes = ["hub/verify.js"]; - project.tasks[0].allowed_paths = ["hub/verify.js"]; - - const payload = buildVerifyPayload({ - slug: "test-project", - data: project, - history: [{ rev: 17, delta: { tasks: { t1: { status: "complete" } } }, summary: ["task 1 completed"] }], - taskId: "t1", - references: [{ value: "hub/verify.js:1-40", selectedBecause: "explicit task reference" }], - snippets: [ - { - id: "verify_1_40", - reference: "hub/verify.js:1-40", - path: "hub/verify.js", - startLine: 1, - endLine: 40, - text: "export function buildVerifyPayload() {}", - hash: "sha256:test", - indexedAtRev: 18 - } - ], - now: "2026-04-16T00:00:00.000Z" - }); - - assert.equal(payload.packType, "verify"); - assert.equal(payload.evidenceSources.taskState.selectedBecause, "current task state"); - assert.equal(payload.evidenceSources.references[0].selectedBecause, "explicit task reference"); - assert.ok(payload.checks.some((check) => check.kind === "definition_of_done")); - assert.ok(payload.checks.some((check) => check.kind === "expected_change")); -}); diff --git a/test/versioning.test.js b/test/versioning.test.js index 6c96a32..fd81521 100644 --- a/test/versioning.test.js +++ b/test/versioning.test.js @@ -91,13 +91,16 @@ test("applyPatch bumps rev and writes history", async () => { const file = trackerPath(ws, "test-project"); writeFileSync(file, JSON.stringify(validProject())); store.ingest(file, readFileSync(file, "utf-8")); + // Re-ingest after rev-stamp rewrite + store.ingest(file, readFileSync(file, "utf-8")); - const patch = await store.applyPatch("test-project", { tasks: { t1: { status: "complete" } } }); - assert.equal(patch.ok, true); + await store.applyPatch("test-project", { tasks: { t1: { status: "complete" } } }); + // The hub's file rewrite triggers what would be a chokidar event; simulate + // by calling ingest on the new file state. + store.ingest(file, readFileSync(file, "utf-8")); const entry = store.get("test-project"); assert.ok(entry.rev >= 2); - assert.equal(patch.rev, entry.rev); const onDisk = JSON.parse(readFileSync(file, "utf-8")); assert.equal(onDisk.meta.rev, entry.rev); @@ -121,6 +124,7 @@ test("rollback replays a prior snapshot as a new rev", async () => { const rev1 = store.get("test-project").rev; await store.applyPatch("test-project", { tasks: { t1: { status: "complete" } } }); + store.ingest(file, readFileSync(file, "utf-8")); const rev2 = store.get("test-project").rev; assert.ok(rev2 > rev1); assert.equal( @@ -162,7 +166,7 @@ test("rollback 404s for missing snapshot", async () => { } }); -test("undo restores the previous effective state and redo reapplies the undone state", async () => { +test("getSince returns only events after fromRev", async () => { const ws = setupWorkspace(); try { const store = new Store(ws); @@ -171,62 +175,11 @@ test("undo restores the previous effective state and redo reapplies the undone s store.ingest(file, readFileSync(file, "utf-8")); await store.applyPatch("test-project", { tasks: { t1: { status: "in_progress" } } }); - const rev2 = store.get("test-project").rev; - - await store.applyPatch("test-project", { tasks: { t1: { status: "complete" } } }); - const rev3 = store.get("test-project").rev; - assert.equal(JSON.parse(readFileSync(file, "utf-8")).tasks.find((t) => t.id === "t1").status, "complete"); - - const undo = await store.undo("test-project"); - assert.equal(undo.ok, true); - assert.equal(undo.to, rev2); - assert.equal(undo.newRev, rev3 + 1); - assert.equal(JSON.parse(readFileSync(file, "utf-8")).tasks.find((t) => t.id === "t1").status, "in_progress"); - - const redo = await store.redo("test-project"); - assert.equal(redo.ok, true); - assert.equal(redo.to, rev3); - assert.equal(redo.newRev, undo.newRev + 1); - assert.equal(JSON.parse(readFileSync(file, "utf-8")).tasks.find((t) => t.id === "t1").status, "complete"); - - const revisions = store.revisions("test-project"); - assert.ok(revisions.some((entry) => entry.action === "undo")); - assert.ok(revisions.some((entry) => entry.action === "redo")); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("redo requires the latest history event to be an undo", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(validProject())); store.ingest(file, readFileSync(file, "utf-8")); await store.applyPatch("test-project", { tasks: { t1: { status: "complete" } } }); - - const redo = await store.redo("test-project"); - assert.equal(redo.ok, false); - assert.equal(redo.status, 409); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - -test("getSince returns only events after fromRev", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(validProject())); store.ingest(file, readFileSync(file, "utf-8")); - await store.applyPatch("test-project", { tasks: { t1: { status: "in_progress" } } }); - - await store.applyPatch("test-project", { tasks: { t1: { status: "complete" } } }); - const currentRev = store.get("test-project").rev; const sinceRev1 = store.getSince("test-project", 1); assert.equal(sinceRev1.currentRev, currentRev); @@ -245,6 +198,7 @@ test("revisions lists every recorded rev", async () => { writeFileSync(file, JSON.stringify(validProject())); store.ingest(file, readFileSync(file, "utf-8")); await store.applyPatch("test-project", { tasks: { t1: { status: "complete" } } }); + store.ingest(file, readFileSync(file, "utf-8")); const revs = store.revisions("test-project"); assert.ok(revs.length >= 2); @@ -257,26 +211,6 @@ test("revisions lists every recorded rev", async () => { } }); -test("history returns recent events with truncation metadata", async () => { - const ws = setupWorkspace(); - try { - const store = new Store(ws); - const file = trackerPath(ws, "test-project"); - writeFileSync(file, JSON.stringify(validProject())); - store.ingest(file, readFileSync(file, "utf-8")); - - await store.applyPatch("test-project", { tasks: { t1: { status: "in_progress" } } }); - await store.applyPatch("test-project", { tasks: { t1: { status: "complete" } } }); - - const history = store.history("test-project", { limit: 2 }); - assert.equal(history.events.length, 2); - assert.equal(history.truncation.returned, 2); - assert.ok(history.truncation.totalAvailable >= 2); - } finally { - rmSync(ws, { recursive: true, force: true }); - } -}); - test("ingest cold-start resume: matching snapshot does not bump rev", () => { const ws = setupWorkspace(); try { diff --git a/test/watcher-scope.test.js b/test/watcher-scope.test.js deleted file mode 100644 index 1a0a08a..0000000 --- a/test/watcher-scope.test.js +++ /dev/null @@ -1,93 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-wscope-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# watcher scope\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 15000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - server.close(() => resolve(port)); - }); - }); -} - -async function waitForUpdate(url, matcher, { attempts = 30, interval = 250 } = {}) { - for (let i = 0; i < attempts; i++) { - const res = await fetch(url); - if (res.ok) { - const body = await res.json(); - if (matcher(body)) return body; - } - await new Promise((r) => setTimeout(r, interval)); - } - throw new Error(`timeout waiting for ${url}`); -} - -test("symlink target edits propagate through the dedicated polling watcher", async () => { - const workspace = setupWorkspace(); - const repo = mkdtempSync(join(tmpdir(), "llm-tracker-repo-")); - const port = await findFreePort(); - const targetPath = join(repo, "linked.json"); - writeFileSync(targetPath, JSON.stringify({ ...validProject(), meta: { ...validProject().meta, slug: "linked" } }, null, 2)); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - const link = runCli(["link", "linked", targetPath, "--path", workspace, "--port", String(port)]); - assert.equal(link.status, 0, link.stderr || link.stdout); - - const initial = await fetch(`http://127.0.0.1:${port}/api/projects/linked`); - assert.equal(initial.status, 200); - const initialBody = await initial.json(); - assert.equal(initialBody.file, realpathSync(targetPath)); - - // Edit the symlink target directly (not the workspace symlink) — the - // polling watcher for the linked target must pick this up. - const body = JSON.parse(JSON.stringify({ ...validProject(), meta: { ...validProject().meta, slug: "linked", scratchpad: "edited-via-target" } })); - writeFileSync(targetPath, JSON.stringify(body, null, 2)); - - const updated = await waitForUpdate( - `http://127.0.0.1:${port}/api/projects/linked`, - (payload) => payload?.data?.meta?.scratchpad === "edited-via-target" - ); - assert.equal(updated.data.meta.scratchpad, "edited-via-target"); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - rmSync(repo, { recursive: true, force: true }); - } -}); diff --git a/test/why-decisions-cli.test.js b/test/why-decisions-cli.test.js deleted file mode 100644 index d247789..0000000 --- a/test/why-decisions-cli.test.js +++ /dev/null @@ -1,101 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { validProject } from "./fixtures.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const BIN = join(__dirname, "..", "bin", "llm-tracker.js"); - -function setupWorkspace() { - const ws = mkdtempSync(join(tmpdir(), "llm-tracker-why-cli-")); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(ws, sub), { recursive: true }); - } - writeFileSync(join(ws, "README.md"), "# test workspace\n"); - return ws; -} - -function runCli(args, options = {}) { - return spawnSync(process.execPath, [BIN, ...args], { - encoding: "utf-8", - timeout: 10000, - ...options - }); -} - -function stopDaemon(workspace) { - runCli(["daemon", "stop", "--path", workspace]); -} - -function findFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "::", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : null; - server.close((closeErr) => { - if (closeErr) reject(closeErr); - else resolve(port); - }); - }); - }); -} - -async function waitForProject(port, slug) { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - try { - const res = await fetch(`http://localhost:${port}/api/projects/${slug}`); - if (res.status === 200) return; - } catch {} - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`timed out waiting for project ${slug}`); -} - -test("llm-tracker why and decisions render deterministic retrieval packs", async () => { - const workspace = setupWorkspace(); - const port = await findFreePort(); - const project = validProject(); - project.meta.rev = 4; - project.tasks[0].comment = "Needed before task 2 can finish"; - project.tasks[0].reference = "hub/store.js:1-20"; - project.tasks[1].comment = "Wait for task 1 to clear"; - writeFileSync(join(workspace, "trackers", "test-project.json"), JSON.stringify(project, null, 2)); - writeFileSync( - join(workspace, ".history", "test-project.jsonl"), - JSON.stringify({ - rev: 3, - ts: "2026-04-15T00:01:00.000Z", - delta: { tasks: { t1: { comment: "Needed before task 2 can finish" } } }, - summary: ["task 1 note updated"] - }) + "\n" - ); - - try { - const started = runCli(["--path", workspace, "--port", String(port), "--daemon"]); - assert.equal(started.status, 0, started.stderr || started.stdout); - - await waitForProject(port, "test-project"); - - const why = runCli(["why", "test-project", "t1", "--path", workspace]); - assert.equal(why.status, 0, why.stderr || why.stdout); - assert.match(why.stdout, /WHY/); - assert.match(why.stdout, /Unblocks t2/); - - const decisions = runCli(["decisions", "test-project", "--path", workspace]); - assert.equal(decisions.status, 0, decisions.stderr || decisions.stdout); - assert.match(decisions.stdout, /decisions 2/); - assert.match(decisions.stdout, /Needed before task 2 can finish/); - } finally { - stopDaemon(workspace); - rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/test/why.test.js b/test/why.test.js deleted file mode 100644 index 0800bd0..0000000 --- a/test/why.test.js +++ /dev/null @@ -1,44 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { buildWhyPayload } from "../hub/why.js"; -import { validProject } from "./fixtures.js"; - -test("buildWhyPayload explains why a task matters, what it unblocks, and recent history", () => { - const project = validProject(); - project.meta.rev = 9; - project.tasks[0].goal = "Ship the first live task pack"; - project.tasks[0].comment = "Needed before downstream automation can run"; - project.tasks[0].reference = "hub/store.js:1-20"; - project.tasks.push({ - id: "t4", - title: "Task 4", - status: "not_started", - placement: { swimlaneId: "ops", priorityId: "p1" }, - dependencies: ["t1"] - }); - - const history = [ - { rev: 5, ts: "2026-04-15T00:00:00.000Z", delta: { tasks: { t1: { comment: "first note" } } }, summary: ["task 1 note"] }, - { rev: 6, ts: "2026-04-15T00:01:00.000Z", delta: { tasks: { t1: { status: "in_progress" } } }, summary: ["task 1 started"] }, - { rev: 7, ts: "2026-04-15T00:02:00.000Z", delta: { tasks: { t1: { status: "not_started" } } }, summary: ["task 1 reset"] }, - { rev: 8, ts: "2026-04-15T00:03:00.000Z", delta: { tasks: { t1: { comment: "Needed before downstream automation can run" } } }, summary: ["task 1 note updated"] } - ]; - - const payload = buildWhyPayload({ - slug: "test-project", - data: project, - history, - taskId: "t1", - now: "2026-04-15T12:00:00.000Z" - }); - - assert.equal(payload.packType, "why"); - assert.equal(payload.task.id, "t1"); - assert.ok(payload.why.some((reason) => reason.kind === "decision_note")); - assert.ok(payload.why.some((reason) => reason.kind === "unblocks" && reason.text.includes("t2"))); - assert.ok(payload.why.some((reason) => reason.kind === "unblocks" && reason.text.includes("t4"))); - assert.equal(payload.references[0].selectedBecause, "explicit task reference"); - assert.equal(payload.unblocks.length, 2); - assert.equal(payload.recentHistory.length, 3); - assert.equal(payload.truncation.history.applied, true); -}); diff --git a/ui/app.js b/ui/app.js index 7487fdb..19943c3 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1,10 +1,6 @@ import { render } from "preact"; import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { html } from "htm/preact"; -import { buildCardMetaFacts } from "./lib/intelligence.js"; -import { HistoryModal } from "./modals/history.js"; -import { ProjectIntelligenceModal, TaskIntelligenceModal } from "./modals/intelligence.js"; -import { humanizeTaskOutcome } from "./task-outcomes.js"; const STATUS_ORDER = ["complete", "in_progress", "not_started", "deferred"]; @@ -178,56 +174,6 @@ function IconBtn({ label, onClick, active, title }) { `; } -async function copyText(text) { - if (!text) return false; - if (navigator.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(text); - return true; - } catch {} - } - - try { - const input = document.createElement("textarea"); - input.value = text; - input.setAttribute("readonly", ""); - input.style.position = "absolute"; - input.style.left = "-9999px"; - document.body.appendChild(input); - input.select(); - const ok = document.execCommand("copy"); - document.body.removeChild(input); - return !!ok; - } catch { - return false; - } -} - -function CopyInlineBtn({ value, label, title }) { - const [copied, setCopied] = useState(false); - - useEffect(() => { - if (!copied) return undefined; - const timer = setTimeout(() => setCopied(false), 1200); - return () => clearTimeout(timer); - }, [copied]); - - return html` - - `; -} - // ─────── Custom dropdown ─────── function Dropdown({ value, options, onChange, renderLabel, className }) { const [open, setOpen] = useState(false); @@ -326,29 +272,13 @@ function FilterToggles({ counts, statusFilters, toggleStatus, blockedCount, open // ─────── Card / Cell / Matrix ─────── -function Card({ task, blockedBy, dragging, searchMatch, fuzzyActive, onDragStart, onDragEnd, onDelete, onSaveComment, onOpenTask }) { +function Card({ task, blockedBy, dragging, onDragStart, onDragEnd, onDelete, onSaveComment }) { const ctx = task.context || {}; const tags = Array.isArray(ctx.tags) ? ctx.tags : []; - const cardFacts = buildCardMetaFacts(task, blockedBy || []); - const outcome = humanizeTaskOutcome(task.outcome); - const approvals = Array.isArray(task.approval_required_for) - ? task.approval_required_for.filter((value) => typeof value === "string" && value.trim()) - : []; - const references = Array.isArray(task.references) && task.references.length - ? task.references - : task.reference - ? [task.reference] - : []; const extraKeys = Object.keys(ctx).filter( (k) => k !== "tags" && k !== "notes" && k !== "files_touched" ); - const classes = [ - "card", - `status-${task.status}`, - dragging ? "dragging" : "", - searchMatch ? "fuzzy-hit" : "", - fuzzyActive && !searchMatch ? "fuzzy-dim" : "" - ].join(" "); + const classes = ["card", `status-${task.status}`, dragging ? "dragging" : ""].join(" "); return html`
-
-
${task.id}
- <${CopyInlineBtn} - value=${task.id} - label="Task id" - title=${`Copy task id ${task.id}`} - /> -
-
- ${task.title} - <${CopyInlineBtn} - value=${task.title} - label="Task title" - title=${`Copy task title: ${task.title}`} - /> -
+
${task.id}
+
${task.title}
${task.goal ? html`
${task.goal}
` : null} ${ctx.notes ? html`
${ctx.notes}
` : null} ${extraKeys.length > 0 ? html`
${extraKeys[0]}: ${String(ctx[extraKeys[0]])}
` : null} -
- ${[ - ["brief", "[READ]"], - ["why", "[WHY]"], - ["execute", "[EXEC]"], - ["verify", "[VERIFY]"] - ].map(([mode, label]) => html` - - `)} -
- ${cardFacts.length > 0 - ? html` -
- ${cardFacts.map((fact) => html` - - ${fact.label} - ${fact.value} - - `)} -
- ` - : null} <${CommentBadge} comment=${task.comment} onSave=${(value) => onSaveComment && onSaveComment(task.id, value)} /> @@ -463,7 +335,7 @@ function Card({ task, blockedBy, dragging, searchMatch, fuzzyActive, onDragStart `; } -function Cell({ laneId, priorityId, tasks, blocked, filterQuery, statusFilters, blockFilters, fuzzyQuery, fuzzyMatchMap, dragState, setDragState, onDrop, onDeleteTask, onSaveComment, onOpenTask }) { +function Cell({ laneId, priorityId, tasks, blocked, filterQuery, statusFilters, blockFilters, dragState, setDragState, onDrop, onDeleteTask, onSaveComment }) { const ref = useRef(null); const [over, setOver] = useState(false); @@ -533,8 +405,6 @@ function Cell({ laneId, priorityId, tasks, blocked, filterQuery, statusFilters, key=${t.id} task=${t} blockedBy=${blocked[t.id]} - searchMatch=${fuzzyMatchMap?.get(t.id) || null} - fuzzyActive=${!!(fuzzyQuery && fuzzyQuery.trim())} dragging=${dragState.taskId === t.id} onDragStart=${(e, task) => { e.dataTransfer.effectAllowed = "move"; @@ -544,7 +414,6 @@ function Cell({ laneId, priorityId, tasks, blocked, filterQuery, statusFilters, onDragEnd=${() => setDragState({ taskId: null })} onDelete=${onDeleteTask} onSaveComment=${onSaveComment} - onOpenTask=${onOpenTask} /> ` )} @@ -552,7 +421,7 @@ function Cell({ laneId, priorityId, tasks, blocked, filterQuery, statusFilters, `; } -function Matrix({ project, filterQuery, statusFilters, blockFilters, fuzzyQuery, fuzzyMatchMap, onMove, onToggleCollapse, onMoveLane, onDeleteTask, onSaveComment, onOpenTask }) { +function Matrix({ project, filterQuery, statusFilters, blockFilters, onMove, onToggleCollapse, onDeleteTask, onSaveComment }) { const [dragState, setDragState] = useState({ taskId: null }); const swimlanes = project.data.meta.swimlanes; const priorities = project.data.meta.priorities; @@ -583,9 +452,6 @@ function Matrix({ project, filterQuery, statusFilters, blockFilters, fuzzyQuery, `; const rows = swimlanes.map((lane) => { - const laneIndex = swimlanes.findIndex((item) => item.id === lane.id); - const canMoveUp = laneIndex > 0; - const canMoveDown = laneIndex >= 0 && laneIndex < swimlanes.length - 1; const per = project.derived?.perSwimlane?.[lane.id] || { counts: {}, pct: 0, total: 0 }; const active = per.counts.in_progress || 0; const allComplete = per.total > 0 && per.counts.complete === per.total; @@ -609,14 +475,7 @@ function Matrix({ project, filterQuery, statusFilters, blockFilters, fuzzyQuery, title="Click to expand swimlane" > ${"\u25B6"} -
- ${lane.label} - <${CopyInlineBtn} - value=${lane.label} - label="Lane name" - title=${`Copy lane name: ${lane.label}`} - /> -
+
${lane.label}
${lane.description ? html`
${lane.description}
` : null}
${per.total} tasks @@ -634,34 +493,10 @@ function Matrix({ project, filterQuery, statusFilters, blockFilters, fuzzyQuery,
-
- ${lane.label} - <${CopyInlineBtn} - value=${lane.label} - label="Lane name" - title=${`Copy lane name: ${lane.label}`} - /> -
-
-
- - +
${lane.label}
${lane.description ? html`
${lane.description}
` : null}
@@ -681,14 +516,11 @@ function Matrix({ project, filterQuery, statusFilters, blockFilters, fuzzyQuery, filterQuery=${filterQuery} statusFilters=${statusFilters} blockFilters=${blockFilters} - fuzzyQuery=${fuzzyQuery} - fuzzyMatchMap=${fuzzyMatchMap} dragState=${dragState} setDragState=${setDragState} onDrop=${onMove} onDeleteTask=${onDeleteTask} onSaveComment=${onSaveComment} - onOpenTask=${onOpenTask} /> ` )} @@ -797,16 +629,12 @@ function ProjectPane({ onFocus, onTogglePin, filter, - searchMode, - fuzzyMatchMap, statusFilters, blockFilters, onMove, onToggleCollapse, - onMoveLane, onDeleteTask, onSaveComment, - onOpenTask, scratchpadExpanded, onToggleScratchpad, onSaveScratchpad @@ -856,17 +684,13 @@ function ProjectPane({ ${data ? html`<${Matrix} project=${project} - filterQuery=${searchMode === "filter" ? filter : ""} + filterQuery=${filter} statusFilters=${statusFilters} blockFilters=${blockFilters} - fuzzyQuery=${searchMode === "fuzzy" ? filter : ""} - fuzzyMatchMap=${fuzzyMatchMap} onMove=${(args) => onMove(slug, args)} onToggleCollapse=${(laneId, collapsed) => onToggleCollapse(slug, laneId, collapsed)} - onMoveLane=${(laneId, direction) => onMoveLane(slug, laneId, direction)} onDeleteTask=${(task) => onDeleteTask(slug, task)} onSaveComment=${(taskId, value) => onSaveComment(slug, taskId, value)} - onOpenTask=${(task, mode) => onOpenTask && onOpenTask(slug, task, mode)} />` : html`

Project file is not yet valid. Fix it and save.

`} @@ -880,7 +704,7 @@ function HelpModal({ workspace, onClose }) { const wsPath = workspace?.workspace || "~/.llm-tracker"; const promptFile = - "Read http://localhost:" + port + "/help (or " + readme + " on disk) and register this project as .\n\n" + + "Read " + readme + " and register this project as .\n\n" + "WORKSPACE IS AT: " + wsPath + "\n" + "Every path you read or write MUST begin with that absolute path. " + "Do NOT create a new .llm-tracker/ folder anywhere else. " + @@ -895,7 +719,7 @@ function HelpModal({ workspace, onClose }) { "Read the tracker only at decision points (claiming next task, resolving a blocker). Writes are fire-and-forget."; const promptHttp = - "Read http://localhost:" + port + "/help for the live contract, then use HTTP-only (no file paths, no fs writes).\n\n" + + "Read " + readme + " for the contract, then use HTTP-only (no file paths, no fs writes).\n\n" + "IMPORTANT — ALWAYS use --data-binary @file.json, never inline -d '...'.\n" + "Inline curl -d strips newlines and corrupts JSON bodies that contain quotes, " + "newlines, or special characters. Write the body to a file first, then send it with " + @@ -920,9 +744,6 @@ function HelpModal({ workspace, onClose }) { const [mode, setMode] = useState("file"); const [copied, setCopied] = useState(false); - const [liveHelp, setLiveHelp] = useState(""); - const [helpError, setHelpError] = useState(null); - const [helpCopied, setHelpCopied] = useState(false); const activePrompt = mode === "file" ? promptFile : promptHttp; const copy = async () => { @@ -933,15 +754,6 @@ function HelpModal({ workspace, onClose }) { } catch {} }; - const copyHelp = async () => { - if (!liveHelp) return; - try { - await navigator.clipboard.writeText(liveHelp); - setHelpCopied(true); - setTimeout(() => setHelpCopied(false), 1800); - } catch {} - }; - useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); @@ -950,25 +762,12 @@ function HelpModal({ workspace, onClose }) { return () => document.removeEventListener("keydown", onKey); }, [onClose]); - useEffect(() => { - fetch("/help") - .then((response) => { - if (!response.ok) throw new Error(response.statusText); - return response.text(); - }) - .then((text) => { - setLiveHelp(text); - setHelpError(null); - }) - .catch((error) => setHelpError(error.message)); - }, []); - return html`