From c742ce265d18475a8a9df36f0bf2509418c3ed87 Mon Sep 17 00:00:00 2001 From: Adi Levinshtein Date: Fri, 17 Apr 2026 17:19:54 -0700 Subject: [PATCH 1/2] feat: add runtime overlays and MCP patch tools --- .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 | 1543 ++++++++++++++++++++- 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 0 -> 6144 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, 17660 insertions(+), 550 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 AGENTS.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 MIGRATING.md create mode 100644 NOTICE create mode 100644 OPEN_SOURCE_READINESS.md create mode 100644 SECURITY.md create mode 100644 bin/commands/blockers.js create mode 100644 bin/commands/brief.js create mode 100644 bin/commands/changed.js create mode 100644 bin/commands/decisions.js create mode 100644 bin/commands/execute.js create mode 100644 bin/commands/fuzzy.js create mode 100644 bin/commands/next.js create mode 100644 bin/commands/pick.js create mode 100644 bin/commands/reload.js create mode 100644 bin/commands/search.js create mode 100644 bin/commands/shared.js create mode 100644 bin/commands/verify.js create mode 100644 bin/commands/why.js mode change 100644 => 100755 bin/llm-tracker.js create mode 100644 bin/mcp-context-data.js create mode 100644 bin/mcp-context.js create mode 100644 bin/mcp-prompts.js create mode 100644 bin/mcp-read-tools.js create mode 100644 bin/mcp-resources.js create mode 100644 bin/mcp-server.js create mode 100644 bin/mcp-tools.js create mode 100644 bin/mcp-utils.js create mode 100644 bin/mcp-write-tools.js create mode 100644 bin/workspace-client.js create mode 100644 hub/blockers.js create mode 100644 hub/briefs.js create mode 100644 hub/changed.js create mode 100644 hub/decisions.js create mode 100644 hub/error-payload.js create mode 100644 hub/execute.js create mode 100644 hub/next.js create mode 100644 hub/pick.js create mode 100644 hub/project-loader.js create mode 100644 hub/references.js create mode 100644 hub/routes/intelligence.js create mode 100644 hub/runtime-overlay.js create mode 100644 hub/runtime.js create mode 100644 hub/search.js create mode 100644 hub/snippets.js create mode 100644 hub/status-vocabulary.js create mode 100644 hub/task-metadata.js create mode 100644 hub/verify.js create mode 100644 hub/why.js create mode 100644 test/blockers.test.js create mode 100644 test/brief-cli.test.js create mode 100644 test/briefs.test.js create mode 100644 test/changed.test.js create mode 100644 test/daemon.test.js create mode 100644 test/decisions.test.js create mode 100644 test/error-payload.test.js create mode 100644 test/execute-cli.test.js create mode 100644 test/execute.test.js create mode 100644 test/field-limits.test.js create mode 100644 test/healthz.test.js create mode 100644 test/helpers/fake-embedder.mjs create mode 100644 test/ingest-lock.test.js create mode 100644 test/mcp-tools.test.js create mode 100644 test/mcp.test.js create mode 100644 test/next-cli.test.js create mode 100644 test/next.test.js create mode 100644 test/pick-cli.test.js create mode 100644 test/pick.test.js create mode 100644 test/references.test.js create mode 100644 test/reload-cli.test.js create mode 100644 test/restore.test.js create mode 100644 test/runtime-overlay.test.js create mode 100644 test/search-cli.test.js create mode 100644 test/search.test.js create mode 100644 test/security.test.js create mode 100644 test/shortcuts-cli.test.js create mode 100644 test/snippets.test.js create mode 100644 test/swimlane-move.test.js create mode 100644 test/task-intel-cli.test.js create mode 100644 test/tombstones.test.js create mode 100644 test/ui-intelligence.test.js create mode 100644 test/verify-cli.test.js create mode 100644 test/verify.test.js create mode 100644 test/watcher-scope.test.js create mode 100644 test/why-decisions-cli.test.js create mode 100644 test/why.test.js create mode 100644 ui/favicon.png create mode 100644 ui/lib/intelligence.js create mode 100644 ui/modals/history.js create mode 100644 ui/modals/intelligence.js create mode 100644 ui/task-outcomes.js diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3cad999 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +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 new file mode 100644 index 0000000..6463b57 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +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 new file mode 100644 index 0000000..f18c6dd --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ +## 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 65d9830..0779534 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,5 @@ 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 e52ae94..fbe8504 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ coverage/ # Project-local scratch initial prompt.txt +.llm-tracker/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e54b072 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# 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 fc57ccb..878dca0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,17 +7,52 @@ Deep-dive on the internals. For the marketing hook and installation, see [README ## Pieces ``` -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 +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 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. ``` @@ -33,15 +68,17 @@ 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: {rev, ts, delta, summary} +└── .history/.jsonl # append-only event log: change/delete/restore/undo/redo/rollback events ``` --- ## Schema -Every tracker JSON has two top-level keys: `meta` and `tasks`. +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. ### meta @@ -54,6 +91,7 @@ Every tracker JSON has two top-level keys: `meta` and `tasks`. | `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:** @@ -77,13 +115,23 @@ 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 | | Source location as `path/to/file.ext:line` or `…:line-line`. | +| `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. | | `comment` | string \| null (≤ 500 chars) | | One free-form note per task. Rendered as a `[C]` badge with a hover popover. | -| `blocker_reason` | string \| null | | One sentence when the LLM is stuck. | +| `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. | | `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`. @@ -106,8 +154,12 @@ 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) @@ -123,13 +175,118 @@ 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 `{rev, ts, delta, summary}` to `.history/.jsonl`. +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`. The **delta** is a structured field-level diff: @@ -204,6 +361,8 @@ 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 @@ -232,6 +391,8 @@ 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 @@ -241,10 +402,26 @@ curl -X POST http://localhost:/api/projects//patch \ | `/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. | +| `/api/projects/:slug` | GET | Full project state plus effective tracker `file` path. | | `/api/projects/:slug` | PUT | Create or replace a project (full body). | -| `/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` | 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/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. | @@ -252,11 +429,24 @@ curl -X POST http://localhost:/api/projects//patch \ | `/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?}`. Body limit: 16 MB. +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`. --- @@ -268,6 +458,7 @@ All errors return JSON: `{error, type?, hint?}`. Body limit: 16 MB. | 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 | @@ -280,7 +471,38 @@ First match wins: 1. `--port N` flag 2. `LLM_TRACKER_PORT` env 3. `/settings.json` → `port` -4. Default `4400` +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. --- @@ -300,13 +522,38 @@ 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. -### Polling-based file watching +### 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. -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. +Creating a second accidental workspace is the wrong fix because it forks the source of truth. ### Body parsing & error format -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. +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. ### Cold-start resume @@ -315,6 +562,7 @@ 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 new file mode 100644 index 0000000..1846581 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,11 @@ +# 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 new file mode 100644 index 0000000..0489e53 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,81 @@ +# 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 4de51ca..0054f31 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,159 @@ -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. +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 diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..fc2436f --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,393 @@ +# 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 new file mode 100644 index 0000000..1279f53 --- /dev/null +++ b/NOTICE @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..c2d4698 --- /dev/null +++ b/OPEN_SOURCE_READINESS.md @@ -0,0 +1,195 @@ +# 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 9b6b896..0bf4a6c 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,66 @@ [![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: MIT](https://img.shields.io/badge/License-MIT-amber.svg)](LICENSE) +[![License: Apache%202.0](https://img.shields.io/badge/License-Apache%202.0-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 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. +🔒 **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. 💸 **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 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. +⚡ **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. --- @@ -47,6 +94,7 @@ It's a file-system-as-database tracker. Your LLMs update project states with tin ```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. @@ -58,12 +106,72 @@ 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 @@ -90,24 +198,224 @@ 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) @@ -117,15 +425,70 @@ Full flag reference: `npx llm-tracker help`. - **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 @@ -148,8 +511,9 @@ Full schema, merge semantics, field ownership, versioning, rollback, and the who ```bash npm install -npm test # 55 tests (Node's built-in test runner) +npm test # 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)**. @@ -183,6 +547,60 @@ 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 -MIT — see [LICENSE](LICENSE). +Apache-2.0 — see [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..675f585 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,68 @@ +# 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 new file mode 100644 index 0000000..ed63a1f --- /dev/null +++ b/bin/commands/blockers.js @@ -0,0 +1,73 @@ +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 new file mode 100644 index 0000000..cfcbd36 --- /dev/null +++ b/bin/commands/brief.js @@ -0,0 +1,137 @@ +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 new file mode 100644 index 0000000..d46c8b2 --- /dev/null +++ b/bin/commands/changed.js @@ -0,0 +1,55 @@ +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 new file mode 100644 index 0000000..35dfa63 --- /dev/null +++ b/bin/commands/decisions.js @@ -0,0 +1,53 @@ +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 new file mode 100644 index 0000000..f022318 --- /dev/null +++ b/bin/commands/execute.js @@ -0,0 +1,91 @@ +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 new file mode 100644 index 0000000..778f488 --- /dev/null +++ b/bin/commands/fuzzy.js @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..900c49d --- /dev/null +++ b/bin/commands/next.js @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..daf94f6 --- /dev/null +++ b/bin/commands/pick.js @@ -0,0 +1,55 @@ +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 new file mode 100644 index 0000000..46e2c60 --- /dev/null +++ b/bin/commands/reload.js @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..9108f09 --- /dev/null +++ b/bin/commands/search.js @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..91a5ff0 --- /dev/null +++ b/bin/commands/shared.js @@ -0,0 +1,92 @@ +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 new file mode 100644 index 0000000..c4885ae --- /dev/null +++ b/bin/commands/verify.js @@ -0,0 +1,68 @@ +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 new file mode 100644 index 0000000..b93e588 --- /dev/null +++ b/bin/commands/why.js @@ -0,0 +1,98 @@ +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 100644 new mode 100755 index 0c4bc87..056c9c4 --- a/bin/llm-tracker.js +++ b/bin/llm-tracker.js @@ -1,32 +1,43 @@ #!/usr/bin/env node +import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; -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 {}; - } -} +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"; -function validPort(n) { - const p = parseInt(n, 10); - return !isNaN(p) && p >= 1 && p <= 65535 ? p : null; +function validLineCount(n) { + const count = parseInt(n, 10); + return !isNaN(count) && count >= 1 && count <= 5000 ? count : null; } const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PKG_ROOT = resolve(__dirname, ".."); -const DEFAULT_WORKSPACE = join(homedir(), ".llm-tracker"); -const DEFAULT_PORT = 4400; +const DAEMON_READY_TIMEOUT_MS = 5000; +const DAEMON_STOP_TIMEOUT_MS = 5000; +const DAEMON_FORCE_STOP_TIMEOUT_MS = 2000; function parseArgs(argv) { const args = { _: [], flags: {} }; @@ -42,11 +53,6 @@ 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 })) { @@ -61,6 +67,55 @@ 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"); @@ -69,9 +124,7 @@ function cmdInit(args) { console.log(`Workspace already exists at ${workspace}`); } else { copyTree(template, workspace); - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(workspace, sub), { recursive: true }); - } + ensureWorkspaceLayout(workspace); console.log(`Initialized workspace at ${workspace}`); } @@ -93,37 +146,12 @@ 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 the hub: npx llm-tracker"); + console.log(" Start in background: npx llm-tracker --daemon"); 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]; @@ -133,9 +161,13 @@ async function cmdLink(args) { process.exit(1); } const abs = resolve(target); - const { status, body } = await httpRequest(workspace, "POST", `/api/projects/${slug}/symlink`, { - target: abs - }); + const { status, body } = await httpRequest( + workspace, + args.flags.port, + "POST", + `/api/projects/${slug}/symlink`, + { target: abs } + ); if (status === 0) { console.error("Hub not reachable. Start it with 'llm-tracker'."); process.exit(1); @@ -147,6 +179,46 @@ 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) { @@ -157,9 +229,13 @@ async function cmdRollback(args) { console.error("Usage: llm-tracker rollback "); process.exit(1); } - const { status, body } = await httpRequest(workspace, "POST", `/api/projects/${slug}/rollback`, { - to: rev - }); + const { status, body } = await httpRequest( + workspace, + args.flags.port, + "POST", + `/api/projects/${slug}/rollback`, + { to: rev } + ); if (status === 0) { console.error("Hub not reachable. Start it with 'llm-tracker'."); process.exit(1); @@ -179,7 +255,12 @@ async function cmdSince(args) { console.error("Usage: llm-tracker since []"); process.exit(1); } - const { status, body } = await httpRequest(workspace, "GET", `/api/projects/${slug}/since/${rev}`); + const { status, body } = await httpRequest( + workspace, + args.flags.port, + "GET", + `/api/projects/${slug}/since/${rev}` + ); if (status === 0) { console.error("Hub not reachable. Start it with 'llm-tracker'."); process.exit(1); @@ -209,12 +290,13 @@ async function cmdSince(args) { } } -function cmdStatus(args) { +async 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}.`); @@ -241,53 +323,312 @@ function cmdStatus(args) { } } -async function cmdRun(args) { +async function cmdRun(args, { daemonized = false } = {}) { const workspace = resolveWorkspace(args.flags.path); - const wsSettings = loadWorkspaceSettings(workspace); - const port = - validPort(args.flags.port) || - validPort(process.env.LLM_TRACKER_PORT) || - validPort(wsSettings.port) || - DEFAULT_PORT; + const port = resolvePort(workspace, args.flags.port); - if (!existsSync(workspace)) { - console.error(`No workspace at ${workspace}. Run 'npx llm-tracker init' first.`); + 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 (!existsSync(join(workspace, "README.md"))) { - console.error(`Workspace at ${workspace} is missing README.md. Run 'npx llm-tracker init' to repair.`); + 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) { + const workspace = resolveWorkspace(args.flags.path); + const status = getDaemonStatus(workspace); + + 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}`); process.exit(1); } - for (const sub of ["trackers", "patches", ".snapshots", ".history"]) { - mkdirSync(join(workspace, sub), { recursive: true }); + 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; } - await startHub({ workspace, port, uiDir: join(PKG_ROOT, "ui") }); + 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}.`); + 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); + } + 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)); } 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 (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 + 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 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 @@ -297,6 +638,11 @@ 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 new file mode 100644 index 0000000..9992665 --- /dev/null +++ b/bin/mcp-context-data.js @@ -0,0 +1,190 @@ +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 new file mode 100644 index 0000000..7e59843 --- /dev/null +++ b/bin/mcp-context.js @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..bb4504c --- /dev/null +++ b/bin/mcp-prompts.js @@ -0,0 +1,184 @@ +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 new file mode 100644 index 0000000..9b5074e --- /dev/null +++ b/bin/mcp-read-tools.js @@ -0,0 +1,297 @@ +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 new file mode 100644 index 0000000..17de5ee --- /dev/null +++ b/bin/mcp-resources.js @@ -0,0 +1,104 @@ +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 new file mode 100644 index 0000000..9828563 --- /dev/null +++ b/bin/mcp-server.js @@ -0,0 +1,117 @@ +#!/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 new file mode 100644 index 0000000..f0ea194 --- /dev/null +++ b/bin/mcp-tools.js @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..74169b8 --- /dev/null +++ b/bin/mcp-utils.js @@ -0,0 +1,80 @@ +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 new file mode 100644 index 0000000..8aae170 --- /dev/null +++ b/bin/mcp-write-tools.js @@ -0,0 +1,163 @@ +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 new file mode 100644 index 0000000..f1c0479 --- /dev/null +++ b/bin/workspace-client.js @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000..0d9014d --- /dev/null +++ b/hub/blockers.js @@ -0,0 +1,86 @@ +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 new file mode 100644 index 0000000..4b2b6ec --- /dev/null +++ b/hub/briefs.js @@ -0,0 +1,232 @@ +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 new file mode 100644 index 0000000..8c9a720 --- /dev/null +++ b/hub/changed.js @@ -0,0 +1,150 @@ +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 new file mode 100644 index 0000000..1024ddc --- /dev/null +++ b/hub/decisions.js @@ -0,0 +1,96 @@ +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 new file mode 100644 index 0000000..13b1387 --- /dev/null +++ b/hub/error-payload.js @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..5aeaacc --- /dev/null +++ b/hub/execute.js @@ -0,0 +1,126 @@ +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 e72fc05..2d09887 100644 --- a/hub/merge.js +++ b/hub/merge.js @@ -11,10 +11,17 @@ 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") { + if (k === "updatedAt" || k === "rev" || k === "deleted_tasks") { notes.ignored.push(`meta.${k} is hub-owned`); continue; } @@ -22,25 +29,47 @@ export function mergeProject(existing, incoming) { merged.meta[k] = v; } if (incoming.meta.swimlanes) { - 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 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 out = { ...lane }; - if (p && "collapsed" in p) { - if ("collapsed" in lane && lane.collapsed !== p.collapsed) { + if ("collapsed" in prevLane) { + if ("collapsed" in lane && lane.collapsed !== prevLane.collapsed) { notes.ignored.push( `meta.swimlanes[${lane.id}].collapsed is human-owned (kept existing)` ); } - out.collapsed = p.collapsed; + out.collapsed = prevLane.collapsed; } else if ("collapsed" in lane) { notes.ignored.push( `meta.swimlanes[${lane.id}].collapsed is human-owned (dropped)` ); delete out.collapsed; } - return out; - }); + 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; } } @@ -70,11 +99,16 @@ export function mergeProject(existing, incoming) { } } for (const inc of incomingArr) { - if (!existingIds.has(inc.id)) { - const defaulted = { dependencies: [], ...inc }; - next.push(defaulted); - notes.appended.push(inc.id); + 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; } + 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 new file mode 100644 index 0000000..f626661 --- /dev/null +++ b/hub/next.js @@ -0,0 +1,106 @@ +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 new file mode 100644 index 0000000..f203c9e --- /dev/null +++ b/hub/pick.js @@ -0,0 +1,125 @@ +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 eaee469..3a24b8b 100644 --- a/hub/progress.js +++ b/hub/progress.js @@ -1,4 +1,4 @@ -import { STATUS_VALUES } from "./validator.js"; +import { STATUS_VALUES } from "./status-vocabulary.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 new file mode 100644 index 0000000..d120b9c --- /dev/null +++ b/hub/project-loader.js @@ -0,0 +1,87 @@ +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 new file mode 100644 index 0000000..6cd84d7 --- /dev/null +++ b/hub/references.js @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000..edde087 --- /dev/null +++ b/hub/routes/intelligence.js @@ -0,0 +1,188 @@ +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 new file mode 100644 index 0000000..338cb95 --- /dev/null +++ b/hub/runtime-overlay.js @@ -0,0 +1,180 @@ +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 new file mode 100644 index 0000000..080dc5e --- /dev/null +++ b/hub/runtime.js @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..b2bf715 --- /dev/null +++ b/hub/search.js @@ -0,0 +1,772 @@ +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 8eb3419..f34a3f3 100644 --- a/hub/server.js +++ b/hub/server.js @@ -1,30 +1,144 @@ import { createServer } from "node:http"; -import { readFileSync, existsSync, writeFileSync, renameSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +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"; -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 }; + if (!entry) return { slug, data: null, derived: null, error: null, file: null }; + let file = entry.path || null; + if (file) { + try { + file = realpathSync(file); + } catch {} + } return { slug, data: entry.data, derived: entry.derived, rev: entry.rev, - error: entry.error || null + error: entry.error || null, + file }; } @@ -36,14 +150,138 @@ function snapshot(store) { return projects; } -export async function startHub({ workspace, port, uiDir }) { +export async function startHub({ workspace, port, uiDir, host, token } = {}) { const store = new Store(workspace); const app = express(); - app.use(cors()); - app.use(express.json({ limit: "16mb" })); + 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.get("/api/workspace", (_req, res) => { - res.json({ workspace, readme: join(workspace, "README.md") }); + res.json({ workspace, readme: readmePath, help: "/help" }); + }); + + app.get("/help", (_req, res) => { + sendWorkspaceHelp(res); }); const settingsFile = join(workspace, "settings.json"); @@ -64,6 +302,9 @@ export async function startHub({ workspace, port, uiDir }) { }); }); + 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(); @@ -92,17 +333,26 @@ export async function startHub({ workspace, port, uiDir }) { }); }); - app.get("/api/projects", (_req, res) => { + app.get("/api/projects", async (_req, res) => { + await reloadAllProjects({ broadcastUpdate: false }); 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", async (req, res) => { + app.put("/api/projects/:slug", rejectOversizedMutableFields, 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 }); @@ -111,14 +361,80 @@ export async function startHub({ workspace, port, uiDir }) { 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 }); - res.json({ ok: true, slug: req.params.slug }); + 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 + }); }); 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 }); - res.json({ ok: true, slug: req.params.slug, linkPath: r.linkPath, target: r.target }); + 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 + }); }); app.delete("/api/projects/:slug/tasks/:taskId", async (req, res) => { @@ -142,6 +458,24 @@ export async function startHub({ workspace, port, uiDir }) { 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); @@ -159,17 +493,56 @@ export async function startHub({ workspace, port, uiDir }) { res.json({ ok: true, from: r.from, to: r.to, newRev: r.newRev }); }); - app.post("/api/projects/:slug/patch", async (req, res) => { + 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) => { 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, notes: r.notes || null }); + return res.status(r.status || 400).json({ + error: r.message, + type: r.type || null, + hint: r.hint || null, + 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: entry?.rev ?? null, - notes: r.notes + 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 }); }); @@ -180,7 +553,29 @@ export async function startHub({ workspace, port, uiDir }) { } const r = await store.applyCollapse(req.params.slug, { swimlaneId, collapsed }); if (!r.ok) return res.status(r.status || 400).json({ error: r.message }); - res.json({ ok: true }); + 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 }); }); app.post("/api/projects/:slug/move", async (req, res) => { @@ -199,27 +594,12 @@ export async function startHub({ workspace, port, uiDir }) { }); app.get("/README.md", (_req, 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")); + sendWorkspaceHelp(res); }); app.get("/api/history/:slug", (req, res) => { - 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 }); + const history = store.history(req.params.slug, { fromRev: 0, limit: 50 }); + res.json({ lines: history?.events || [] }); }); // Vendor UI deps resolved from the hub's node_modules (works for local dev @@ -242,6 +622,7 @@ export async function startHub({ workspace, port, uiDir }) { 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)); } } @@ -251,6 +632,26 @@ export async function startHub({ workspace, port, uiDir }) { 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)); }); @@ -267,7 +668,7 @@ export async function startHub({ workspace, port, uiDir }) { type: err.type || null, hint: err.type === "entity.too.large" - ? "Request body exceeds 16 MB. Split the patch into smaller chunks, or open a GitHub issue if your project is legitimately this large." + ? `Request body exceeds ${bodyLimit}. Split the patch into smaller chunks, or set LLM_TRACKER_BODY_LIMIT if your project is legitimately this large.` : err.type === "entity.parse.failed" ? "Body is not valid JSON. Check quoting / escaping / trailing commas." : undefined @@ -275,7 +676,34 @@ export async function startHub({ workspace, port, uiDir }) { }); const httpServer = createServer(app); - const wss = new WebSocketServer({ server: httpServer, path: "/ws" }); + // 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)); + }); function broadcast(msg) { const data = JSON.stringify(msg); @@ -290,25 +718,72 @@ export async function startHub({ workspace, port, uiDir }) { const trackersDir = join(workspace, "trackers"); const patchesDir = join(workspace, "patches"); - // 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. + // 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. const watcher = chokidar.watch(trackersDir, { - ignoreInitial: false, + ignoreInitial: true, depth: 0, - followSymlinks: true, + 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, 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 } }); @@ -327,7 +802,7 @@ export async function startHub({ workspace, port, uiDir }) { const errPath = patchPath.replace(/\.json$/, ".errors.json"); writeFileSync( errPath, - JSON.stringify({ timestamp: new Date().toISOString(), kind: "parse", message: e.message, path: patchPath }, null, 2) + JSON.stringify(buildTrackerErrorBody({ message: e.message, kind: "parse", path: patchPath }), null, 2) ); } catch {} return; @@ -339,7 +814,13 @@ export async function startHub({ workspace, port, uiDir }) { writeFileSync( errPath, JSON.stringify( - { timestamp: new Date().toISOString(), kind: "schema", message: r.message, notes: r.notes || null, path: patchPath }, + buildTrackerErrorBody({ + message: r.message, + kind: "schema", + type: r.type || "schema", + path: patchPath, + notes: r.notes || null + }), null, 2 ) @@ -347,6 +828,13 @@ export async function startHub({ workspace, port, uiDir }) { } 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); @@ -363,7 +851,7 @@ export async function startHub({ workspace, port, uiDir }) { return true; }; - const handleFile = (filePath) => { + const ingestTrackerFile = async (filePath, { broadcastUpdate = true } = {}) => { if (!isTrackerFile(filePath)) return; const slug = slugFromFile(filePath); if (!slug) return; @@ -373,49 +861,197 @@ export async function startHub({ workspace, port, uiDir }) { } catch { return; } - const result = store.ingest(filePath, raw); - broadcast({ - type: result.event, - slug: result.slug, - project: projectPayload(result.slug, store.get(result.slug)) - }); + // 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 }; + }; + + await reloadAllProjects({ broadcastUpdate: false }); + watcher - .on("add", handleFile) - .on("change", handleFile) + .on("add", (filePath) => ingestTrackerFile(filePath)) + .on("change", (filePath) => ingestTrackerFile(filePath)) .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 }); }); - 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("─────────────────────────────────────────────────────────"); + 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); + try { await watcher.close(); } catch {} + try { + await linkedTargetsWatcher.close(); + } catch {} try { await patchesWatcher.close(); } catch {} - httpServer.close(() => process.exit(0)); + 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); }; 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 new file mode 100644 index 0000000..982ed29 --- /dev/null +++ b/hub/snippets.js @@ -0,0 +1,264 @@ +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 new file mode 100644 index 0000000..118f704 --- /dev/null +++ b/hub/status-vocabulary.js @@ -0,0 +1,50 @@ +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 21145b1..d3567ec 100644 --- a/hub/store.js +++ b/hub/store.js @@ -11,18 +11,27 @@ 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 + readHistory, + listRevs } from "./snapshots.js"; +import { buildPickedPayload, resolvePickSelection } from "./pick.js"; export function slugFromFile(filePath) { const base = basename(filePath); @@ -41,12 +50,11 @@ export function errorPath(workspace, slug) { export function writeErrorFile(workspace, slug, err) { const file = errorPath(workspace, slug); - const body = { - timestamp: new Date().toISOString(), - kind: err.kind, + const body = buildTrackerErrorBody({ message: err.message, + kind: err.kind, path: trackerPath(workspace, slug) - }; + }); try { writeFileSync(file, JSON.stringify(body, null, 2)); } catch {} @@ -85,6 +93,51 @@ 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; @@ -92,6 +145,94 @@ 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) { @@ -113,104 +254,130 @@ 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 incoming; + let loadedIncoming; try { - 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("; ") }); + 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 }; } - const derived = deriveProject(incoming); - const entry = { - data: incoming, - derived, + // Snapshot differs from incoming — treat snapshot as the prev baseline. + prev = { + data: snap, + base: incomingBase, path: filePath, rev: incomingRev, - error: null, - notes: { ignored: [], warnings: [], appended: [], updated: [] } + overlayEnabled }; - 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); - const { ok, errors } = validateProject(merged); - if (!ok) { - return this._recordError(slug, filePath, { kind: "schema", message: errors.join("; ") }); - } + const prevData = prev?.data || null; + const { merged, notes } = mergeProject(prevData, incoming); + if (normalizationNotes.warnings.length > 0) { + notes.warnings.push(...normalizationNotes.warnings); + } - 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 { ok, errors } = validateProject(merged); + if (!ok) { + return this._recordError(slug, filePath, { kind: "schema", message: errors.join("; ") }); + } - const baseRev = prev?.rev ?? incoming?.meta?.rev ?? 0; - const newRev = baseRev + 1; + 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 }; + } - merged.meta.rev = newRev; - merged.meta.updatedAt = new Date().toISOString(); + const baseRev = prev?.rev ?? incoming?.meta?.rev ?? 0; + const newRev = baseRev + 1; - try { - atomicWriteJson(filePath, merged); - } catch {} + merged.meta.rev = newRev; + merged.meta.updatedAt = new Date().toISOString(); + const persisted = this._persistProject( + slug, + filePath, + incomingBase, + merged, + overlayEnabled + ); - writeSnapshot(this.workspace, slug, newRev, merged); - appendHistoryEntry(this.workspace, slug, { - rev: newRev, - ts: merged.meta.updatedAt, - delta, - summary: summarize(delta) - }); + writeSnapshot(this.workspace, slug, newRev, merged); + appendHistoryEntry(this.workspace, slug, { + rev: newRev, + ts: merged.meta.updatedAt, + delta, + summary: summarize(delta) + }); - 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); + const entry = this._entry(slug, filePath, merged, persisted.base, newRev, notes); + entry.overlayEnabled = persisted.overlayEnabled; + this.projects.set(slug, entry); + clearErrorFile(this.workspace, slug); - return { ok: true, slug, event: "UPDATE", project: entry, delta, notes }; + return { ok: true, slug, event: "UPDATE", project: entry, delta, notes }; + } catch (e) { + return this._recordError(slug, filePath, { kind: "parse", message: e.message }); + } } _recordError(slug, filePath, err) { @@ -232,6 +399,7 @@ 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" }; } @@ -245,6 +413,7 @@ 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 }; @@ -284,6 +453,7 @@ 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("; ")}` }; @@ -341,6 +511,15 @@ 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; @@ -350,16 +529,16 @@ export class Store { if (!ok) return { ok: false, status: 400, message: errors.join("; ") }; const delta = computeDelta(current.data, newState); - const derived = deriveProject(newState); - - this.projects.set(slug, { - data: newState, - derived, - path: file, - rev: newRev, - error: null, - notes: { ignored: [], warnings: [], appended: [], updated: [] } - }); + 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, { @@ -369,26 +548,129 @@ export class Store { summary: summarize(delta) }); - atomicWriteJson(file, newState); 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}`] + }); + clearErrorFile(this.workspace, slug); + + return { ok: true, slug, restoredFromRev: targetRev, file, newRev }; + }); + } + async deleteProject(slug) { return this.withLock(slug, async () => { const file = trackerPath(this.workspace, slug); 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 }; + return { ok: true, deletedRev, deletedFromRev }; }); } @@ -402,75 +684,229 @@ export class Store { message: "project not found — register by writing the full tracker file first" }; } - let existing; + 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; try { - existing = JSON.parse(readFileSync(file, "utf-8")); + if (!existing || !existingBase) { + const loaded = this._loadProjectState(slug, file); + existing = loaded.data; + existingBase = loaded.base; + overlayEnabled = loaded.overlayEnabled; + } } 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) return { ok: false, status: 400, message: errors.join("; "), notes }; + 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) + }); - atomicWriteJson(file, merged); - return { ok: true, notes }; + clearErrorFile(this.workspace, slug); + return { ok: true, notes, rev: newRev, noop: false }; }); } - async rollback(slug, toRev) { + async pickTask(slug, { taskId, assignee = null, force = false, comment } = {}) { 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 in memory" }; + return { ok: false, status: 404, message: "project not found" }; } - const baseRev = current.rev; - const newRev = baseRev + 1; + 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(snap)); - newState.meta.rev = newRev; - newState.meta.updatedAt = new Date().toISOString(); + 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 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 { ok, errors } = validateProject(newState); - if (!ok) { + if (!ok) return { ok: false, status: 400, message: errors.join("; ") }; + + const delta = computeDelta(current.data, newState); + if (!hasChanges(delta)) { return { - ok: false, - status: 500, - message: `snapshot validation failed: ${errors.join("; ")}` + ok: true, + noop: true, + payload: buildPickedPayload({ + slug, + data: current.data, + history, + taskId: selection.taskId, + autoSelected: selection.autoSelected, + selectedBecause: selection.selectedBecause, + noop: true + }) }; } - const delta = computeDelta(current.data, newState); - const derived = deriveProject(newState); + const newRev = current.rev + 1; + newState.meta.rev = newRev; + newState.meta.updatedAt = new Date().toISOString(); - // 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: [] } - }); + 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, ts: newState.meta.updatedAt, delta, - summary: summarize(delta), - rolledBackFrom: baseRev, - rolledBackTo: toRev + summary: summarize(delta) }); - atomicWriteJson(current.path, newState); + 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 + }) + }; + }); + } - return { ok: true, from: baseRev, to: toRev, newRev }; + 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 + }); }); } @@ -487,16 +923,46 @@ 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 + rolledBackTo: e.rolledBackTo, + deletedFromRev: e.deletedFromRev, + restoredFromRev: e.restoredFromRev })); } + 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" }; } @@ -609,17 +1075,77 @@ export class Store { if (!ok) return { ok: false, status: 400, message: errors.join("; ") }; 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); - this.projects.set(slug, { - data: newState, - derived, - path: file, + writeSnapshot(this.workspace, slug, newRev, newState); + appendHistoryEntry(this.workspace, slug, { rev: newRev, - error: null, - notes: { ignored: [], warnings: [], appended: [], updated: [] } + ts: newState.meta.updatedAt, + delta, + summary: summarize(delta) }); + 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, @@ -628,7 +1154,6 @@ 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 new file mode 100644 index 0000000..6225e92 --- /dev/null +++ b/hub/task-metadata.js @@ -0,0 +1,112 @@ +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 76f114b..b7a944f 100644 --- a/hub/validator.js +++ b/hub/validator.js @@ -1,7 +1,7 @@ import Ajv from "ajv"; import addFormats from "ajv-formats"; - -export const STATUS_VALUES = ["not_started", "in_progress", "complete", "deferred"]; +import { EFFORT_VALUES, REFERENCE_PATTERN_SOURCE } from "./references.js"; +import { STATUS_VALUES, TASK_OUTCOME_VALUES } from "./status-vocabulary.js"; const schema = { type: "object", @@ -42,7 +42,12 @@ const schema = { } }, scratchpad: { type: "string", maxLength: 5000 }, - updatedAt: { type: ["string", "null"] } + updatedAt: { type: ["string", "null"] }, + deleted_tasks: { + type: ["array", "null"], + items: { type: "string", minLength: 1 }, + uniqueItems: true + } } }, tasks: { @@ -56,6 +61,9 @@ 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"], @@ -68,13 +76,47 @@ const schema = { assignee: { type: ["string", "null"] }, reference: { type: ["string", "null"], - pattern: "^.+:\\d+(-\\d+)?$" + 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" } }, comment: { type: ["string", "null"], maxLength: 500 }, - blocker_reason: { type: ["string", "null"] }, + 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" } + }, context: { type: "object" }, updatedAt: { type: ["string", "null"] }, rev: { type: ["integer", "null"] } @@ -97,6 +139,9 @@ 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 new file mode 100644 index 0000000..b6718f6 --- /dev/null +++ b/hub/verify.js @@ -0,0 +1,144 @@ +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 new file mode 100644 index 0000000..b6728d7 --- /dev/null +++ b/hub/why.js @@ -0,0 +1,187 @@ +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 fa50b0b..7820e2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,13 @@ "": { "name": "llm-tracker", "version": "0.2.0", - "license": "MIT", + "license": "Apache-2.0", "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", @@ -25,6 +26,934 @@ "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", @@ -38,6 +967,15 @@ "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", @@ -126,6 +1064,13 @@ "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", @@ -253,13 +1198,61 @@ "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" + "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==", + "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" } }, "node_modules/depd": { @@ -281,6 +1274,21 @@ "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", @@ -340,12 +1348,30 @@ "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", @@ -355,11 +1381,33 @@ "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", @@ -401,6 +1449,24 @@ "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", @@ -453,6 +1519,12 @@ "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", @@ -543,6 +1615,39 @@ "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", @@ -555,6 +1660,24 @@ "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", @@ -579,6 +1702,16 @@ "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", @@ -623,6 +1756,15 @@ "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", @@ -674,12 +1816,63 @@ "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", @@ -794,6 +1987,15 @@ "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", @@ -806,6 +2008,58 @@ "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", @@ -815,6 +2069,15 @@ "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", @@ -833,6 +2096,21 @@ "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", @@ -843,6 +2121,30 @@ "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", @@ -916,6 +2218,72 @@ "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", @@ -942,6 +2310,24 @@ "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", @@ -972,6 +2358,21 @@ "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", @@ -993,6 +2394,71 @@ "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", @@ -1065,6 +2531,12 @@ "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", @@ -1095,6 +2567,25 @@ "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", @@ -1108,6 +2599,12 @@ "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", @@ -1135,6 +2632,27 @@ "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", @@ -1155,6 +2673,25 @@ "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 8ba2fc7..8ff32e8 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "bugs": { "url": "https://github.com/justguy/llm-tracker/issues" }, - "license": "MIT", + "license": "Apache-2.0", "author": "justguy", "keywords": [ "llm", @@ -41,10 +41,11 @@ "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", @@ -58,6 +59,9 @@ "img", "README.md", "ARCHITECTURE.md", - "LICENSE" + "CHANGELOG.md", + "MIGRATING.md", + "LICENSE", + "NOTICE" ] } diff --git a/test/blockers.test.js b/test/blockers.test.js new file mode 100644 index 0000000..587a885 --- /dev/null +++ b/test/blockers.test.js @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..d637336 --- /dev/null +++ b/test/brief-cli.test.js @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..edaedbd --- /dev/null +++ b/test/briefs.test.js @@ -0,0 +1,116 @@ +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 new file mode 100644 index 0000000..83f9bc2 --- /dev/null +++ b/test/changed.test.js @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000..963dff1 --- /dev/null +++ b/test/daemon.test.js @@ -0,0 +1,270 @@ +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 new file mode 100644 index 0000000..be68283 --- /dev/null +++ b/test/decisions.test.js @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..3fa01eb --- /dev/null +++ b/test/error-payload.test.js @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..a74c506 --- /dev/null +++ b/test/execute-cli.test.js @@ -0,0 +1,90 @@ +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 new file mode 100644 index 0000000..5b17499 --- /dev/null +++ b/test/execute.test.js @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..38110c1 --- /dev/null +++ b/test/field-limits.test.js @@ -0,0 +1,189 @@ +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 e8395d1..78abaa7 100644 --- a/test/fixtures.js +++ b/test/fixtures.js @@ -1,3 +1,16 @@ +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 new file mode 100644 index 0000000..309c8aa --- /dev/null +++ b/test/healthz.test.js @@ -0,0 +1,92 @@ +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 new file mode 100644 index 0000000..9eaedca --- /dev/null +++ b/test/helpers/fake-embedder.mjs @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..73ea5a6 --- /dev/null +++ b/test/ingest-lock.test.js @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..8c48a49 --- /dev/null +++ b/test/mcp-tools.test.js @@ -0,0 +1,58 @@ +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 new file mode 100644 index 0000000..931c5c1 --- /dev/null +++ b/test/mcp.test.js @@ -0,0 +1,539 @@ +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 6ad8e76..86e52d6 100644 --- a/test/merge.test.js +++ b/test/merge.test.js @@ -76,6 +76,18 @@ 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 new file mode 100644 index 0000000..1ec7d10 --- /dev/null +++ b/test/next-cli.test.js @@ -0,0 +1,88 @@ +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 new file mode 100644 index 0000000..18e39ac --- /dev/null +++ b/test/next.test.js @@ -0,0 +1,230 @@ +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 new file mode 100644 index 0000000..b6b5677 --- /dev/null +++ b/test/pick-cli.test.js @@ -0,0 +1,87 @@ +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 new file mode 100644 index 0000000..d860bd9 --- /dev/null +++ b/test/pick.test.js @@ -0,0 +1,82 @@ +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 4976bc3..6cf8861 100644 --- a/test/progress.test.js +++ b/test/progress.test.js @@ -29,6 +29,15 @@ 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 new file mode 100644 index 0000000..3a553e5 --- /dev/null +++ b/test/references.test.js @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..68ef271 --- /dev/null +++ b/test/reload-cli.test.js @@ -0,0 +1,184 @@ +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 new file mode 100644 index 0000000..a9859bc --- /dev/null +++ b/test/restore.test.js @@ -0,0 +1,209 @@ +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 new file mode 100644 index 0000000..8502f45 --- /dev/null +++ b/test/runtime-overlay.test.js @@ -0,0 +1,108 @@ +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 new file mode 100644 index 0000000..0509490 --- /dev/null +++ b/test/search-cli.test.js @@ -0,0 +1,115 @@ +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 new file mode 100644 index 0000000..8efb878 --- /dev/null +++ b/test/search.test.js @@ -0,0 +1,291 @@ +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 new file mode 100644 index 0000000..df0a4ba --- /dev/null +++ b/test/security.test.js @@ -0,0 +1,337 @@ +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 new file mode 100644 index 0000000..3c3c942 --- /dev/null +++ b/test/shortcuts-cli.test.js @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..ef502a5 --- /dev/null +++ b/test/snippets.test.js @@ -0,0 +1,128 @@ +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 87c5eb7..8891f67 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -161,6 +161,28 @@ 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 { @@ -173,6 +195,124 @@ 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 { @@ -194,6 +334,29 @@ 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 { @@ -291,6 +454,23 @@ 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 new file mode 100644 index 0000000..3b6ce2e --- /dev/null +++ b/test/swimlane-move.test.js @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..d68d962 --- /dev/null +++ b/test/task-intel-cli.test.js @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..80c5373 --- /dev/null +++ b/test/tombstones.test.js @@ -0,0 +1,125 @@ +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 new file mode 100644 index 0000000..f464f16 --- /dev/null +++ b/test/ui-intelligence.test.js @@ -0,0 +1,80 @@ +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 64157bc..3bc0638 100644 --- a/test/validator.test.js +++ b/test/validator.test.js @@ -79,12 +79,47 @@ 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)", () => { @@ -101,6 +136,21 @@ 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 new file mode 100644 index 0000000..1817655 --- /dev/null +++ b/test/verify-cli.test.js @@ -0,0 +1,88 @@ +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 new file mode 100644 index 0000000..b43349c --- /dev/null +++ b/test/verify.test.js @@ -0,0 +1,41 @@ +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 fd81521..6c96a32 100644 --- a/test/versioning.test.js +++ b/test/versioning.test.js @@ -91,16 +91,13 @@ 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")); - 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 patch = await store.applyPatch("test-project", { tasks: { t1: { status: "complete" } } }); + assert.equal(patch.ok, true); 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); @@ -124,7 +121,6 @@ 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( @@ -166,7 +162,7 @@ test("rollback 404s for missing snapshot", async () => { } }); -test("getSince returns only events after fromRev", async () => { +test("undo restores the previous effective state and redo reapplies the undone state", async () => { const ws = setupWorkspace(); try { const store = new Store(ws); @@ -175,11 +171,62 @@ test("getSince returns only events after fromRev", async () => { 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); @@ -198,7 +245,6 @@ 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); @@ -211,6 +257,26 @@ 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 new file mode 100644 index 0000000..1a0a08a --- /dev/null +++ b/test/watcher-scope.test.js @@ -0,0 +1,93 @@ +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 new file mode 100644 index 0000000..d247789 --- /dev/null +++ b/test/why-decisions-cli.test.js @@ -0,0 +1,101 @@ +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 new file mode 100644 index 0000000..0800bd0 --- /dev/null +++ b/test/why.test.js @@ -0,0 +1,44 @@ +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 19943c3..7487fdb 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1,6 +1,10 @@ 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"]; @@ -174,6 +178,56 @@ 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); @@ -272,13 +326,29 @@ function FilterToggles({ counts, statusFilters, toggleStatus, blockedCount, open // ─────── Card / Cell / Matrix ─────── -function Card({ task, blockedBy, dragging, onDragStart, onDragEnd, onDelete, onSaveComment }) { +function Card({ task, blockedBy, dragging, searchMatch, fuzzyActive, onDragStart, onDragEnd, onDelete, onSaveComment, onOpenTask }) { 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" : ""].join(" "); + const classes = [ + "card", + `status-${task.status}`, + dragging ? "dragging" : "", + searchMatch ? "fuzzy-hit" : "", + fuzzyActive && !searchMatch ? "fuzzy-dim" : "" + ].join(" "); return html`
-
${task.id}
-
${task.title}
+
+
${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.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)} /> @@ -335,7 +463,7 @@ function Card({ task, blockedBy, dragging, onDragStart, onDragEnd, onDelete, onS `; } -function Cell({ laneId, priorityId, tasks, blocked, filterQuery, statusFilters, blockFilters, dragState, setDragState, onDrop, onDeleteTask, onSaveComment }) { +function Cell({ laneId, priorityId, tasks, blocked, filterQuery, statusFilters, blockFilters, fuzzyQuery, fuzzyMatchMap, dragState, setDragState, onDrop, onDeleteTask, onSaveComment, onOpenTask }) { const ref = useRef(null); const [over, setOver] = useState(false); @@ -405,6 +533,8 @@ 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"; @@ -414,6 +544,7 @@ function Cell({ laneId, priorityId, tasks, blocked, filterQuery, statusFilters, onDragEnd=${() => setDragState({ taskId: null })} onDelete=${onDeleteTask} onSaveComment=${onSaveComment} + onOpenTask=${onOpenTask} /> ` )} @@ -421,7 +552,7 @@ function Cell({ laneId, priorityId, tasks, blocked, filterQuery, statusFilters, `; } -function Matrix({ project, filterQuery, statusFilters, blockFilters, onMove, onToggleCollapse, onDeleteTask, onSaveComment }) { +function Matrix({ project, filterQuery, statusFilters, blockFilters, fuzzyQuery, fuzzyMatchMap, onMove, onToggleCollapse, onMoveLane, onDeleteTask, onSaveComment, onOpenTask }) { const [dragState, setDragState] = useState({ taskId: null }); const swimlanes = project.data.meta.swimlanes; const priorities = project.data.meta.priorities; @@ -452,6 +583,9 @@ function Matrix({ project, filterQuery, statusFilters, blockFilters, onMove, onT `; 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; @@ -475,7 +609,14 @@ function Matrix({ project, filterQuery, statusFilters, blockFilters, onMove, onT title="Click to expand swimlane" > ${"\u25B6"} -
${lane.label}
+
+ ${lane.label} + <${CopyInlineBtn} + value=${lane.label} + label="Lane name" + title=${`Copy lane name: ${lane.label}`} + /> +
${lane.description ? html`
${lane.description}
` : null}
${per.total} tasks @@ -493,10 +634,34 @@ function Matrix({ project, filterQuery, statusFilters, blockFilters, onMove, onT
-
${lane.label}
+
+ ${lane.label} + <${CopyInlineBtn} + value=${lane.label} + label="Lane name" + title=${`Copy lane name: ${lane.label}`} + /> +
+
+
+ +
${lane.description ? html`
${lane.description}
` : null}
@@ -516,11 +681,14 @@ function Matrix({ project, filterQuery, statusFilters, blockFilters, onMove, onT filterQuery=${filterQuery} statusFilters=${statusFilters} blockFilters=${blockFilters} + fuzzyQuery=${fuzzyQuery} + fuzzyMatchMap=${fuzzyMatchMap} dragState=${dragState} setDragState=${setDragState} onDrop=${onMove} onDeleteTask=${onDeleteTask} onSaveComment=${onSaveComment} + onOpenTask=${onOpenTask} /> ` )} @@ -629,12 +797,16 @@ function ProjectPane({ onFocus, onTogglePin, filter, + searchMode, + fuzzyMatchMap, statusFilters, blockFilters, onMove, onToggleCollapse, + onMoveLane, onDeleteTask, onSaveComment, + onOpenTask, scratchpadExpanded, onToggleScratchpad, onSaveScratchpad @@ -684,13 +856,17 @@ function ProjectPane({ ${data ? html`<${Matrix} project=${project} - filterQuery=${filter} + filterQuery=${searchMode === "filter" ? 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.

`} @@ -704,7 +880,7 @@ function HelpModal({ workspace, onClose }) { const wsPath = workspace?.workspace || "~/.llm-tracker"; const promptFile = - "Read " + readme + " and register this project as .\n\n" + + "Read http://localhost:" + port + "/help (or " + readme + " on disk) 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. " + @@ -719,7 +895,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 " + readme + " for the contract, then use HTTP-only (no file paths, no fs writes).\n\n" + + "Read http://localhost:" + port + "/help for the live 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 " + @@ -744,6 +920,9 @@ 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 () => { @@ -754,6 +933,15 @@ 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(); @@ -762,12 +950,25 @@ 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`