diff --git a/CHANGELOG.md b/CHANGELOG.md index dbdd816..a582479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ Each entry links the GitHub issue (the canonical spec) and the merge PR (the shi ### Removed -- **Monthly Progress sync and sprint issue publication** — removed 3,758 lines of scripts and dedicated tests after their intended adoption windows finished. Only one monthly issue was ever created (`Progress: April 2026`, #46; none in May–July), and the four sprint issues (#230, #234, #237, #239) were all created on 2026-07-03/04 with none since. No core lifecycle script or dev-relay integration invokes either feature, and `task-progress-reporting` accumulated no Learnings. The task mirrors under `backlog/tasks/` remain core and unchanged. Closes [#340](https://github.com/sungjunlee/dev-backlog/issues/340). +- **Monthly Progress sync and sprint issue publication** — removed 3,758 lines of scripts and dedicated tests after their intended adoption windows finished. Only one monthly issue was ever created (`Progress: April 2026`, #46; none in May–July), and the four sprint issues (#230, #234, #237, #239) were all created on 2026-07-03/04 with none since. No core lifecycle script or dev-relay integration invokes either feature, and `task-progress-reporting` accumulated no Learnings. Closes [#340](https://github.com/sungjunlee/dev-backlog/issues/340). +- **Required task mirrors** — the GitHub-native core now resolves task specification, AC, and lifecycle directly from live Issues. Fresh setup and complete sprint execution require no `backlog/tasks/` or `backlog/completed/`; `sync-pull --legacy-export` remains an explicit one-way diagnostic/rollback boundary. Closes [#347](https://github.com/sungjunlee/dev-backlog/issues/347). +- **Zero-adopter local tracker and generic compatibility machinery** — measured adoption found 0 of 17 consumers selecting a non-default tracker and GitHub remotes in all 18 known consumers. The local JSON store, local lifecycle tests, and generic/local design surface are removed; GitHub failure is fail-loud with no fallback, while Backlog.md remains manual import/explicit export compatibility only. Closes [#348](https://github.com/sungjunlee/dev-backlog/issues/348). ## [0.9.0] — 2026-07-27 @@ -21,7 +23,7 @@ Net effect: **−2,556 lines** of scripts and tests (26,984 → 24,428) with no - **Local canonical store is JSON; task files become derived mirrors** (BREAKING for `tracker: local`). `backlog/local-tracker.json` is the sole local authority, and `backlog/tasks/` + `backlog/completed/` are one-way projections — exactly the role they already held in `github` mode. The binding rule: a mirror is **never** parsed back as truth, so the `tracker-task-truth` "never two co-authoritative stores" constraint is satisfied more cleanly than the shape it replaces, where Markdown was canonical *and* hand-editable *and* lock-arbitrated at once. `local-tracker.js` 1,391 → 597 lines. Closes [#321](https://github.com/sungjunlee/dev-backlog/issues/321) / PR [#327](https://github.com/sungjunlee/dev-backlog/pull/327). - **Concurrent-write safety is revision-based compare-and-swap, not a lock.** The store carries a monotonic `revision`; a mutation reads at N, writes a complete fsynced candidate, and claims `.local-tracker.revision-{N+1}.json` through no-overwrite `link`. A losing writer *helps the existing claim across* — it is content-complete — then re-reads and retries within a bounded budget; exhaustion fails closed rather than writing unconditionally. Crash debris is inert by construction: revision-identified files a later writer can complete or clean, never a mutual-exclusion primitive left in an unknown state. - **Tracker selection moves to `backlog/.tracker`**, a single line read with `readFileSync().trim()` and validated fail-closed. `config.yml` is never written again — which is what removes the reason its 395-line selection tokenizer existed. It is still *read* for its other fields through the unchanged `lib.js:parseSimpleYaml`. Legacy compatibility is exact: an existing `tracker:` key with no `.tracker` resolves as before and is migrated on the next setup run without editing `config.yml`, so the PR #301 Learning "preserve user YAML bytes" now holds permanently and trivially; a repo with neither still defaults to `github` with zero migration. `setup-dev-backlog.js` 1,007 → 567 lines. Closes [#322](https://github.com/sungjunlee/dev-backlog/issues/322) / PR [#328](https://github.com/sungjunlee/dev-backlog/pull/328). -- **Adapter tiering and a size budget are now design contract**: a seam (`tracker.js`), remote translators (**≤200 lines**, holding no durable state), and exactly one storage substrate (`local`). The rule — *an adapter over 200 lines is not an adapter, it is a substrate; stop and re-tier* — is what keeps "support more trackers" a linear cost. Recorded in `docs/tracker-adapter-design.md` § "Adapter Tiers (v0.9.0)". Closes [#320](https://github.com/sungjunlee/dev-backlog/issues/320) / PR [#326](https://github.com/sungjunlee/dev-backlog/pull/326). +- **Adapter tiering and a size budget are now design contract**: a seam (`tracker.js`), remote translators (**≤200 lines**, holding no durable state), and exactly one storage substrate (`local`). The rule — *an adapter over 200 lines is not an adapter, it is a substrate; stop and re-tier* — is what keeps "support more trackers" a linear cost. Recorded in the [historical v0.9.0 adapter design](https://github.com/sungjunlee/dev-backlog/blob/v0.9.0/docs/tracker-adapter-design.md) § "Adapter Tiers (v0.9.0)". Closes [#320](https://github.com/sungjunlee/dev-backlog/issues/320) / PR [#326](https://github.com/sungjunlee/dev-backlog/pull/326). ### Fixed @@ -49,7 +51,7 @@ Headline: **configured tracker adapters** — exactly one adapter owns canonical ### Added -- **Configured tracker adapters — one adapter owns canonical task truth per repo** (O9). A deep, capability-gated tracker seam: the active tracker is chosen only from configuration, defaults to `github` when unset, resolution probes only the configured adapter, and the runtime never silently switches trackers on failure. Required lifecycle and identity stay small; milestones, PR relationships, mirrors, progress issues, comments, and closing semantics are capability-gated and fail closed before mutation. Design frozen in `docs/tracker-adapter-design.md`. Shipped in phases: +- **Configured tracker adapters — one adapter owns canonical task truth per repo** (O9). A deep, capability-gated tracker seam: the active tracker is chosen only from configuration, defaults to `github` when unset, resolution probes only the configured adapter, and the runtime never silently switches trackers on failure. Required lifecycle and identity stay small; milestones, PR relationships, mirrors, progress issues, comments, and closing semantics are capability-gated and fail closed before mutation. Design frozen in the [historical v0.8.0 adapter design](https://github.com/sungjunlee/dev-backlog/blob/v0.8.0/docs/tracker-adapter-design.md). Shipped in phases: - `tracker.js` configured-only resolver plus the core adapter seam; the `local` slot stays explicitly unavailable until the local adapter lands. Interface and `gh`-coupling inventory were frozen first. Closes [#272](https://github.com/sungjunlee/dev-backlog/issues/272) / PR [#280](https://github.com/sungjunlee/dev-backlog/pull/280) and [#273](https://github.com/sungjunlee/dev-backlog/issues/273) / PR [#282](https://github.com/sungjunlee/dev-backlog/pull/282). - Tracker-neutral task identity: one exact task-ref seam for GitHub `#N` and local `{PREFIX}-N[.M]`; sprint state exposes additive `tracker`/`id`/`ref` and retains GitHub `issue_number` so existing consumers keep working. Closes [#274](https://github.com/sungjunlee/dev-backlog/issues/274) / PR [#284](https://github.com/sungjunlee/dev-backlog/pull/284). - GitHub behind the seam: the GitHub adapter owns required lifecycle translation and confines direct `gh` calls to itself plus explicit milestone/mirror/progress/PR/comment/triage transports; core callers resolve only the configured tracker. GitHub is now the frozen compatibility baseline. Closes [#275](https://github.com/sungjunlee/dev-backlog/issues/275) / PR [#286](https://github.com/sungjunlee/dev-backlog/pull/286). @@ -83,7 +85,7 @@ Headline: **configured tracker adapters** — exactly one adapter owns canonical - `skills/dev-backlog/SKILL.md` reassess-signal paragraph compressed to defer accounting details to `references/integration-contract.md`; craftkit provenance stated once per SKILL.md; stale "upcoming backlog-doctor" wording moved to present tense. Closes [#246](https://github.com/sungjunlee/dev-backlog/issues/246). - `skills/dev-backlog/references/integration-contract.md` component example swapped to the live `sprint-execution` slug. Closes [#248](https://github.com/sungjunlee/dev-backlog/issues/248). - `docs/spec-system-design.md` gains a dated provenance note for the 0.7.0 spec-* move; the dead research-survey link now cites git history (pre-`cd31a2b`) with the restore decision tracked in [craftkit#124](https://github.com/sungjunlee/craftkit/issues/124). Closes [#249](https://github.com/sungjunlee/dev-backlog/issues/249). -- `spec/system-map.md` "Executable Evidence" now records the O8/O9 acceptance proof (PR [#303](https://github.com/sungjunlee/dev-backlog/pull/303)) as merged and both objectives `[validated]`, and adds a Project-Wide Invariant for Windows-first-class execution; the `docs/tracker-adapter-design.md` twin line is synced. Closes [#315](https://github.com/sungjunlee/dev-backlog/issues/315) / PR [#317](https://github.com/sungjunlee/dev-backlog/pull/317). +- `spec/system-map.md` "Executable Evidence" now records the O8/O9 acceptance proof (PR [#303](https://github.com/sungjunlee/dev-backlog/pull/303)) as merged and both objectives `[validated]`, and adds a Project-Wide Invariant for Windows-first-class execution; the [historical v0.8.0 adapter design](https://github.com/sungjunlee/dev-backlog/blob/v0.8.0/docs/tracker-adapter-design.md) twin line is synced. Closes [#315](https://github.com/sungjunlee/dev-backlog/issues/315) / PR [#317](https://github.com/sungjunlee/dev-backlog/pull/317). - Signal-driven reassess: the post-multi-track reassess cycle ran and found no v0.8.0 release blockers (backlog-doctor 8/8, capabilities-doctor ok, component-lint clean); report `backlog/triage/2026-07-20-reassess.md`. Closes [#312](https://github.com/sungjunlee/dev-backlog/issues/312) / PR [#316](https://github.com/sungjunlee/dev-backlog/pull/316). ### Removed diff --git a/CLAUDE.md b/CLAUDE.md index 014b7c4..d2ebc51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,16 +24,17 @@ The `spec-charter`, `spec-system-map`, and `spec-grill` skills moved to [craftki - **GitHub Issues = source of truth** for task definitions (what to do) - **Sprint files = execution hub** (how to do it, context, notes, progress) -- **Task files = thin GitHub mirror** (sync cache, AC checkboxes only) -- **Backlog.md compatible** — task file format follows Backlog.md; sprints/ is a custom addition +- **No required GitHub task mirror** — resolve task intent and AC from live Issues +- **Legacy task export is explicit** — `sync-pull --legacy-export` is rollback/diagnostic material only +- **Backlog.md compatible** — legacy task exports follow Backlog.md shape; sprints/ is a custom addition - **Cross-platform** — works on Claude Code and Codex (both have `gh` CLI) -- **Explicit sync** — pull/push is manual; no silent background sync +- **No hidden sync** — provider writes and legacy exports are deliberate operations ## Two-Layer Architecture ``` GitHub (what) ↔ gh CLI ↔ backlog/sprints/ (how + context) - backlog/tasks/ (thin mirror) + backlog/tasks/ (optional legacy export) ``` ## Project Spec Home diff --git a/README.md b/README.md index bed66eb..06459f5 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,15 @@ under Upgrade behavior. README.md is the product overview and human quick start. The agent execution contract, sprint-file rules, and full script reference live in [skills/dev-backlog/SKILL.md](skills/dev-backlog/SKILL.md). -The implementation still exposes local-tracker and task-export compatibility -while the 2026-08 migration is staged. Those paths are frozen, are not the -target product boundary, and never become co-authoritative. See the +The zero-adopter local tracker has been removed. GitHub is the only runtime +task authority. Backlog.md-compatible files remain an explicit one-way legacy +import/export boundary and never become co-authoritative. See the [authority and routing contract](skills/dev-backlog/references/authority-contract.md). ```text -backlog/.tracker: github | local +backlog/.tracker: github | - +-- github -> GitHub Issues (canonical) -> no required task mirror - | - `-- local -> backlog/local-tracker.json (canonical, no gh) - -> tasks/ derived compatibility projections + `-- GitHub Issues (canonical) -> no required task mirror backlog/sprints/ execution hub: plan, context, progress ^ @@ -110,22 +107,16 @@ bash /path/to/dev-backlog/skills/dev-backlog/scripts/sprint-close.sh backlog Fresh GitHub setup creates only `backlog/.tracker` and `backlog/sprints/`; `backlog/tasks/` and `backlog/completed/` are not required or created. -Transition compatibility only: an existing fully offline repository may still -choose `--tracker local`. In that explicitly selected legacy mode, -`backlog/local-tracker.json` remains its sole task authority; `backlog/tasks/` -and `backlog/completed/` are derived read-only projections. Mutate the JSON -authority only through the configured tracker lifecycle, use normalized refs -such as `BACK-1` in the Plan, and run the same `status`, `next`, and -`sprint-close` commands. This is separate from the GitHub-native core. Local -mode deliberately does not invent milestones, PR relationships, comments, or closing-keyword links. -Those requests fail before side effects with actionable remediation; JSON-capable -commands return the same structured error contract. Do not adopt local mode for -new repositories or add features to it during the GitHub-native migration. +Backlog.md compatibility is a one-way legacy boundary. Import means a +human-reviewed compatible Markdown record is used to create or amend a GitHub +Issue. Export is the explicit `sync-pull.js --legacy-export` diagnostic or +rollback snapshot. Runtime execution never reads task files as task truth, and +the Backlog.md CLI or runtime is never required. For task `list`, `read`, `create`, `update`, and `close`, the stable invocation boundary is the configured adapter exported by `scripts/tracker.js`. Operators and agents resolve it with the target `backlogDir` and call those methods in -either mode; the exact procedure and signatures are documented in +the GitHub mode; the exact procedure and signatures are documented in [the process guide](skills/dev-backlog/references/process.md#required-core-lifecycle-invocation-boundary). ### Upgrade behavior @@ -135,7 +126,8 @@ There is zero automatic tracker-selection migration. A repository with neither in GitHub mode with its existing `#N`, numeric `issue_number`, milestone, comment, and closing behavior. When `.tracker` is absent, runtime reads a legacy YAML selection as a compatibility fallback. -Running `setup-dev-backlog.js` migrates that resolved choice to `.tracker` +Only the legacy value `github` is accepted. Running `setup-dev-backlog.js` +pins that resolved choice to `.tracker` without editing `config.yml`; setup never migrates task files and runtime never chooses a tracker from availability or failure. Existing automation that invokes `sync-pull.js` without a flag must add @@ -143,8 +135,8 @@ Existing automation that invokes `sync-pull.js` without a flag must add materialization. This opt-in preserves rollback/diagnostic exports without putting them back on the normal workflow. It is an intentional CLI migration, not an automatic tracker or task-data migration. -The implementation-level contract and proof map live in -[docs/tracker-adapter-design.md](docs/tracker-adapter-design.md). +The retained compatibility seams, consumer evidence, and subtraction proof +live in [docs/compatibility-subtraction.md](docs/compatibility-subtraction.md). Then use the skill during your coding session: diff --git a/backlog/sprints/2026-07-github-native-core-simplification.md b/backlog/sprints/2026-07-github-native-core-simplification.md index bd567eb..0dfa742 100644 --- a/backlog/sprints/2026-07-github-native-core-simplification.md +++ b/backlog/sprints/2026-07-github-native-core-simplification.md @@ -22,13 +22,13 @@ Make GitHub Issues the standalone task authority, preserve sprint continuity for - [ ] #349 Validate GitHub Projects as an optional planning projection (3d) ### Batch 3 — Mirrorless execution pilot -- [~] #347 Pilot mirrorless GitHub execution and retire task mirrors (two sprints across 2–3 consuming repositories) → PR #353 (open) +- [x] #347 Pilot mirrorless GitHub execution and retire task mirrors (two sprints across 2–3 consuming repositories) → PR #353 (merged) ### Batch 4 — Subtract unused compatibility machinery -- [ ] #348 Subtract zero-adopter tracker and compatibility machinery (5d) +- [~] #348 Subtract zero-adopter tracker and compatibility machinery (5d) → PR #354 (open) [branch:codex/compatibility-subtraction] ### Batch 5 — Evidence-gated memory decision -- [ ] #350 Benchmark historical retrieval before admitting project memory (4–6 week shadow period) +- [~] #350 Benchmark historical retrieval before admitting project memory (4–6 week shadow period; earliest decision 2026-08-28) [run:memory-shadow-2026-07-31] ## Running Context - GitHub Issues are canonical task definitions and lifecycle state for this milestone; sprint files carry only complex execution continuity. @@ -46,3 +46,10 @@ Make GitHub Issues the standalone task authority, preserve sprint continuity for - 2026-07-31: #346 completed via PR #352. Final resolver coverage passed 16/16 plus Linux/Windows CI; iterative review fixed code examples, ordered/nested/lazy AC, HTML comments, and nested fence/list-container boundaries, ending with an independent no-findings review. Started #347 on `codex/mirrorless-pilot`. - 2026-07-31: #347 pilot evidence captured from active sprint execution in `sungjunlee/aibris` and `sungjunlee/dear-scene`. Both recovered live AC, task intent, lifecycle, and in-flight pointers with zero resolver blockers or mirror writes; dear-scene exposed a stale 7-AC mirror against the live 8-AC Issue. GitHub setup now creates only `.tracker` plus `sprints/`, `sync-pull` requires `--legacy-export`, and mirrorless doctor/close coverage passes. - 2026-07-31: #347 controlled transitions completed in aibris Issue #171 → PR #172 and dear-scene Issue #293 → PR #294. Both dedicated sprints closed through the real close script, both PRs merged and closed their Issues, live lifecycle re-resolved as `closed`, all 5/5 episode AC were checked, and existing task/completed mirrors had zero diff. +- 2026-07-31: #347 completed via PR #353 after Linux/Windows CI, focused 67/67, smoke 191/191, and a final independent no-findings review. Started #348 on `codex/compatibility-subtraction`. +- 2026-07-31: #348 removed the zero-adopter local tracker and generic/local design surface: four whole files and 1,909 exact lines. GitHub-only setup, no-fallback behavior, one-way Backlog.md import/export, and optional-integration absence are covered; focused regression tests passed 30/30 and the full Node suite passed. +- 2026-07-31: #348 independent review restored retained GitHub seam safety coverage for setup atomicity, public no-effect gates, availability/adapter/identity/capability contracts, and typed CLI errors. The final review reported no findings; focused tracker tests passed 18/18 and shell smoke passed 191/191. +- 2026-07-31: Opened #348 PR #354 from `codex/compatibility-subtraction`; awaiting GitHub CI and PR review. +- 2026-07-31: Addressed PR #354 Codex review by removing the actor contract's remaining current-local examples, preserving configured-prefix refs only for historical file orientation, and replacing impossible tracker-switch remediation with GitHub capability-transport recovery. Focused 33/33 and the full Node suite passed; independent re-review reported no findings. +- 2026-07-31: #350 shadow benchmark started with 20 pre-registered historical-retrieval questions across dev-backlog, dev-relay, and consumer repositories. The earliest four-week go/no-go date is 2026-08-28. +- 2026-07-31: #349 Projects scope escalation was refused pending explicit informed approval for persistent organization Project read/write access. No Project resource or local state was created. diff --git a/docs/compatibility-subtraction.md b/docs/compatibility-subtraction.md new file mode 100644 index 0000000..83d3325 --- /dev/null +++ b/docs/compatibility-subtraction.md @@ -0,0 +1,73 @@ +# Compatibility Subtraction Record + +Issue #348 removes the measured zero-adopter local tracker without adding a +provider, registry, or framework. GitHub Issues remain the only task +specification and lifecycle authority. + +## Measured surface + +The 2026-07 adoption review found 0 of 17 consumers selecting a non-default +tracker. The follow-up inventory found GitHub remotes in all 18 known consumer +repositories. No current consumer or measured portability invariant justified +the local task store. + +| Whole-file surface | Before | After | Delta | +| --- | ---: | ---: | ---: | +| Local runtime and tests (`local-tracker.js`, unit, integration) | 3 files / 1,413 lines | 0 / 0 | -3 / -1,413 | +| Generic/local adapter design document | 1 / 496 | 0 / 0 | -1 / -496 | +| Total exact whole-file deletion | 4 / 1,909 | 0 / 0 | -4 / -1,909 | + +The remaining edits further remove local branches from setup, tracker +resolution, acceptance tests, and public prose. This table deliberately counts +only exact deleted files so the measurement is reproducible with +`git show : | wc -l`. + +The setup integration suite is retained and narrowed to GitHub-only safety +invariants: legacy YAML/BOM ambiguity, strict remote evidence, sanitized +failures, atomic rollback and temp cleanup, byte-idempotent repair, selection +precedence, no-effect refusal, dangling symlink safety, and cross-platform +`init.sh` behavior. + +## Retained abstraction evidence + +| Surface | Current consumers | Portability or compatibility invariant | +| --- | --- | --- | +| `tracker.js` GitHub seam | `effective-task-spec.js`, `sync-pull.js`, `sprint-init.js`, `tracker-status-list.js` | Dependency injection keeps provider argv deterministic and proves GitHub unavailability fails without fallback. It is not a provider registry. | +| `github-tracker.js` | Core Issue list/read/create/update/close paths | Fake-`gh` acceptance tests preserve exact argv and subprocess behavior across POSIX and Git-for-Windows. | +| `task-ref.js` | Sprint-state parsing, effective task reads, legacy export filenames | `#N` is the runtime identity. `{PREFIX}-N[.M]` parsing is retained only for historical Backlog.md import/export bytes and exact filename handling. | +| `.tracker` plus legacy config reader | Setup and runtime resolution | Existing explicit or legacy `github` selection is preserved byte-for-byte; missing selection defaults to GitHub; every other value fails explicitly. | +| GitHub capability gate | Milestone-backed sprint init/close and optional transports | Injected availability/capability tests fail before effects. Core Issue execution does not require optional transports. | + +Each retained seam therefore has either a named current consumer or an +executable portability/compatibility invariant. None authorizes a second task +provider. + +## One-way Backlog.md boundary + +Backlog.md compatibility is legacy format compatibility, not a second +lifecycle: + +- import is a human-reviewed Markdown record used to create or amend a GitHub + Issue; +- export is an explicit `sync-pull.js --legacy-export` diagnostic or rollback + snapshot; +- runtime work and completion resolve the live Issue and never read task files + as fallback; +- no Backlog.md CLI, package, daemon, or bidirectional sync is required. + +## Optional-integration absence proof + +`tracker-cycle.acceptance.test.js` runs the complete mirrorless GitHub +create → Plan → orient/effective read → update → close cycle while these +surfaces are absent: + +| Optional surface | Absence invariant | +| --- | --- | +| Relay | No `.relay/` path is required or created. | +| Matt Pocock/craftkit skills | No `.agents/skills/` or `spec/` path is required or created. | +| GitHub Projects | Recorded provider argv contains no Projects command or API. | +| Backlog.md tooling | No `node_modules/`, `tasks/`, or `completed/` path is required or created. | + +The same test verifies that live task reads, AC resolution, status/next, +updates, final close, and closed-task reads succeed with only this bundle and a +fake GitHub transport. diff --git a/docs/tracker-adapter-design.md b/docs/tracker-adapter-design.md deleted file mode 100644 index 47fb6c0..0000000 --- a/docs/tracker-adapter-design.md +++ /dev/null @@ -1,496 +0,0 @@ -# Tracker Adapter Design Contract - -Status: implemented foundation with dual-mode acceptance proof on issue #278's -implementation branch. Runtime evidence was originally inventoried at commit -`019a6ec`; merged issues #273-#277 provide selection, identity, GitHub wiring, -local persistence, and setup. GitHub remains the compatibility baseline. The -proof branch merged as PR #303 (2026-07-12); O8/O9 are validated. - -Amended 2026-07-26 by "Adapter Tiers (v0.9.0)" below, which re-tiers how -adapters are built. The required interface, identity shape, capability model, -failure/authority semantics, and every GitHub compatibility row in the frozen -sections are unchanged by that amendment. - -This document froze the smallest tracker boundary that can support another -canonical task store without weakening existing GitHub behavior. The #272 -freeze itself did not configure a tracker or implement an adapter; the current -foundation state is recorded separately below. These changes do not persist -local tasks, alter setup, or rewrite command, Markdown, JSON, sprint, or -task-mirror compatibility surfaces. - -## Adapter Tiers (v0.9.0) - -Accepted 2026-07-26 as milestone 16 / issue #320. This section governs how a new -adapter is built and how large it is allowed to be. It changes no frozen -contract below. - -### The asymmetry this fixes - -The seam works. The two adapters behind it are not the same kind of thing: - -| Adapter | Lines | What it actually is | -| --- | --- | --- | -| `github-tracker.js` | 172 | a translator — build `gh` argv, parse JSON, normalize | -| `local-tracker.js` | 1,391 | a transactional file database plus a YAML round-trip serializer | - -Only a minority of `local-tracker.js` is task lifecycle. The bulk is substrate -machinery that exists because markdown was chosen as the canonical store: -roughly 200 lines of frontmatter YAML parse/serialize with verbatim -human-byte and CRLF preservation, 200 lines of allocation lock -(`.local-tracker.lock`, `pid:token` stamps, retry loop, live-versus-dead holder -distinction, deliberate no-reclamation), 150 lines of filesystem-boundary -guards, and 120 lines of close compensation and split-store detection. - -Every tracker that would come next — `gitlab`, `gitea`, `jira`, `linear` — is -the *first* kind. `local` is the only adapter that must implement storage at -all. Placing it in the same tier as `github` put a database behind the seam, -and that, not the seam, is the whole weight of the local axis. - -### Three tiers - -| Tier | Responsibility | Budget | Members | -| --- | --- | --- | --- | -| Seam | Selection, adapter-shape and identity validation, configured-only availability probing, capability gates, typed errors | ~350 lines | `tracker.js` — exactly one, permanent | -| Remote translator | Build provider CLI/API arguments, parse responses, normalize to `{ tracker, id, ref, url? }`. Holds no durable state of its own | **≤200 lines** | `github` (172); future `gitlab`, `gitea`, `jira`, `linear` | -| Storage substrate | Implement a task store where no provider exists behind the adapter | ≤600 lines | `local`, and only `local` | - -### The adapter size budget - -> **A new adapter over 200 lines is not an adapter — it is a substrate. Stop and re-tier.** - -Crossing 200 lines means the module has stopped translating and started owning -state, ordering, or durability. Those belong in the substrate tier, where there -is exactly one implementation to review, harden, and verify on both platforms. -The budget is what keeps "support more trackers" a linear cost instead of a -compounding one. - -This is a design-review gate, not a lint rule. It measures the adapter's own -module, excluding tests and shared helpers. - -### Local canonical shape - -`local` owns a JSON store under `backlog/`. `backlog/tasks/*.md` and -`backlog/completed/*.md` become **derived mirrors** — exactly the role they -already hold in `github` mode. Put differently: `local` becomes a tracker whose -"provider" is a local JSON file, so it looks like every other adapter from the -seam's point of view. - -That single decision is what lets the substrate shrink: - -- markdown is no longer canonical, so the frontmatter YAML parse/serialize path - and CRLF byte preservation are deleted; -- a single store file keeps write-temp plus atomic rename for complete, - non-torn replacement. - -The deleted allocation lock had two jobs: atomic rename prevents partial or -interleaved store bytes, but it does not serialize ID allocation or any other -read-modify-write. `local` handles that second job with revision-based -compare-and-swap: a complete fsynced candidate claims its next revision through -no-overwrite `link`, then renames into place; a collision re-reads and retries -within a bounded budget. A crash leaves only inert revision-identified debris, -never a lock that another writer must interpret or reclaim. - -**Co-authoritative rule (binding).** The derived mirror is never parsed back as -truth. Every read resolves from the JSON store; a hand-edit to a mirror file is -not an input and is overwritten on the next write. This is *stricter* than -today's arrangement, in which markdown is canonical, hand-editable, and -arbitrated by a lock — three routes to the same bytes. It satisfies the -`tracker-task-truth` hard constraint ("never treats two task stores as -co-authoritative") more cleanly than the shape it replaces. - -### Selection source - -Tracker selection moves out of `backlog/config.yml` into `backlog/.tracker`, a -single-line file containing `github` or `local`. - -The motivation is the same over-build in a different place: -`setup-dev-backlog.js` carries ~395 lines of hand-written YAML tokenizer -(`consumeQuotedToken`, `decodeDoubleQuotedScalar`, `validAnchorOrAliasName`, -`isBlockScalarHeader`, `tokenizeYamlLine`, `mutateTrackerText`, …) whose entire -job is writing one key into a user-owned YAML file safely — detecting anchors, -aliases, block scalars, and quoted keys that could hide a second `tracker:` -declaration. The repository has no `package.json` by design, since skills must -run without `npm install`, so a YAML dependency was never an option and the -parser was written by hand. The correct fix is to stop writing to `config.yml` -at all, not to write a better parser. - -Consequences: reading a selection becomes `readFileSync().trim()`; the PR #301 -Learning "preserve user YAML bytes" is satisfied permanently and trivially, -because the file is never touched; and `config.yml` continues to be *read* for -its other fields through the existing `lib.js:parseSimpleYaml` (~80 lines), -which is unchanged and stays. - -Compatibility is preserved exactly: a `tracker:` key already present in -`config.yml` with no `.tracker` file resolves to the same tracker as before and -is migrated to `.tracker` on the next setup run without editing `config.yml`; a -repository with neither still defaults to `github` with zero migration; and -runtime still never infers a tracker from availability or switches after a -failure. - -Note this repository currently carries **three** YAML implementations. After -this amendment only the first survives: `lib.js:parseSimpleYaml` (reads -`config.yml`, used everywhere — keep), the `setup-dev-backlog.js` tokenizer -(writes the tracker key — deleted), and the `local-tracker.js` frontmatter -round-trip (task files — deleted). - -#### The legacy read guarantee is narrower than the deleted tokenizer, on purpose - -The tokenizer existed to *write* one key into a user-owned YAML file safely: it -had to be certain no second declaration was hiding anywhere, because clobbering -one would corrupt the user's config. Nothing writes `config.yml` any more, so -that job is gone. What remains is a read that must either resolve the value the -old code resolved, or refuse. - -`legacy-tracker.js` therefore **refuses without decoding**. It counts `tracker` -keys wherever the old lexer counted them — nested, sequence, and flow contexts -included — and excludes what the old lexer excluded: block-scalar bodies, -comments, and quoted spans. Beyond that it refuses two shapes the old lexer -decoded, rather than reimplementing the decoder: a quoted key containing escape -sequences (`"track\x65r": github`) and an explicit mapping key (`? tracker`). - -Two accepted consequences, recorded so they are decisions rather than gaps: - -- A config using those shapes is **refused with an actionable message** where the - old setup would have decoded and then refused it as a duplicate. The outcome — - no migration, an error naming the problem — is the same; only the reason text - differs. -- A `tracker:` sequence appearing inside a **multi-line** quoted scalar can be - over-counted, producing a refusal where the old lexer accepted the file. This - errs toward refusal, never toward a silent selection. - -The residual risk is a pre-existing repository whose `config.yml` uses YAML -escapes or explicit keys around its tracker declaration. Measured 2026-07-26, -one repository in the world carries a `tracker:` key at all, and this change -migrates it. Rebuilding the decoder to close that gap would restore the ~395 -lines this issue exists to delete. - -### Windows consequence - -Replacing an open allocation-lock pathname during reclamation was the sole -cause of the local tracker's Windows divergence. Three tests carried: - -``` -t.skip("Windows prevents replacing an open lock pathname; Ubuntu covers this POSIX race") -``` - -Compare-and-swap has no lock pathname to reclaim. A writer claims its revision -through no-overwrite `link` and renames its own complete candidate into place; -nothing is ever replaced while another process holds it open. The redesign -therefore **deletes** those skips rather than re-documenting them, and the -concurrent-writer coverage runs everywhere. Windows-specific code stays at 68 -lines (`bash-runtime.js` 44 plus `portable-path.js` 24) and the -`windows-latest` CI job. - -### What survives unchanged - -The PR #298 Learnings are durable and survive the format change: exact-ID -allocation across active and completed tasks, fail-closed control-character and -injection validation, and crash-recoverable close/archive semantics. Dropping -any of them is a regression, not a simplification. - -Also unchanged: the required interface, the `{ tracker, id, ref, url? }` -identity, capability reporting, the failure and authority semantics, and every -GitHub compatibility row in the frozen sections below. - -### Migration posture - -`local` has zero adopters. Measured 2026-07-26 across the 19 repositories that -consume dev-backlog: no local store exists in any of them, and exactly one -`backlog/config.yml` carries a `tracker:` key — this repository's own, set to -the compatibility default `github`. `scope:` appears in zero sprint files and no -repository runs two or more active tracks. - -Therefore **no migration path for existing local stores ships**. That is a -deliberate, dated decision taken while the window is open, not an oversight. A -later adopter starts on the JSON shape. - -### Spec impact - -The canonical-shape change is an implementation choice; it does not alter an -Expected Behavior or a Hard Constraint of the `tracker-task-truth` capability. -A `Decisions` row in `spec/capabilities.md` therefore records it. If review -finds otherwise, it escalates to a human-gated `spec-grill` pass before the -implementation merges — an invariant is never amended unattended (the #294 -precedent). - -## Runtime Adapter State (#273-#278) - -This section records the runtime as of #278. Selection source and the local -canonical shape are superseded by "Adapter Tiers (v0.9.0)" above once milestone -16 lands; everything else here stands. - -`backlog/config.yml` selects one `tracker`, initially `github` or `local`. -Repositories without that key use `github` as a deterministic compatibility -default. Selection reads only the supplied configuration value: it performs no -CLI, authentication, remote, adapter, or filesystem detection. - -`skills/dev-backlog/scripts/tracker.js` owns selection, exact adapter-shape and -identity validation, configured-only availability probing, and optional -capability gates. An unavailable or throwing configured adapter fails with no -probe or fallback to the other slot. The GitHub slot now delegates its required -task lifecycle to `github-tracker.js`; generic sync and orientation callers use -configured-only resolution. Milestone and triage GitHub transports live in -explicitly named provider modules and are reached only -after their declared capability gates. Legacy helper exports remain compatibility -shims over those owners, including their injected execution seams. The local slot -is now implemented by `local-tracker.js` (#276): it owns the seven required -operations over `backlog/tasks/` and `backlog/completed/` as the canonical local -task store, allocates collision-safe parent IDs under an exclusive lock with -atomic same-filesystem publication, preserves human body/AC bytes on -metadata-only updates, archives on close without overwrite, reports no optional -capabilities, and never invokes `gh` or falls back. In local mode these task -files are canonical; GitHub mode continues to treat them as mirrors. -`setup-dev-backlog.js` (#277) persists a deliberate choice without reserializing -user YAML or migrating tasks. Issue #278 adds the offline dual-mode executable -proof and aligns the public documentation with this runtime. - -### Shared unsupported-capability boundary - -`tracker.js` owns `UnsupportedTrackerCapabilityError` and its serializer. The -stable code is `TRACKER_CAPABILITY_UNSUPPORTED`; serialized errors contain -exactly `code`, `tracker`, `capability`, `message`, and `remediation`. A public -JSON command wraps that shape once as `{ "error": ... }`, writes it to stdout, -and exits non-zero. Human commands write the same message and remediation to -stderr. Capability gates run before provider/filesystem effects and never -change `backlog/.tracker` or resolve another tracker. - -### Dual-mode executable proof (#278) - -`skills/dev-backlog/scripts/tracker-cycle.acceptance.test.js` is the release -proof. Its table-driven `github` and `local` rows cross real temporary-file and -CLI/subprocess boundaries without network access. The GitHub row starts with a -tracker-less legacy config, records fake-`gh` argv, and freezes `#N`, numeric -`issue_number`, task mirror bytes/body preservation, milestone, close, and -final read/list behavior without rewriting the -config. The local row performs explicit setup, canonical create, normalized -Plan orientation, read/update/body preservation, Done archive, sprint close, -and final read/list with an execution-trap `gh` that records zero calls. A -capability table covers all four optional features plus representative JSON and -human public boundaries. - -## Pre-Seam Baseline Inventory - -Everything in this historical inventory describes the runtime at `019a6ec`, before the -tracker seam existed. That baseline had no configured tracker resolver: -GitHub Issues were task truth, production callers either executed `gh` directly -or call GitHub-specific helpers, sprint Plan items use numeric `#N` references, -and task mirrors encode the same number in `BACK-N`-style names and IDs. - -The inventory was derived from live source, not filenames. The direct-call -set is reproducible with: - -```bash -rg -l --glob '!*.test.js' --glob '!smoke-test.sh' \ - '(execFile(?:Sync)?\("gh"|^[[:space:]]*(?:MS="\$MILESTONE" )?gh (?:api|issue|pr)\b|^[[:space:]]*"gh",)' \ - skills/dev-backlog/scripts skills/backlog-triage/scripts -``` - -### Direct `gh` invocation inventory - -These were the seven remaining production files in the frozen inventory. Test and smoke -fixtures are evidence for compatibility, but are not production callers. - -| Production file | Direct current behavior | Current owner | Target seam or capability | Later issue | -| --- | --- | --- | --- | --- | -| `skills/dev-backlog/scripts/lib.js` | `getOpenIssueCount` runs a GraphQL issue count; `fetchOpenIssues` runs `gh issue list` and parses GitHub fields. | Shared dev-backlog GitHub query helper | Required `list` plus configured-adapter availability; GitHub argv stays inside the GitHub adapter. | #273 seam, #275 move | -| `skills/dev-backlog/scripts/sync-pull.js` | Its exported `fetchOpenIssues` runs `gh issue list`; omitted limits call the shared GraphQL count helper. | Backlog materialization | Required `list`; writing/updating mirrors remains `backlog-sync`. | #275 | -| `skills/dev-backlog/scripts/sprint-init.js` | `getMilestoneDue` runs `gh api .../milestones`; `getMilestoneIssues` runs `gh issue list --milestone`. | Sprint planning | Optional `milestones`; issue results still enter the required task identity seam. | #275 | -| `skills/dev-backlog/scripts/status.sh` | Human mode runs `gh issue list` for the “GitHub Issues” table. JSON mode does not call GitHub; it delegates to `sprint-state.js`. | Sprint orientation | Required `list` for the configured tracker; current GitHub table/output is the baseline. | #275 | -| `skills/dev-backlog/scripts/sprint-close.sh` | `--close-milestone` lists GitHub milestones and PATCHes the matching milestone closed. | Sprint closeout | Optional `milestones`; local sprint completion remains owned by `sprint-execution`. | #275 | -| `skills/backlog-triage/scripts/triage-collect.js` | GraphQL-fetches open issues and optional recent closed issues; optionally REST-fetches comments per issue. | Triage evidence collection | Required `list`/`read` for core task evidence; optional `pull-request relationships` and `comments` for enrichment. | #275 | -| `skills/backlog-triage/scripts/triage-apply.js` | `runGh` executes generated issue view/comment/edit/close commands for accepted anchors. | Explicit triage mutation | Required `update`/`close` for neutral task changes; optional `comments` and `milestones` for GitHub-only actions. | #275 | - -### GitHub-specific helpers and injection seams - -The following helpers are already public or dependency-injected test seams. -They are compatibility surfaces; moving behavior must not make existing tests -or consumers spawn a real `gh` process unexpectedly. - -| Current helper surface | Current coupling and injectable boundary | Target ownership | Preservation owner | -| --- | --- | --- | --- | -| `lib.js`: `GH_EXEC_DEFAULTS`, `OPEN_ISSUE_COUNT_QUERY`, `OPEN_ISSUE_JSON_FIELDS`, `getOpenIssueCount({ repo, execFile })`, `fetchOpenIssues({ repo, limit, defaultLimit, execFile })` | GitHub query shapes and injected `execFile` are exported. | GitHub adapter internals, with compatibility exports or shims at the old module boundary. | #273 declares the shim rule; #275 preserves argv/results. | -| `sync-pull.js`: `getOpenIssueCount(execFile)`, `fetchOpenIssues(limit, execFile)`, `loadOpenIssues({ limit, execFile })`, `run({ issues, ... })` | CLI transport and filesystem materialization are separable today. | Adapter supplies required `list`; sync-pull retains its materializer and existing exports. | #275 | -| `sprint-init.js`: `createSprintFile({ getDue, getIssues, ... })` | Tests inject milestone due and issue collection even though the default functions call `gh`. | Optional milestone capability supplies those values; file construction remains sprint-owned. | #275 | -| `triage-collect.js`: exported `fetchOpenIssuesGraphql`, `fetchIssueComments`, `fetchClosedIssues`, `collectSnapshot`, and repo parsers | Collection accepts injected execution and stores GitHub-shaped snapshot v2 data. | Core list/read plus optional relationship/comment enrichment; GitHub remote parsing remains provider-scoped. | #275 | -| `triage-apply.js`: exported `toGhCommands`, `runGh`, `parseGhLabels`, and `execute(..., deps)` | Command generation, execution, and `deps.runGh`/`deps.execFile` are observable seams. | Neutral mutations call required lifecycle methods; provider actions remain capability-gated; compatibility helpers remain. | #275 | - -### Numeric reference, renderer, and storage inventory - -This table inventories every production parser, renderer, or persisted/public -surface under `skills/dev-backlog` and `skills/backlog-triage` that assumes a -numeric GitHub issue, `#N`, `BACK-N`-style filename/ID, or `issue_number`. -Rows group symbols only when they share one owner and one migration boundary. - -| Production surface | Current evidence and contract | Current owner | Target seam or capability | Later issue | -| --- | --- | --- | --- | --- | -| `init.sh`; `lib.js` config defaults | Bootstrap writes `task_prefix: "BACK"`; `CONFIG_DEFAULTS.task_prefix` stores the default mirror prefix. | Backlog configuration | Prefix participates in display `ref`, not canonical `id`; tracker selection is a separate single value. | #273 config, #274 identity | -| `sync-pull.js` task materialization | `findExistingTaskFile` matches `{PREFIX}-{issue.number} - `; filenames are `{PREFIX}-{N} - {slug}.md`; frontmatter stores `id: {PREFIX}-{N}`. | `backlog-sync` task mirror | Materialize from normalized identity while preserving GitHub filenames, frontmatter, body preservation, and byte shape. | #274 identity, #275 transport | -| `sprint-init.js` Plan renderer | `buildIssueLines` emits exactly `- [ ] #${issue.number} ...`; milestone collection returns numeric GitHub issues. | Sprint planning | Render the identity `ref`; GitHub continues to render `#N` byte-for-byte. Milestone lookup is optional. | #274 renderer, #275 milestone | -| `lib.sh`, `next.sh`, and human `status.sh` | `RE_CB_*`, checkbox counting, next-item selection, and displayed Plan lines require `#` immediately after the checkbox. | Shell sprint consumption | One normalized Plan-ref parser must back behavior while preserving all existing GitHub human output. | #274 | -| `sprint-state.js` | `CHECKBOX_RE` accepts only `#(\d+)`; `PR_RE` parses `PR #N`; `parsePlanItem` stores `issue_number`; `computeAge` matches exact `#N` in Progress. | Single machine sprint parser | Parse normalized task `ref`, add normalized identity fields, preserve GitHub `issue_number`, PR annotation, age matching, batches, and schema compatibility. PR data remains optional provider metadata. | #274 | -| `backlog-doctor.js` | Consumes `sprint-state.js` and republishes `issue_number` in `publicPlanItem` for in-flight checks. | Sprint health reporting | Consume normalized identity additively while retaining the current public GitHub field. | #274 | -| `sprint-close.sh` | Extracts digits from checked `#N` lines, then finds exactly `/[A-Z]+-{N} - ` before moving the task mirror. | Sprint closeout | Use the single normalized ref/identity implementation; preserve exact-match protection (`1` must not select `11`) and GitHub move behavior. | #274 | -| `triage-collect.js` snapshot v2 | Stores numeric `issues[].number`, `closing_prs[].number`, optional `closed_issues[].number`, and comments; repo detection accepts GitHub remotes only. | `triage-grooming` evidence store | Core list/read identities at collection boundary; GitHub snapshot schema remains compatible, with optional PR/comment enrichment. | #275 | -| `triage-relate.js` | `extractIssueRefs`, body/comment phrase scanners, `blocks`/`closes`/`depends on` regexes, numeric edge endpoints, and renderers use `#N`; merged PR evidence uses PR numbers. | Triage relationship analysis | Core task identities for relationships; GitHub `#N` snapshot/report compatibility remains, and PR links are optional. | #275 | -| `triage-stale.js` | Validates numeric snapshot issues; emits `#N`, `merge-into:#N`, merged closing PR labels, and numeric duplicate targets. | Triage stale analysis | Core task identities for candidates; closing-PR evidence and provider closing action stay optional. | #275 | -| `triage-report.js` | `ANCHOR_PATTERN`, `parseAnchor`, active-sprint protection, relationship rendering, action models, and `merge-into:#N` all store/render numeric `issueNumber`/`#N`. | Triage report and confirmation surface | GitHub report/anchor grammar is frozen; neutral core identity may be additive, never a rewrite of existing reports. | #275 | -| `triage-apply.js` | Parses numeric anchors, dedupes on `issueNumber`, stores numeric `issue` in the JSONL apply log, emits `issueNumber` in JSON, and generates numeric GitHub commands. | Explicit triage mutation and audit | Required update/close for neutral actions; comments/milestones are optional. Existing anchors, logs, JSON, and command helpers remain readable/callable. | #275 | - -Current user-facing documentation also promises `Fixes #N` close linking and -GitHub issue comments/labels during work. Those are compatibility evidence, -not core tracker semantics: closing-keyword linkage and comments are optional -capabilities, while a provider's mapping of neutral task fields to labels is an -adapter concern. - -## Accepted Target Design - -This section is the accepted target from issue #270 and merged PR #271. It is -intentionally separate from the runtime inventory above. - -Exactly one explicitly configured tracker owns canonical task truth for a -repository. Initial configured values are `github` and `local`; an absent new -key may retain GitHub through the compatibility default frozen for #273, but -runtime availability never chooses a value. Sprint files remain the canonical -execution hub. Task files are derived mirrors in both modes; local task truth -lives only in `backlog/local-tracker.json`, while GitHub task truth remains in -GitHub Issues. They are never two canonical task stores. - -The seam is deep rather than a command wrapper: callers ask for task lifecycle -operations and stable identity. The GitHub adapter owns GitHub transport and -translation. Provider publication and relationship features are discovered as -optional capabilities and never enlarge the required interface. - -```text -one persisted tracker selection - | - v -configured adapter -- availability + capabilities - | - +-- required task lifecycle and normalized identity - | - `-- explicitly supported optional provider capabilities - -backlog/sprints/ remains the execution hub -``` - -## Required Tracker Interface - -The operation set below is normative; method/class names and internal control -flow are not. This is the entire required interface: - -| Required operation | Contract | -| --- | --- | -| Availability | Probe only the configured adapter and return usable/unusable with an actionable reason. It reports state; it never selects another adapter. | -| Capability reporting | Report which optional capabilities the configured adapter actually supports. Absence is explicit. | -| List tasks | Return normalized tasks and identities from the one canonical task store. | -| Read task | Read one task by normalized identity (or an unambiguously parsed ref at a compatibility boundary). | -| Create task | Create one task in the configured canonical store and return its normalized identity. | -| Update task | Update provider-neutral task content/state in the configured canonical store and return the resulting task/identity. | -| Close task | Close one task in the configured canonical store and return the resulting task/identity. This does not promise provider closing keywords or PR linkage. | - -Every lifecycle operation carries or returns this normalized identity: - -```text -{ tracker, id, ref, url? } -``` - -| Identity field | Meaning | -| --- | --- | -| `tracker` | Configured adapter key that owns the identity, initially `github` or `local`. | -| `id` | Stable adapter-owned identifier. Treat it as opaque; it is not required to be numeric or equal to `ref`. | -| `ref` | Stable display/reference string used at human and compatibility boundaries, such as GitHub `#42` or local `BACK-42`. | -| `url?` | Optional provider link. Absence is valid and must not be fabricated. | - -No milestone, PR-relationship, comment, or closing-keyword method belongs -in this required set. Callers may translate their existing payloads to the -provider-neutral task content/state needed by these operations, but this design -does not freeze internal classes, transport objects, or call order. - -## Optional Capabilities - -Optional behavior is invoked only after capability reporting says it is -supported. A provider may expose none, some, or all of these without weakening -the required task lifecycle. - -| Optional capability | Existing GitHub behavior it contains | Current owner | -| --- | --- | --- | -| Milestones | Milestone due/issue selection in `sprint-init`, accepted triage assignment, and `sprint-close --close-milestone`. | Sprint planning/close and triage; GitHub implementation in #275. | -| Pull-request relationships | Merged-closing-PR triage evidence and PR annotations/links. | Triage; GitHub implementation in #275. | -| Comments | Accepted triage comments. | Triage; GitHub implementation in #275. | -| Closing semantics | `Fixes #N`, provider close keywords/PR auto-linkage, and duplicate-close reason. | Workflow guidance and triage; GitHub implementation in #275. | - -Provider labels, assignees, and other metadata are not additional required -operations. The GitHub adapter may map provider-neutral task fields internally -to preserve current behavior; arbitrary provider metadata would require a -separately reported optional capability rather than leaking into this core. - -### Failure and authority semantics - -The governing failure text is exact: - -> runtime never silently switches the configured tracker, transient auth/CLI/remote failure cannot select `local`, unsupported capabilities fail clearly, and two task stores are never co-authoritative. - -Consequences: - -- An unavailable configured adapter returns an actionable availability error. - It does not retry against another task store or reinterpret mirrors as truth. -- The absent-key GitHub compatibility default planned for #273 is a stable - configuration rule, not failure detection and not fallback. -- Optional capability calls fail before mutation with the configured tracker - and unsupported capability identified. Callers do not fabricate an empty - milestone, relationship, comment, or close-link result. -- Canonical writes go only to the selected adapter. Derived mirrors may be - written from canonical task state, but never become a second authority. -- Partial or transient provider failure remains a failure of that operation; - it cannot change selection for the next operation. - -## Compatibility Matrix - -GitHub behavior is the baseline except where a later product-boundary decision -is explicitly recorded. “Preserve” includes arguments, mutation -safety, Markdown and filenames, JSON aliases, human output where asserted, and -existing dependency-injection seams. Additive normalized fields are allowed; -removing or silently changing an existing field is not. - -| Command or data/helper surface | Frozen GitHub compatibility promise | Implementation issue | -| --- | --- | --- | -| `sync-pull` | #347 deliberately replaces no-flag compatibility with a required `--legacy-export` gate (no flag exits 2 before materialization). Behind that gate, preserve all open issues when limit is omitted, `--update` frontmatter refresh and AC/body preservation including the marker-owned Progress-body exception, idempotent filenames/content, and no hidden write. | #275 foundation; #347 boundary change | -| Task file format and filenames | Preserve `backlog/tasks/{PREFIX}-{N} - {slug}.md`, frontmatter `id: {PREFIX}-{N}`, title/status/labels/priority/milestone/date fields, body structure, and `backlog/completed/` names for GitHub. No historical rename. | #274 | -| `sprint-init` | Preserve CLI/JSON, active-sprint refusal, milestone due/date behavior, milestone issue selection, estimates, and GitHub Plan lines `- [ ] #N ...`. Missing/failed GitHub milestone queries continue their current `TBD`/empty degradation for GitHub; another adapter does not inherit milestone semantics. | #275, with #274 rendering | -| Sprint Plan grammar | Existing `- [ ] #N`, `- [~] #N`, `- [x] #N`, batch headings, `[run:...]`, `[branch:...]`, and `→ PR #N (state)` remain accepted/rendered exactly. Local refs are additive; historical sprints are not rewritten. Exact matching prevents `#1`/`#11` and `BACK-1`/`BACK-11` collisions. | #274 | -| `status.sh --json` | Continue delegating to `sprint-state.js --mode status`; keep schema v1 fields and fail-loud ambiguous-active behavior. Normalized identity fields may be additive only. Human GitHub mode retains its current issue table after transport moves. | #274 JSON, #275 human list | -| `next.sh --json` | Continue delegating to `sprint-state.js --mode next`; preserve the same full JSON document, next-batch wave semantics, field aliases, and ambiguous-active failure. Human Plan output remains compatible. | #274 | -| `sprint-state.js` fields, including `issue_number` | Preserve top-level `schema_version`, `active_sprint`, `plan_items`, `next_batch`, `latest_progress`, and `in_flight`; preserve every current item/age/pointer field. For GitHub entries `issue_number` remains the same integer wherever it currently appears; normalized identity is additive. | #274 | -| `sprint-close` | Preserve doctor-before-close, status/progress mutation, checked-task move, exact numeric filename match, context reminder, dry-run, and current output. GitHub milestone closure remains available only through the declared milestone capability and never runs for unsupported adapters. | #274 task ref; #275 milestone | -| `backlog-triage` | Preserve GitHub snapshot v2 fields, `#N` relationship and anchor grammar, advisory-by-default behavior, explicit apply/`--yes`, accepted-action dedupe, argv, JSONL audit logs, JSON fields, and protection of active-sprint issues. Core list/read may use the seam; comments, milestones, PR evidence, and GitHub close reasons remain capability-gated. | #275 | -| Exported helper injection seams | Every helper listed in “GitHub-specific helpers and injection seams” remains exported with compatible inputs/results, or an explicit compatibility shim preserves it. Injected `execFile`, `runGh`, filesystem readers, milestone readers, comment readers, and sprint-state paths must remain effective; tests must not cross the seam into real network/process calls. | #273 shim rule; #275 transport/argv proof | - -## Verification Map - -This map records the implemented foundation leaves and their proof ownership. - -| Later issue | Frozen sections it must satisfy | Required verification evidence | -| --- | --- | --- | -| #273 — configured selection and core seam | “Accepted Target Design”, “Required Tracker Interface”, exact “Failure and authority semantics”, and the exported-helper row of the Compatibility Matrix. | Unit tests for `github`/`local`/invalid/absent selection, unavailable configured adapter, capability report, unsupported capability error, no transient fallback, and compatibility exports. Assert the required operation set contains only availability, capabilities, list/read/create/update/close and identity exactly includes `{ tracker, id, ref, url? }`. Do not implement local storage or setup. | -| #274 — tracker-neutral task references | “Numeric reference, renderer, and storage inventory”, normalized identity in “Required Tracker Interface”, and the task-file/Plan/status/next/sprint-state/close rows of the Compatibility Matrix. | Parser/renderer golden tests for legacy `#N`, additive local `{PREFIX}-N`, exact-match collisions, invalid/mixed fixtures, byte-compatible GitHub Plan output, additive JSON identity, and retained GitHub `issue_number`. No historical rewrite and no local persistence. | -| #275 — GitHub behavior behind the seam | “Direct `gh` invocation inventory”, “GitHub-specific helpers and injection seams”, “Optional Capabilities”, failure rules, and every GitHub behavior row of the Compatibility Matrix. | A source scan proving core callers no longer own direct GitHub task lifecycle calls; mocked golden argv/results for every inventoried call family; existing marker/content safety tests; triage regression tests; full Node and smoke suites. Explicitly GitHub-scoped optional modules may still execute `gh`; capability absence must fail clearly. | -| #276 — local canonical persistence | Required lifecycle, identity, and authority/failure semantics. | Offline lifecycle, exact identity, collision-safe allocation, body preservation, fail-closed storage, recovery, and archive tests with no GitHub calls. | -| #277 — explicit setup | Persisted selection and zero-migration authority rules. | Fresh/legacy setup process tests, byte-idempotent config mutation, provider isolation, atomic publication, and explicit-switch refusal/repair evidence. | -| #278 — dual-mode release proof | Compatibility Matrix, shared unsupported-capability boundary, and documentation/runtime alignment. | `tracker-cycle.acceptance.test.js` table rows, fake/trapped `gh`, exact GitHub argv/bytes/aliases, offline local lifecycle, all-capability typed errors, representative public JSON/human errors, plus repository-wide gates. | - -The repository-wide regression gate is: - -```bash -git diff --check -node --test skills/*/scripts/*.test.js -node --test skills/dev-backlog/scripts/tracker-cycle.acceptance.test.js -bash skills/dev-backlog/scripts/smoke-test.sh -node skills/dev-backlog/scripts/objectives-check.js --json -node skills/dev-backlog/scripts/component-lint.js --json -node skills/dev-backlog/scripts/capabilities-doctor.js --json -node skills/dev-backlog/scripts/backlog-doctor.js --json -npx --yes skills add . -l -``` - -The phase boundaries remain historical ownership boundaries; no leaf makes two -task stores co-authoritative or retroactively rewrites GitHub repositories. diff --git a/skills/dev-backlog/SKILL.md b/skills/dev-backlog/SKILL.md index 867fdd1..7d68cd2 100644 --- a/skills/dev-backlog/SKILL.md +++ b/skills/dev-backlog/SKILL.md @@ -1,8 +1,8 @@ --- name: dev-backlog argument-hint: "[orient|create|plan|work|next|sync|complete] [issue-number]" -description: Manage configured-tracker-backed sprint execution. Use for GitHub mirrors or offline local tasks, sprint planning or closing, next-work selection, 다음 작업, 이슈 만들어, 스프린트 계획, 백로그. -compatibility: Requires git and Node.js 18+; GitHub mode also requires gh CLI. Works on Claude Code and Codex. +description: Manage GitHub-backed sprint execution and explicit legacy exports. Use for sprint planning or closing, next-work selection, 다음 작업, 이슈 만들어, 스프린트 계획, 백로그. +compatibility: Requires git, Node.js 18+, and gh CLI. Works on Claude Code and Codex. metadata: related-skills: "spec-charter, spec-grill, backlog-triage, relay, relay-plan, relay-dispatch, relay-review, relay-merge" --- @@ -28,35 +28,29 @@ README covers install and human quick start. This file is the agent execution co | "complete", "close sprint" | `complete` | Sprint/task state is finalized and rediscovery-prone context is promoted. | If `backlog/` does not exist, run `scripts/setup-dev-backlog.js --tracker -github|local --non-interactive`; see `references/file-format.md`. Never infer a +github --non-interactive`; see `references/file-format.md`. Never infer a tracker from availability. Related skills (none required for either core cycle): when installed, `spec-charter` (`spec/charter.md`), `spec-system-map` (`spec/system-map.md`), and `spec-grill` (`spec/capabilities.md`) ship with craftkit (`npx skills add sungjunlee/craftkit`) and supply the optional spec axis; [`backlog-triage`](../backlog-triage/SKILL.md) provides advisory backlog review before sprint planning. Degradation when they are absent is specified in `references/spec-fallback.md`. -The target state ownership, migration freeze, and optional-integration boundary +The state ownership, compatibility, and optional-integration boundary are single-sourced in [`references/authority-contract.md`](references/authority-contract.md). -Compatibility code for local trackers and task mirrors may remain during the -staged migration, but it is not permission to expand the target product. ## Core Contracts -The runtime still exposes this transition implementation contract while the -live resolver and compatibility subtraction are staged: - ``` -backlog/.tracker (one line: github | local) - github -> GitHub Issues canonical; no task-file directory required - local -> backlog/local-tracker.json canonical; zero provider calls +backlog/.tracker (one line: github) + -> GitHub Issues canonical; no task-file directory required -backlog/tasks/ + completed/ <- optional GitHub exports / derived local compatibility projections +backlog/tasks/ + completed/ <- optional one-way legacy exports backlog/config.yml <- Backlog.md settings; legacy tracker fallback only -backlog/sprints/ <- shared execution hub in both modes +backlog/sprints/ <- optional complex-execution hub ``` - One active track per scope: sprints with `status: active` must declare disjoint scopes (`component:` equality or `scope:` glob collision = overlap, decided by the shared `scopesOverlap` predicate). Disjoint tracks coexist as a portfolio; overlapping tracks fail loud; most repos run a single track, which behaves exactly as before. - Start every session by reading `backlog/sprints/_context.md` and the active sprint file when present. -- Task files are derived mirrors in both modes and are never read back as truth; canonical task truth is GitHub Issues (`github`) or `backlog/local-tracker.json` (`local`). In both modes, decisions, progress, and cross-task context stay in the sprint file. -- A missing `.tracker` file falls back to a legacy `tracker:` key in `config.yml`, then to the zero-migration GitHub compatibility default. Runtime failure never changes the selected tracker. +- Task files are one-way legacy exports and are never read back as truth. GitHub Issues own task truth; decisions, progress, and cross-task context stay in an admitted sprint file. +- A missing `.tracker` file accepts only the legacy value `github` from `config.yml`, then uses the zero-migration GitHub compatibility default. Any other value fails; runtime failure never changes selection. - Optional provider capabilities are not part of the core lifecycle. Unsupported requests fail before effects through the shared typed error contract in `tracker.js`; public JSON surfaces emit one structured error and human surfaces include the same remediation. - Completed sprints stay as the permanent execution record. - Backlog-side file boundaries live in `references/backlog-boundaries.md`. Spec-axis boundaries and how `objectives:`/`component:` degrade when spec files are absent live in `references/spec-fallback.md` (in-bundle, always resolvable); their durable authoring home is craftkit's `spec-charter` skill, consulted when installed. Sprint `objectives:` reference charter Objective IDs, and `component:` is one primary capability handle from `spec/capabilities.md`. @@ -113,14 +107,13 @@ current state and next actionable batch. Follow `references/process.md` → `## Create — New Issues`. Done when the new task exists in GitHub and, only when the work was admitted to -a sprint, is added to the active Plan. Transition compatibility modes keep -their existing behavior until their staged retirement. +a sprint, is added to the active Plan. ### Plan 1. Confirm that the work meets a Sprint Admission trigger. Otherwise keep the Issue → PR path sprint-free. 2. Resolve Objectives from `spec/charter.md`; fall back to legacy root `CHARTER.md`; omit the `objectives:` field entirely when both are absent (see `references/spec-fallback.md`). -3. List/inspect open tasks. Use milestone selection only when the configured adapter reports `milestones`; local planning writes normalized refs directly and does not fabricate one. +3. List/inspect open Issues. Use milestone selection only when the GitHub adapter reports `milestones`. 4. Create the active sprint file with Goal, ordered Plan batches, estimates, and dependencies. Include `objectives:` and `component:` only when their backing spec files exist; use `sprint-init.js --component "slug"` when a capability axis exists, or mutually exclusive `--scope` globs when no component axis fits. Plan batches are execution waves: intra-batch items MUST be mutually parallel-safe (disjoint files, no ordering between them), dependent items MUST go in a later batch, and batch order is execution order. 5. A second active track is refused only when its scope overlaps an existing active track; declare a disjoint `component:`/`scope:` to run tracks concurrently. Once more than one track is active, any track without a declared axis warns and allows (disjointness cannot be proven against an undeclared scope). @@ -167,7 +160,6 @@ Done when there is no stale active sprint or rediscovery-prone context trapped i the Issue changes. - GitHub rollback/diagnostics: `sync-pull.js --legacy-export` may explicitly export non-authoritative mirrors. Never use them as execution input. -- Local: `backlog/local-tracker.json` is already canonical and its mirrors are refreshed on every mutation; do not call `gh` or manufacture a push/pull step. - Never perform background sync or switch trackers after a failure. Done when the user can tell which direction changed and what was updated. @@ -193,12 +185,12 @@ node "$skill_dir/scripts/sprint-init.js" "next-sprint" --dry-run Core scripts (full flag inventory in `references/scripts.md`): - `scripts/init.sh` — bootstrap `backlog/`. -- `scripts/setup-dev-backlog.js` — persist the explicit canonical tracker without migrating task files. +- `scripts/setup-dev-backlog.js` — persist `github` without migrating task files. - `scripts/effective-task-spec.js` — resolve live task specification, AC, lifecycle, source, and stable digest without consulting task mirrors. - `scripts/sync-pull.js --legacy-export` — opt-in rollback/diagnostic export; never part of setup, orient, plan, work, or complete. -- `scripts/sprint-init.js` — create a milestone-backed sprint when supported; local plans are authored from normalized refs. +- `scripts/sprint-init.js` — create a milestone-backed sprint when supported. - `scripts/next.sh` / `scripts/status.sh` — next actionable batch and tracker-neutral sprint state; portfolio view for N disjoint tracks, `--track ` for one. - `scripts/sprint-close.sh` — close the active sprint (`--track ` when multiple tracks are active); prints the doctor/reassess summary. - `scripts/backlog-doctor.js` — aggregate health checks; JSON includes `reassess_signal`. diff --git a/skills/dev-backlog/references/authority-contract.md b/skills/dev-backlog/references/authority-contract.md index 3164610..6f4ad83 100644 --- a/skills/dev-backlog/references/authority-contract.md +++ b/skills/dev-backlog/references/authority-contract.md @@ -1,9 +1,8 @@ # GitHub-native authority and routing contract -This is the target product contract for the 2026-08 GitHub-native core -simplification milestone. During the staged migration, code may still expose -local-tracker and task-mirror compatibility paths, but those paths must not -gain features or become a second authority. +This is the product contract for the 2026-08 GitHub-native core simplification +milestone. The zero-adopter local tracker has been removed. Task files remain +only as an explicit one-way legacy import/export boundary. The contract is based on the 2026-07-27 adoption review: all 17 observed consumer repositories used the default GitHub path, and 0 of 17 selected a @@ -20,7 +19,7 @@ easier to view or retrieve, but it never accepts an independent write. | State class | Sole authority | Write and read route | Non-authoritative surfaces | | --- | --- | --- | --- | | Task specification | GitHub Issue body and acceptance criteria | Create or amend the Issue, then read the live Issue | Legacy `backlog/tasks/` files, sprint Plan text, GitHub Projects | -| Task lifecycle | GitHub Issue state and native metadata | Update the Issue state, labels, milestone, assignees, and native relationships | Sprint checkboxes, local task files, project-board fields | +| Task lifecycle | GitHub Issue state and native metadata | Update the Issue state, labels, milestone, assignees, and native relationships | Sprint checkboxes, legacy task files, project-board fields | | Planning fields | GitHub Issue native metadata | Use labels, milestone, assignees, and Issue relationships; read them live | GitHub Projects views/fields, triage reports, sprint ordering | | Complex execution state | One active sprint file for the admitted track | Update its Plan, Running Context, and Progress at explicit boundaries | Relay run artifacts, PR tabs, chat history, status projections | | Durable decisions | The bounded `spec/*` contract axis | Amend through the human-gated spec process; route project, system, and capability decisions to the matching spec file | Issues, sprint Running Context, `_context.md`, generated memory | @@ -66,10 +65,9 @@ The core product excludes: dependencies; - a second task-spec or lifecycle authority outside GitHub Issues. -Until this milestone completes, do not add tracker adapters, local-tracker -features, bidirectional compatibility machinery, task-mirror features, or a -committed memory/compiler layer. Removal work must follow the staged resolver -and mirrorless pilot rather than deleting recovery paths prematurely. +Do not add tracker providers, bidirectional compatibility machinery, +task-mirror lifecycle features, or a committed memory/compiler layer without +new measured adoption evidence and an explicit authority-contract amendment. ## Optional boundaries @@ -78,7 +76,7 @@ and mirrorless pilot rather than deleting recovery paths prematurely. | Relay | Optional implementation/review delegation | May update an admitted sprint through its integration contract; never required for task resolution or sprint execution | | Matt Pocock skills | Optional shaping and execution techniques | May help an actor plan or implement; no persisted dev-backlog state or hard dependency | | GitHub Projects | Optional planning projection | May visualize Issue metadata; project-only fields cannot become task or lifecycle authority and the core flow must work without Projects | -| Backlog.md | Optional format compatibility | Existing compatible Markdown may remain import/export material; its conventions and runtime are not product dependencies | +| Backlog.md | Optional one-way legacy format compatibility | Human-reviewed Markdown may be imported into a GitHub Issue; `--legacy-export` may emit diagnostic/rollback snapshots; task files are never read as runtime authority and Backlog.md tooling is not required | | Spec axis | Optional durable project contract | Human-gated when present; absence must not block task work or the complete sprint cycle | | Retrieval/memory experiments | Optional, report-only evidence tools | Must remain reproducible projections until the separate benchmark meets its quantitative go/no-go gate | diff --git a/skills/dev-backlog/references/file-format.md b/skills/dev-backlog/references/file-format.md index 486006a..d75b7e1 100644 --- a/skills/dev-backlog/references/file-format.md +++ b/skills/dev-backlog/references/file-format.md @@ -1,6 +1,8 @@ # File Format Reference -Task files are compatible with the [Backlog.md](https://github.com/MrLesk/Backlog.md) task format. +Task files are explicit legacy exports compatible with the +[Backlog.md](https://github.com/MrLesk/Backlog.md) task format. Runtime never +reads them as task specification or lifecycle authority. ## Frontmatter Fields @@ -8,7 +10,7 @@ Task files are compatible with the [Backlog.md](https://github.com/MrLesk/Backlo | Field | Type | Required | Description | |-------|------|----------|-------------| -| `id` | string | Yes | Storage ref `{PREFIX}-{N[.M]}`; a GitHub mirror derives `N` from the issue number, while local owns the ref directly | +| `id` | string | Yes | Legacy export ref `{PREFIX}-{N}` derived from the GitHub issue number | | `title` | string | Yes | Brief, action-oriented description | | `status` | string | Yes | Current state (see Status Values below) | @@ -61,15 +63,13 @@ Examples: - `PROJ-7 - Fix-login-timeout.md` - `BACK-100.2 - Create-HTTP-server-module.md` (sub-task) -The prefix comes from `config.yml` (`task_prefix`). In GitHub mode, the numeric -part is the issue number and the file is a mirror. In local mode, the configured -adapter allocates the canonical local ID. +The prefix comes from `config.yml` (`task_prefix`). The integer part is the +GitHub issue number. Decimal IDs are accepted only when parsing historical +Backlog.md files; they are not runtime task identities. ## Body Structure -Task files are derived mirrors in both modes: from GitHub Issues when -`.tracker` contains `github`, and from `backlog/local-tracker.json` when it -contains `local`. +Task files are one-way legacy exports from GitHub Issues. Notes, decisions, and cross-task context still go in the **sprint file** (`backlog/sprints/`), not here. @@ -78,10 +78,9 @@ Notes, decisions, and cross-task context still go in the **sprint file** [Synced from GitHub issue body — includes any checkboxes from the issue] ``` -`sync-pull.js` and the local projection wrap a task body in `## Description`. -In GitHub mode the issue body is authoritative. In local mode the JSON body is -authoritative, so hand-edited mirror bytes are overwritten on the next local -write rather than parsed back into task truth. +`sync-pull.js --legacy-export` wraps an Issue body in `## Description`. The +Issue body is authoritative; hand-edited export bytes are never parsed back +into task truth. ## Effective Task Specification @@ -105,7 +104,7 @@ from `AC:BEGIN/END` (preferred), an Acceptance Criteria heading, or a legacy body-wide fallback, plus normalized `lifecycle`, `source_ref`, and stable SHA-256 `source_revision`/`source_digest`. -For manual task files or Backlog.md CLI compatibility, you can optionally add structured AC markers: +For a human-reviewed Backlog.md import or export, you can optionally add structured AC markers: ```markdown ## Acceptance Criteria @@ -117,11 +116,10 @@ For manual task files or Backlog.md CLI compatibility, you can optionally add st The `` markers enable machine parsing by the Backlog.md CLI. Without them, acceptance criteria still work as plain checkboxes — the file reads fine either way. -Configured-adapter operations may update supported frontmatter fields, and an -explicit body update may change task-body content. Metadata-only updates -preserve body and AC bytes. During normal execution, keep human task-body edits -to AC checkboxes; notes, technical decisions, and running context belong in the -sprint file. +Import is intentionally manual and one-way: review compatible Markdown, then +create or amend a GitHub Issue. No runtime command treats a file edit as an +Issue update. Notes, technical decisions, and running context belong in an +admitted sprint file. ## Sprint Frontmatter (spec-axis fields) @@ -146,11 +144,11 @@ Order planned tasks into parallel-safe batches. Group small tasks (~30min or les github ``` -The supported values are `github` and `local`. When `.tracker` is missing, -runtime reads a legacy top-level `tracker:` value from `config.yml`; with -neither, it deterministically defaults to `github`. Availability never changes -the selection or falls back to the other adapter. Setup writes `.tracker` -atomically and never edits `config.yml`. +The only supported value is `github`. When `.tracker` is missing, runtime +accepts only a legacy top-level `tracker: github` value from `config.yml`; with +neither, it deterministically defaults to `github`. Any other value fails. +Availability never changes selection. Setup writes `.tracker` atomically and +never edits `config.yml`. ## config.yml @@ -164,22 +162,6 @@ statuses: ["To Do", "In Progress", "Done"] `config.yml` remains the read-only source for Backlog.md settings such as `task_prefix`; setup never creates, rewrites, or removes fields from it. -## Local Canonical Storage (`.tracker` = `local`) - -In local mode `backlog/local-tracker.json` is the **canonical** task store. -`backlog/tasks/` and `backlog/completed/` are one-way derived mirrors and are -never read back as task truth. Required list/read/create/update/close operations -return normalized identity -`{ tracker: "local", id, ref: "{PREFIX}-{N[.M]}" }` without fabricating a URL. -Metadata-only updates preserve the canonical body/AC bytes; close atomically -changes one JSON record to `state: closed` and projects `status: Done`. Local -reports no optional provider capabilities, so -milestones, PR relationships, comments, and closing -semantics fail before filesystem or provider effects and never invoke `gh`. - -JSON allocation, atomic publication, collision, and recovery details have one -implementation owner: [Tracker Adapter Design Contract](../../../docs/tracker-adapter-design.md). - dev-backlog also reads `task_prefix`, `default_status`, and `statuses`; `project_name` is retained as metadata. Other Backlog.md config fields are not consumed by dev-backlog. @@ -193,8 +175,12 @@ Hierarchical IDs use decimal notation: Sub-tasks get their own files: `BACK-42.1 - Subtask-title.md` -## Backlog.md CLI Compatibility +## Backlog.md One-Way Legacy Compatibility -The `backlog/tasks/` and `backlog/completed/` directories use a Backlog.md-compatible task-file format, so the CLI will recognize them. +`sync-pull.js --legacy-export` may write `backlog/tasks/` in a +Backlog.md-compatible shape for diagnosis or rollback. The Backlog.md CLI may +recognize those files, but it is not installed, invoked, or required by +dev-backlog. To import historical compatible Markdown, a human must review it +and explicitly create or amend the corresponding GitHub Issue. The `backlog/sprints/` directory is a custom addition for sprint execution tracking. Backlog.md CLI ignores it (only scans `tasks/`, `completed/`, `drafts/`, `decisions/`, `docs/`). This is safe — sprints/ won't interfere with CLI operations. diff --git a/skills/dev-backlog/references/integration-contract.md b/skills/dev-backlog/references/integration-contract.md index 3a1f2ab..e9e3efc 100644 --- a/skills/dev-backlog/references/integration-contract.md +++ b/skills/dev-backlog/references/integration-contract.md @@ -12,7 +12,7 @@ Any actor consuming dev-backlog state should treat these files as the stable rea - `backlog/sprints/*.md` with `status: active` are the active execution hubs — one per disjoint-scope track (most repos run a single track): frontmatter identifies lifecycle, routing, and track-scope state; `## Goal`, `## Plan`, `## Running Context`, and `## Progress` identify the current objective, work queue, reusable discoveries, and execution trace. - `backlog/sprints/_context.md` is cross-sprint project memory. Its sections provide durable context for future sessions and analyzers. -- `backlog/tasks/` and `backlog/completed/` are derived mirrors in both modes. GitHub Issues are authoritative for `tracker: github`; `backlog/local-tracker.json` is authoritative for `tracker: local`. Actors resolve effective task specs and AC through `effective-task-spec.js`; mirror bodies and checkboxes are diagnostic/export bytes only. Sprint files remain the execution log. +- `backlog/tasks/` and `backlog/completed/` are optional legacy exports with one-way flow from GitHub. GitHub Issues are the sole task authority. Actors resolve effective task specs and AC through `effective-task-spec.js`; exported bodies and checkboxes are diagnostic/rollback bytes only. Sprint files remain the execution log. - `spec/capabilities.md`, when present, is an optional capability-level learning target addressed by active sprint frontmatter `component:`. The sections below define the path, heading, checkbox, and annotation grammar. Consumers may read more prose, but they must not require additional headings or rewritten formats to orient from files alone. @@ -32,27 +32,26 @@ Multiple active tracks are a **portfolio**, not an error; only **overlapping-sco ## Tracker Selection and Capability Error Surface -`backlog/.tracker` selects exactly one canonical tracker. When absent, a legacy -`tracker:` key in `backlog/config.yml` is read as a compatibility fallback; with -neither, GitHub is the zero-migration default. Availability probes and operation -failures never select another adapter. Existing GitHub `#N`, numeric -`issue_number`, filenames, Markdown, argv, and provider behavior remain aliases -with unchanged values. Local Plan items use `{PREFIX}-N[.M]`, and their -`issue_number` is `null`. +`backlog/.tracker` accepts exactly one runtime authority: `github`. When absent, +the legacy `tracker:` key in `backlog/config.yml` is read only when its value is +also `github`; with neither, GitHub is the zero-migration default. Every other +selection fails before provider calls or local mutation. Availability probes +and operation failures never select another adapter. Existing GitHub `#N`, +numeric `issue_number`, filenames, Markdown, argv, and provider behavior remain +aliases with unchanged values. Optional provider capabilities are `milestones`, `pull-request-relationships`, -`comments`, and `closing-semantics`. A configured -tracker that does not report one must fail before effects with this serialized -shape: +`comments`, and `closing-semantics`. A GitHub transport that does not report one +must fail before effects with this serialized shape: ```json { "error": { "code": "TRACKER_CAPABILITY_UNSUPPORTED", - "tracker": "local", + "tracker": "github", "capability": "milestones", - "message": "Tracker \"local\" does not support capability \"milestones\".", - "remediation": "Use tracker \"local\" without \"milestones\", or explicitly change backlog/.tracker to a tracker that supports it before retrying. No tracker switch was attempted." + "message": "Tracker \"github\" does not support capability \"milestones\".", + "remediation": "Use tracker \"github\" without \"milestones\", or restore that tracker's capability transport before retrying. No tracker switch or fallback was attempted." } } ``` @@ -89,10 +88,10 @@ Top-level schema: | `line` | string | Original plan line. | | `checkbox_state` | string | Exact marker content: `" "`, `"~"`, or `"x"`. | | `state` | string | Normalized state: `todo`, `in_flight`, or `done`. | -| `tracker` | string | Identity owner: `github` for `#N`, or `local` for a configured-prefix ref. | -| `id` | string | Stable tracker-owned ID. GitHub uses the decimal issue number as a string; local decimal subtask IDs such as `42.1` stay lossless. Treat this field as opaque. | -| `ref` | string | Complete display ref, byte-compatible `#N` for GitHub or `{PREFIX}-N[.M]` for local. | -| `issue_number` | integer or `null` | Compatibility alias. It remains the exact integer for GitHub items and is `null` for local items; a local numeric suffix is never coerced into a GitHub issue number. | +| `tracker` | string | `github` for current `#N` work. Historical `{PREFIX}-N[.M]` Plan lines may parse as `local` for file-only orientation; that value is not a selectable runtime provider. | +| `id` | string | Stable opaque ID. GitHub uses the decimal issue number as a string; a historical decimal subtask ID such as `42.1` remains lossless for file-only orientation. | +| `ref` | string | Complete display ref: byte-compatible `#N` for current GitHub work, or a preserved historical `{PREFIX}-N[.M]` ref. | +| `issue_number` | integer or `null` | Compatibility alias. It is the exact integer for GitHub items and `null` for historical configured-prefix refs, which are never coerced into GitHub issue numbers. | | `title` | string | Plan title after removing parsed PR, branch, and run annotations. | | `batch_heading` | string or `null` | Current `### Batch...` heading, if any. | | `pr` | object or `null` | `{ "number": N, "state": "..." }` from `→ PR #N (state)` when present. | @@ -168,12 +167,14 @@ The doctor's own JSON schema stays at `1`; it is independent of the actor read-s | What | Pattern | Example | |------|---------|---------| -| Task files | `backlog/tasks/{PREFIX}-{N[.M]} - {slug}.md` | `backlog/tasks/BACK-42.1 - oauth-flow.md` | +| Legacy task exports | `backlog/tasks/{PREFIX}-{N[.M]} - {slug}.md` | `backlog/tasks/BACK-42.1 - oauth-flow.md` | | Active sprint | `backlog/sprints/*.md` with `status: active` | `backlog/sprints/2026-03-auth-system.md` | | Cross-sprint context | `backlog/sprints/_context.md` | (always this exact name) | -| Completed tasks | `backlog/completed/{PREFIX}-{N[.M]} - {slug}.md` | `backlog/completed/BACK-38 - db-schema.md` | +| Legacy completed exports | `backlog/completed/{PREFIX}-{N[.M]} - {slug}.md` | `backlog/completed/BACK-38 - db-schema.md` | -**PREFIX** defaults to `BACK` and is configurable via `backlog/config.yml` → `task_prefix`. +These task paths exist only when an operator requests a legacy export. **PREFIX** +defaults to `BACK` and is configurable via `backlog/config.yml` → +`task_prefix`; it does not select or identify a runtime provider. ## Sprint File Sections @@ -233,26 +234,36 @@ Sprint plan items use this format: - [ ] #42 OAuth2 flow (~2hr) - [~] #42 OAuth2 flow (~2hr) → PR #87 (reviewing) - [x] #42 OAuth2 flow (~2hr) → PR #87 (merged) -- [ ] BACK-42 Local task -- [~] BACK-42.1 Local subtask [branch:local-42.1] ``` | Marker | Meaning | Regex | Set by | |--------|---------|-------|--------| -| `[ ]` | Not started | complete `#N` or `{PREFIX}-N[.M]` after the marker | sprint-init.js, manual | -| `[~]` | In-flight (PR open/reviewing) | complete `#N` or `{PREFIX}-N[.M]` after the marker | dev-relay dispatch | -| `[x]` | Done (merged/completed) | complete `#N` or `{PREFIX}-N[.M]` after the marker | dev-relay merge, manual | +| `[ ]` | Not started | complete `#N` after the marker | sprint-init.js, manual | +| `[~]` | In-flight (PR open/reviewing) | complete `#N` after the marker | dev-relay dispatch | +| `[x]` | Done (merged/completed) | complete `#N` after the marker | dev-relay merge, manual | ### Task reference and compatibility aliases `skills/dev-backlog/scripts/task-ref.js` is the grammar owner. It accepts only complete positive refs: - GitHub: `#N`, where `N` is a positive decimal integer. Identity is `{ tracker: "github", id: "N", ref: "#N" }`. -- Local: `{PREFIX}-N` or `{PREFIX}-N.M`, where `PREFIX` is the exact `backlog/config.yml` `task_prefix` and both numeric components are positive integers. Identity is `{ tracker: "local", id: "N[.M]", ref: "{PREFIX}-N[.M]" }`. - -Zero, negative, partial, foreign-prefix, whitespace-suffixed, and malformed refs are rejected. Decimal notation is supported for local Backlog.md subtasks, not GitHub issue refs. GitHub Plan lines and mirror Markdown continue to render byte-for-byte as `#N`. - -The shell `RE_CB_*` variables remain GitHub-only compatibility aliases for external consumers. Core `status.sh`, `next.sh`, checkbox counting, and closeout delegate task-ref recognition to the shared module. Machine actors should consume `tracker`/`id`/`ref`; `issue_number` remains an unchanged GitHub alias and is `null` for local entries in `plan_items`, `next_batch.items`, `in_flight`, and doctor projections. +- Historical compatibility only: `{PREFIX}-N` or `{PREFIX}-N.M`, where + `PREFIX` is the exact `backlog/config.yml` `task_prefix` and both numeric + components are positive integers. File-only orientation preserves identity + `{ tracker: "local", id: "N[.M]", ref: "{PREFIX}-N[.M]" }`; this does not + make `local` a valid `.tracker` selection or permit lifecycle operations. + +Zero, negative, partial, foreign-prefix, whitespace-suffixed, and malformed refs +are rejected. Decimal notation is preserved only for historical Backlog.md +subtasks, not current GitHub Issue refs. New Plan lines use `#N`. + +The shell `RE_CB_*` variables remain GitHub-only compatibility aliases for +external consumers. Core `status.sh`, `next.sh`, checkbox counting, and closeout +delegate task-ref recognition to the shared module. Machine actors should +consume `tracker`/`id`/`ref`; `issue_number` remains an unchanged GitHub alias. +Historical configured-prefix entries may still appear with `tracker: "local"` +and `issue_number: null` in file-only projections, but actors must not dispatch +or mutate them through the retired provider. ### PR annotation (appended by dev-relay) @@ -298,7 +309,7 @@ Briefs, or ecosystem documents as implicit authority. Optional candidates may be reported for a human to adopt through `spec_ref`, but discovery alone never changes the selected source. -## Transition Task File Structure +## Legacy Export File Structure ```yaml --- @@ -325,7 +336,7 @@ dev-relay reads canonical AC from the effective task-spec result. The `` markers are parsed by the resolver when present and remain compatible with legacy task-file rendering. -Task-file AC is only a transition projection. It is not a Work authorization or +Task-file AC is only a legacy export projection. It is not a Work authorization or relay review anchor by itself. relay-plan freezes Done Criteria and rubrics in the relay run artifacts, and relay-review evaluates against that frozen snapshot. `spec/*` files may read canonical task AC or frozen Done Criteria as @@ -427,7 +438,7 @@ This is optional — items without `[run:...]` are valid when another trace poin - **Missing section in sprint**: treated as empty. - **No task mirror file**: normal path; actors use the effective task-spec resolver and sprint close proceeds without an archive move. - **No GitHub access**: sprint JSON may recover execution continuity and in-flight pointers, but task intent, AC, and lifecycle are unresolved. Stop before execution; do not read a task mirror. -- **Unsupported optional capability**: return the typed error above; do not fabricate an empty provider result, mutate local state, or switch trackers. +- **Unsupported optional capability**: return the typed error above; do not fabricate an empty provider result, mutate sprint or provider state, or switch/fallback trackers. ## Cross-Project Smoke Test diff --git a/skills/dev-backlog/references/process.md b/skills/dev-backlog/references/process.md index f0e4ab5..130340e 100644 --- a/skills/dev-backlog/references/process.md +++ b/skills/dev-backlog/references/process.md @@ -1,22 +1,22 @@ # Process Detailed workflow for each phase. `SKILL.md` has the summary; this file routes -the same core cycle through the one tracker selected in `backlog/.tracker`. -Adapter mechanics and the compatibility proof have one implementation owner: -[Tracker Adapter Design Contract](../../../docs/tracker-adapter-design.md). +the same core cycle through GitHub Issues. The retained seam and subtraction +proof are documented in +[Compatibility Subtraction](../../../docs/compatibility-subtraction.md). ## Setup — Choose Canonical Task Truth -1. For a fresh repository, run `scripts/setup-dev-backlog.js --tracker github|local --non-interactive`. -2. With no `.tracker`, preserve a legacy `tracker:` value from `config.yml`; with neither, keep the deterministic GitHub default. Setup writes the resolved choice to `.tracker` without editing `config.yml`. +1. For a fresh repository, run `scripts/setup-dev-backlog.js --tracker github --non-interactive`. +2. With no `.tracker`, accept only a legacy `tracker: github` value from `config.yml`; with neither, keep the deterministic GitHub default. Setup writes `github` to `.tracker` without editing `config.yml`. 3. Never infer selection from `gh`, authentication, remotes, existing task files, or an operation failure. Setup does not migrate task files. ## Required Core Lifecycle Invocation Boundary The official create/read/update/close boundary for operators and agents is the -configured adapter exported by `scripts/tracker.js`. Resolve it from the target -backlog directory; do not import `github-tracker.js` or `local-tracker.js` -directly and do not select an adapter from runtime availability: +adapter exported by `scripts/tracker.js`. Resolve it from the target backlog +directory; do not import `github-tracker.js` directly and do not select from +runtime availability: ```js const path = require("node:path"); @@ -31,12 +31,11 @@ const { adapter } = resolveConfiguredTracker(readConfig(backlogDir), { backlogDi Call `adapter.list({ state, limit })`, `adapter.read(selector)`, `adapter.create(input)`, `adapter.update(selector, changes)`, or `adapter.close(selector, options)`. Feed the returned normalized `ref` into the -sprint Plan. GitHub selectors are `#N`; local selectors are -`{PREFIX}-N[.M]`. These exported adapter methods are the stable core lifecycle +sprint Plan. Runtime selectors are `#N`. These exported adapter methods are the stable core lifecycle API; shell/Node scripts such as `status.sh`, `sync-pull.js`, and `sprint-close.sh` are workflow boundaries around it, not substitutes for task create/read/update/close. Low-level storage and provider argv remain owned by -the linked Tracker Adapter Design Contract. +the linked Compatibility Subtraction record. For Work and AC verification, use the higher-level read boundary: @@ -57,8 +56,8 @@ resolver never reads `backlog/tasks/` or `backlog/completed/`. 1. If `backlog/` does not exist, complete **Setup**. 2. Read `backlog/sprints/_context.md` when present. 3. Find the active sprint(s). One track: read Goal, Plan, Running Context, and latest Progress. Multiple disjoint tracks: `status.sh`/`next.sh` render a portfolio; pass `--track ` to work one track. -4. If no active sprint exists, list open tasks through the configured adapter and proceed to **Plan**. -5. Use `status.sh --json` and `next.sh --json` for normalized `tracker`/`id`/`ref` state (`schema_version: 2`: `active_sprints[]` plus the retained single-track fields); GitHub keeps numeric `issue_number`, local returns `null`. +4. If no active sprint exists, list open Issues and create a sprint only when complexity admission applies. +5. Use `status.sh --json` and `next.sh --json` for normalized `tracker`/`id`/`ref` state (`schema_version: 2`: `active_sprints[]` plus the retained single-track fields); GitHub keeps numeric `issue_number`. 6. If all Plan items are checked, proceed to **Complete** for that track. `_context.md` plus the track's sprint file provide the execution picture; canonical task reads come from the configured adapter. @@ -66,10 +65,8 @@ resolver never reads `backlog/tasks/` or `backlog/completed/`. ## Create — New Tasks 1. Call the configured adapter's required `create` operation. -2. Use its returned normalized ref in the current sprint Plan when in scope: GitHub `#N`, local `{PREFIX}-N[.M]`. -3. In GitHub mode, continue directly from the created Issue; do not create a - task mirror. In local compatibility mode, create already wrote canonical - JSON and its derived projection; do not call `gh`. +2. Use its returned `#N` ref in the current sprint Plan when in scope. +3. Continue directly from the created Issue; do not create a task mirror. ## Plan — Sprint @@ -77,10 +74,9 @@ When starting a new sprint: 1. Refuse a new sprint only when its scope overlaps an existing active track (`component:` equality or `scope:` glob collision — `sprint-init.js` checks via the shared `scopesOverlap` predicate); disjoint-scope tracks coexist. Complete a conflicting track rather than flipping `status:` inline. Once more than one track is active, any track without a declared axis warns and allows (disjointness cannot be proven against an undeclared scope). 2. Resolve optional `objectives:` and `component:` fields from the spec axis as described in `spec-fallback.md`; pass `sprint-init.js --component "slug"` for a declared capability, or mutually exclusive `--scope "glob[,glob]"` when no component axis fits. -3. List open tasks from the configured adapter. -4. GitHub mode may create/assign a milestone and run `sprint-init.js "topic" --milestone "Name"`; its `#N`, estimates, due date, argv, and JSON remain legacy-compatible. -5. Local mode does not fabricate a milestone. Author the sprint file from normalized local refs returned by the adapter. -6. Set a one-sentence Goal, order mutually parallel-safe work into batches, put dependencies in later batches, and record estimates where useful. +3. List open Issues through the adapter. +4. GitHub may create/assign a milestone and run `sprint-init.js "topic" --milestone "Name"`; its `#N`, estimates, due date, argv, and JSON remain legacy-compatible. +5. Set a one-sentence Goal, order mutually parallel-safe work into batches, put dependencies in later batches, and record estimates where useful. ## Work — Execute a Batch @@ -93,10 +89,10 @@ When starting a new sprint: 4. Do the work and verify every returned AC before checking it off. 5. Update the sprint Plan, Progress, and reusable Running Context only for admitted work. -6. In GitHub mode, comments, PR relationships, milestones, and closing keywords are optional provider capabilities. Invoke them only after their capability gate succeeds. Local mode reports none of them and must continue with the core lifecycle without provider calls. +6. Comments, PR relationships, milestones, and closing keywords are optional GitHub capabilities. Invoke them only after their capability gate succeeds. Delegated work follows the relay Plan → Dispatch → Review → Merge flow; the -same normalized Plan refs remain the sprint anchor in either tracker mode. +same normalized Plan refs remain the sprint anchor. ## Complete — Close Tasks and Sprint @@ -106,9 +102,7 @@ Per task: recorded source revision. If the source changed, review the new effective spec before completion. 2. Commit or merge the implementation and check the Plan item. -3. Call required `close`: GitHub closes the Issue; local marks the canonical - JSON record closed/`Done` and publishes its derived projection under - `backlog/completed/`. +3. Call required `close` to close the GitHub Issue. 4. Use `Fixes #N`, comments, or closing relationships only when GitHub capability semantics are intentionally in scope. For the whole sprint: @@ -117,21 +111,19 @@ For the whole sprint: 2. The command sets `status: completed`, appends final Progress, and prints the doctor/reassess summary. In mirrorless GitHub mode it neither requires nor creates task directories. When checked legacy GitHub mirrors happen to - exist, it archives only those compatibility files. In local compatibility - mode, each task must already have been closed through the configured adapter; - that task close updates canonical JSON and its completed projection, while - sprint close only finalizes the sprint. + exist, it archives only those compatibility files. 3. Promote durable Running Context to `_context.md`; retain the sprint file as history. -## Sync / Legacy Export — Explicit and Mode-Specific +## Sync / Legacy Export — Explicit and One-Way - **GitHub core:** there is no pull step. Re-run `effective-task-spec.js` when Issue content changes and review a changed source revision. - **GitHub rollback/diagnostics:** `sync-pull.js --legacy-export` explicitly writes non-authoritative projections. It is outside setup, orient, plan, work, and complete. -- **Local:** `backlog/local-tracker.json` is canonical and its task-file mirrors are refreshed on every mutation; there is no provider pull/push and no background sync. -- **Both:** an operation failure never changes `.tracker` or makes the other store authoritative. +- Compatible Markdown import is human-reviewed input to create or amend a + GitHub Issue; it is not a runtime read path. +- An operation failure never changes `.tracker` or makes an export authoritative. See `github-sync.md` for GitHub-only command patterns. @@ -145,8 +137,8 @@ effects and never switches trackers. ## Quick Fix — Single Task, No Sprint -Read, update, and close the task through the configured adapter. GitHub may use -its normal issue/closing behavior; local stays entirely in its canonical JSON store. +Read, update, and close the Issue through the adapter and normal GitHub +issue/closing behavior. Create a sprint only when execution context needs to span work or sessions. ## Unplanned Work — Mid-Sprint Scope Change @@ -158,5 +150,5 @@ Create a sprint only when execution context needs to span work or sessions. ## Next — What to Work On 1. Read the active sprint and find the first unchecked batch (`next.sh --track ` selects one track when a portfolio is active). -2. If it is done, list configured-tracker work or start the next sprint. +2. If it is done, list open Issues or start the next sprint. 3. Present the batch with its exact normalized refs and total estimate. diff --git a/skills/dev-backlog/references/scripts.md b/skills/dev-backlog/references/scripts.md index dbaecfa..32defa2 100644 --- a/skills/dev-backlog/references/scripts.md +++ b/skills/dev-backlog/references/scripts.md @@ -14,7 +14,7 @@ node "$skill_dir/scripts/sprint-init.js" "next-sprint" --dry-run ## Full inventory -- `scripts/setup-dev-backlog.js [project-name] [--tracker github|local] [--non-interactive] [--json]` — persist one canonical tracker; GitHub creates only `sprints/`, while local compatibility also creates projection directories. +- `scripts/setup-dev-backlog.js [project-name] [--tracker github] [--non-interactive] [--json]` — persist GitHub as the canonical task authority and create only `sprints/`. - `scripts/init.sh [project-name]` — bootstrap `backlog/` with `.tracker` and directories. - `scripts/tracker.js` — official programmatic core lifecycle boundary: resolve the configured adapter with `{ backlogDir }`, then call `list`, `read`, `create`, `update`, or `close` as documented in `process.md`. - `scripts/effective-task-spec.js TASK_REF [--repo OWNER/REPO] [--spec-ref PATH] [--backlog-dir PATH] [--root PATH]` — resolve the configured live task into effective spec, normalized AC/lifecycle, selected source, and stable SHA-256 revision/digest. A body marker `` or explicit flag selects a repository-relative spec; otherwise the canonical task body wins. Any authority/spec load failure stops without a task-mirror fallback. @@ -33,11 +33,10 @@ node "$skill_dir/scripts/sprint-init.js" "next-sprint" --dry-run ## Tracker routing `backlog/.tracker` is the runtime selection authority. When it is absent, a -legacy `tracker:` key read from `config.yml` is the compatibility fallback; -with neither, GitHub remains the deterministic default. Setup migrates a legacy -selection to `.tracker` without editing `config.yml`. GitHub mode uses `gh` and -treats task files as mirrors; local mode uses `local-tracker.json` as canonical -and derives the same task-file mirrors with zero provider calls. - -Detailed adapter mechanics and compatibility evidence are single-sourced in -[`docs/tracker-adapter-design.md`](../../../docs/tracker-adapter-design.md). +legacy `tracker: github` key read from `config.yml` is the compatibility +fallback; any other value fails. With neither, GitHub remains the deterministic +default. Setup pins the legacy GitHub selection to `.tracker` without editing +`config.yml`. Task files exist only as explicit one-way legacy exports. + +Retained seams and compatibility evidence are single-sourced in +[`docs/compatibility-subtraction.md`](../../../docs/compatibility-subtraction.md). diff --git a/skills/dev-backlog/scripts/authority-contract.test.js b/skills/dev-backlog/scripts/authority-contract.test.js index d24d882..e4fd9ae 100644 --- a/skills/dev-backlog/scripts/authority-contract.test.js +++ b/skills/dev-backlog/scripts/authority-contract.test.js @@ -86,7 +86,8 @@ it("keeps sprint admission and migration boundaries aligned across public docs", ); assert.match(readme, /Close the sprint explicitly only when a sprint was admitted/); - assert.match(readme, /`backlog\/local-tracker\.json` remains its sole task authority/); + assert.match(readme, /Backlog\.md compatibility is a one-way legacy boundary/); + assert.doesNotMatch(readme, /local-tracker\.json.*sole task authority/); assert.match(skill, /`objectives:`\/`component:` are present only when their backing spec files exist/); assert.match(skill, /legacy mirror may be inspected only as diagnostic\/rollback evidence/); assert.match(capabilities, /If that read fails, execution stops/); diff --git a/skills/dev-backlog/scripts/contract-prose.test.js b/skills/dev-backlog/scripts/contract-prose.test.js index c113a23..efda4aa 100644 --- a/skills/dev-backlog/scripts/contract-prose.test.js +++ b/skills/dev-backlog/scripts/contract-prose.test.js @@ -1,99 +1,53 @@ -// This is repo-local because the prose ships byte-identically to every consumer; -// running the same authoring check in backlog-doctor would add no coverage. - const { it } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); -const { STORE_FILE } = require("./local-tracker.js"); -const { TRACKER_SELECTION_FILE } = require("./tracker.js"); + const ROOT = path.resolve(__dirname, "../../.."); -const SURFACES = ["skills/dev-backlog/SKILL.md", "README.md", "spec/system-map.md"]; -const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -function contractBlock(file) { - const lines = fs.readFileSync(path.join(ROOT, file), "utf8").split(/\r?\n/); - let start = -1; - for (let index = 0; index < lines.length; index += 1) { - if (!/^```/.test(lines[index])) continue; - if (start < 0) { - start = index + 1; - continue; - } - const block = lines.slice(start, index); - if (/\bgithub\b/i.test(block.join("\n")) && /\blocal\b/i.test(block.join("\n"))) { - return block.map((text, offset) => ({ text, number: start + offset + 1 })); - } - start = -1; - } - assert.fail(`${file}:1: no delimited canonical-store contract block found`); -} +const SURFACES = [ + "skills/dev-backlog/SKILL.md", + "README.md", + "CLAUDE.md", + "spec/system-map.md", + "skills/dev-backlog/references/integration-contract.md", +]; -function diagnostic(file, line, fact) { - return `${file}:${line.number}: ${line.text.trim()}\ncontradicts code-derived fact: ${fact}`; -} -function requireLine(file, line, pattern, fact) { - assert.ok(pattern.test(line.text), diagnostic(file, line, fact)); -} -function modeSection(file, block, mode) { - const start = block.findIndex(({ text }) => new RegExp(`\\b${mode}\\b.*->`, "i").test(text)); - assert.notEqual(start, -1, diagnostic(file, block[0], `${mode} mode must name canonical truth`)); - const next = block.findIndex(({ text }, index) => index > start && /\b(?:github|local)\b.*->/i.test(text)); - return block.slice(start, next < 0 ? block.length : next); -} -function assertDerived(file, mode, section, block) { - const global = block.find(({ text }) => /derived .*mirrors? in both modes/i.test(text)); - const claim = section.find(({ text }) => /derived/i.test(text) && /tasks?|mirrors?/i.test(text)); - const offender = section.find(({ text }) => /tasks?|completed|mirrors?/i.test(text)) || section[0]; - assert.ok(global || claim, diagnostic( - file, offender, `task mirrors are derived in ${mode} mode` - )); -} -function assertMirrorlessGithub(file, section) { - const claim = section.find(({ text }) => - /(?:\bno\s+(?:required\s+)?task(?:-file)?\s+(?:mirror|directory)\b|\btask(?:-file)?\s+(?:mirror|directory)\s+(?:is\s+)?(?:not\s+)?required\b|\boptional\s+legacy\s+export\b)/i.test(text) - ); - assert.ok(claim, diagnostic( - file, - section[0], - "github mode must not require task mirrors", - )); -} -function assertNoCanonicalMirrors(file, block) { - const lines = fs.readFileSync(path.join(ROOT, file), "utf8").split(/\r?\n/); - const subject = "(?:task files?|(?:backlog/)?tasks/?(?:\\s*\\+\\s*(?:backlog/)?completed/?)?|mirrors?)"; - const forbidden = new RegExp( - `${subject}\\s+(?:(?:are|is)\\s+(?:the\\s+)?canonical|\\(canonical)`, "i" - ); - lines.forEach((text, index) => { - if (forbidden.test(text) || /task files?.*\bcanonical records?\b/i.test(text) || - /\bcanonical\s+(?:task files?|tasks|mirrors?)\b/i.test(text)) { - assert.fail(diagnostic(file, { text, number: index + 1 }, - `only GitHub Issues or backlog/${STORE_FILE} can be canonical task truth`)); - } - }); - const approved = new RegExp( - `(?:GitHub Issues|${escape(`backlog/${STORE_FILE}`)})\\s*\\(?canonical\\)?`, "ig"); - block.forEach((line) => { - const rest = line.text.replace(approved, ""); - if (/\bcanonical\b/i.test(rest) && !/sprints\/.*canonical execution hub/i.test(rest)) - assert.fail(diagnostic(file, line, "no other store can be canonical task truth")); +for (const file of SURFACES) { + it(`${file} states the standalone GitHub authority`, () => { + const markdown = fs.readFileSync(path.join(ROOT, file), "utf8"); + assert.match(markdown, /GitHub Issues[^\n]*(?:canonical|authority|source of truth)/i); + assert.match( + markdown, + /(?:no required task mirror|no task-file directory required|optional legacy export)/i, + ); + assert.doesNotMatch(markdown, /local-tracker\.json\s*\(canonical/i); + assert.doesNotMatch(markdown, /local-tracker\.js\s*->/i); }); } -function assertContract(file) { - const block = contractBlock(file); - const github = modeSection(file, block, "github"); - const local = modeSection(file, block, "local"); - requireLine(file, block[0], new RegExp(escape(`backlog/${TRACKER_SELECTION_FILE}`)), - `tracker selection lives in backlog/${TRACKER_SELECTION_FILE}`); - requireLine(file, github[0], /GitHub Issues.*canonical/i, - "GitHub Issues are canonical in github mode"); - requireLine(file, local[0], new RegExp(`${escape(`backlog/${STORE_FILE}`)}.*canonical`, "i"), - `backlog/${STORE_FILE} is canonical in local mode`); - assertMirrorlessGithub(file, github); - assertDerived(file, "local", local, block); - assertNoCanonicalMirrors(file, block); -} -for (const file of SURFACES) { - it(`${file} matches canonical-store code`, () => assertContract(file)); -} +it("records compatibility subtraction in current decisions and release notes", () => { + const read = (file) => fs.readFileSync(path.join(ROOT, file), "utf8"); + const capabilities = read("spec/capabilities.md"); + const charter = read("spec/charter.md"); + const changelog = read("CHANGELOG.md").split("## [0.9.0]")[0]; + + assert.match(capabilities, /Remove the zero-adopter local tracker/); + assert.match(capabilities, /2026-07-26 local JSON authority/); + assert.match(charter, /Remove the zero-adopter local tracker/); + assert.match(changelog, /Required task mirrors/); + assert.match(changelog, /Zero-adopter local tracker/); + assert.doesNotMatch(changelog, /task mirrors under `backlog\/tasks\/` remain core/); +}); + +it("keeps the actor contract GitHub-only while preserving historical ref parsing", () => { + const markdown = fs.readFileSync( + path.join(ROOT, "skills/dev-backlog/references/integration-contract.md"), + "utf8", + ); + assert.match(markdown, /accepts exactly one runtime authority: `github`/); + assert.match(markdown, /Historical compatibility only:/); + assert.match(markdown, /does not\s+make `local` a valid `\.tracker` selection/); + assert.doesNotMatch(markdown, /Local Plan items use/); + assert.doesNotMatch(markdown, /"tracker": "local",\s*\n\s*"capability"/); + assert.doesNotMatch(markdown, /explicitly change backlog\/\.tracker to a tracker/); +}); diff --git a/skills/dev-backlog/scripts/github-capabilities.test.js b/skills/dev-backlog/scripts/github-capabilities.test.js index 6115a11..e217358 100644 --- a/skills/dev-backlog/scripts/github-capabilities.test.js +++ b/skills/dev-backlog/scripts/github-capabilities.test.js @@ -3,7 +3,6 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); -const { spawnSync } = require("node:child_process"); const { spawnBashSync } = require("./bash-runtime.js"); const { @@ -11,10 +10,8 @@ const { getMilestoneDue, getMilestoneIssues, } = require("./github-milestones.js"); -const { loadOpenIssues } = require("./sync-pull.js"); -const { createSprintFile } = require("./sprint-init.js"); const { listStatusRows } = require("./tracker-status-list.js"); -const { createLocalAdapter } = require("./local-tracker.js"); +const { createSprintFile } = require("./sprint-init.js"); const { collectSnapshot } = require("../../backlog-triage/scripts/triage-collect.js"); const { execute: applyTriage } = require("../../backlog-triage/scripts/triage-apply.js"); @@ -59,7 +56,7 @@ describe("GitHub optional capability transports", () => { assert.equal(calls[2].options.env.MS, "Batch 4"); }); - it("preserves the human status list argv and row bytes through configured resolution", () => { + it("preserves human status-list argv and row bytes through configured resolution", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "github-status-list-")); const backlogDir = path.join(root, "backlog"); fs.mkdirSync(backlogDir); @@ -78,111 +75,75 @@ describe("GitHub optional capability transports", () => { "--json", "number,title,labels,milestone", ]); }); - - it("renders normalized refs from a configured custom local store in human status", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "local-status-list-")); - const backlogDir = path.join(root, "custom-store"); - const binDir = path.join(root, "bin"); - fs.mkdirSync(path.join(backlogDir, "tasks"), { recursive: true }); - fs.mkdirSync(path.join(backlogDir, "completed")); - fs.mkdirSync(binDir); - fs.writeFileSync( - path.join(backlogDir, "config.yml"), - "tracker: local\ntask_prefix: BACK\n" - ); - createLocalAdapter({ backlogDir }).create({ - id: "7.2", - title: "Custom store task", - body: "Custom local task", - labels: ["offline"], - }); - const columnPath = path.join(binDir, "column"); - fs.writeFileSync(columnPath, "#!/bin/sh\ncat\n"); - fs.chmodSync(columnPath, 0o755); - - const result = spawnBashSync([path.join(__dirname, "status.sh"), backlogDir], { - cwd: root, - env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH || ""}` }, - encoding: "utf8", - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /=== Tracker Tasks ===/); - assert.match(result.stdout, /^BACK-7\.2\s+-\s+Custom store task\s+offline$/m); - assert.doesNotMatch(result.stdout, /=== GitHub Issues ===/); - }); }); -describe("configured-only failure before effects", () => { - it("never executes GitHub or writes sprint state for explicit local", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "github-capability-gate-")); +describe("retired tracker public mutation boundaries", () => { + it("rejects local config before sprint-init or triage provider/filesystem effects", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "retired-tracker-mutations-")); const backlogDir = path.join(root, "backlog"); fs.mkdirSync(backlogDir); - fs.writeFileSync(path.join(backlogDir, "config.yml"), "tracker: local\n"); - let executions = 0; - const execFile = () => { - executions += 1; - throw new Error("must not execute"); + fs.writeFileSync(path.join(backlogDir, ".tracker"), "local\n"); + let providerCalls = 0; + const provider = () => { + providerCalls += 1; + throw new Error("provider must not execute"); }; - // Optional-capability GitHub flows stay fail-closed for explicit local. assert.throws(() => createSprintFile({ topic: "blocked", milestone: "blocked", - dryRun: false, sprintsDir: path.join(backlogDir, "sprints"), - }), /local/); + }), /expected one of: github/); + await assert.rejects( - collectSnapshot({ repo: "acme/widgets", trackerConfig: { tracker: "local" }, execFile }), - /local/ + collectSnapshot({ + repo: "acme/widgets", + trackerConfig: { tracker: "local" }, + execFile: provider, + }), + /expected one of: github/ ); - // Required-op flows resolve the local adapter (post-#276) rather than - // GitHub. They must never execute gh or fall back to the GitHub transport. - loadOpenIssues({ config: { tracker: "local" }, execFile }); - const report = path.join(root, "report.md"); fs.writeFileSync(report, [ - "---", "generated: 2026-07-11", "---", "", + "---", "generated: 2026-07-31", "---", "", '', "- [x] revisit", "", ].join("\n")); const applied = applyTriage([report, "--apply", "--yes"], { cwd: root, trackerConfig: { tracker: "local" }, - runGh: () => { - executions += 1; - return { status: 0, stdout: "", stderr: "" }; - }, + runGh: provider, }); - assert.equal(applied.exitCode, 0); - assert.equal(executions, 0); + assert.equal(applied.exitCode, 1); + assert.match(applied.error, /expected one of: github/); + assert.equal(providerCalls, 0); assert.equal(fs.existsSync(path.join(backlogDir, "sprints")), false); - assert.equal(fs.existsSync(path.join(backlogDir, "triage", "2026-07-11-apply.log")), false); + assert.equal(fs.existsSync(path.join(backlogDir, "triage")), false); }); - it("gates milestone close before doctor or sprint-file mutation", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "github-close-gate-")); + it("rejects local selection before milestone close or sprint mutation", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "retired-tracker-close-")); const backlogDir = path.join(root, "backlog"); const sprintsDir = path.join(backlogDir, "sprints"); fs.mkdirSync(sprintsDir, { recursive: true }); - fs.writeFileSync(path.join(backlogDir, "config.yml"), "tracker: local\n"); + fs.writeFileSync(path.join(backlogDir, ".tracker"), "local\n"); const sprintPath = path.join(sprintsDir, "active.md"); fs.writeFileSync(sprintPath, [ "---", "milestone: Batch 4", "status: active", "---", "", - "## Plan", "- [x] #275 Adapter", "", "## Running Context", "", "## Progress", "", + "## Plan", "- [x] #275 Adapter", "", + "## Running Context", "", "## Progress", "", ].join("\n")); + const before = fs.readFileSync(sprintPath, "utf8"); const result = spawnBashSync([ path.join(__dirname, "sprint-close.sh"), backlogDir, "--close-milestone", - ], { encoding: "utf8" }); + ], { cwd: root, encoding: "utf8" }); - assert.equal(result.status, 1); - assert.match(`${result.stdout}${result.stderr}`, /local/); - assert.match(fs.readFileSync(sprintPath, "utf8"), /^status: active$/m); - assert.doesNotMatch(fs.readFileSync(sprintPath, "utf8"), /Sprint closed/); + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}${result.stderr}`, /expected one of: github/); + assert.equal(fs.readFileSync(sprintPath, "utf8"), before); }); - }); diff --git a/skills/dev-backlog/scripts/github-tracker.test.js b/skills/dev-backlog/scripts/github-tracker.test.js index 931860d..4d7b158 100644 --- a/skills/dev-backlog/scripts/github-tracker.test.js +++ b/skills/dev-backlog/scripts/github-tracker.test.js @@ -170,28 +170,22 @@ describe("GitHub required lifecycle adapter", () => { assert.deepEqual(closed, identity); }); - it("resolves only the configured adapter and never falls back after transport failure", () => { - let localReads = 0; + it("propagates configured GitHub transport failure without retrying another provider", () => { const { execFile } = recordingExec([new Error("gh transport failed")]); const github = createGithubAdapter({ execFile }); - const local = { - ...TRACKER_ADAPTERS.local, - availability: () => { - localReads += 1; - return { available: true }; - }, - }; - const resolved = resolveTracker({ tracker: "github" }, { adapters: { github, local } }); + const resolved = resolveTracker({ tracker: "github" }, { adapters: { github } }); assert.throws(() => resolved.adapter.list({ limit: 1 }), /gh transport failed/); - assert.equal(localReads, 0); }); it("fails an unsupported capability before the supplied mutation", () => { let mutations = 0; const resolved = { - tracker: "local", - adapter: TRACKER_ADAPTERS.local, + tracker: "github", + adapter: { + ...TRACKER_ADAPTERS.github, + capabilities: () => [], + }, }; assert.throws( @@ -200,7 +194,7 @@ describe("GitHub required lifecycle adapter", () => { }), (error) => { assert.ok(error instanceof UnsupportedTrackerCapabilityError); - assert.equal(error.tracker, "local"); + assert.equal(error.tracker, "github"); assert.equal(error.capability, "milestones"); return true; } diff --git a/skills/dev-backlog/scripts/local-tracker.integration.test.js b/skills/dev-backlog/scripts/local-tracker.integration.test.js deleted file mode 100644 index b5fb9ec..0000000 --- a/skills/dev-backlog/scripts/local-tracker.integration.test.js +++ /dev/null @@ -1,336 +0,0 @@ -const { describe, it } = require("node:test"); -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { spawn, spawnSync } = require("node:child_process"); - -const { resolveConfiguredTracker, invokeCapability, CAPABILITY_NAMES } = require("./tracker.js"); -const { LocalStoreError } = require("./local-tracker.js"); -const { readSprintState, findNextBatch } = require("./sprint-state.js"); - -const SCRIPTS_DIR = __dirname; -const LOCAL_TRACKER_PATH = path.join(SCRIPTS_DIR, "local-tracker.js"); - -function makeOfflineStore(t) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "local-integration-")); - const backlogDir = path.join(root, "backlog"); - fs.mkdirSync(path.join(backlogDir, "tasks"), { recursive: true }); - fs.mkdirSync(path.join(backlogDir, "completed"), { recursive: true }); - fs.mkdirSync(path.join(backlogDir, "sprints"), { recursive: true }); - fs.writeFileSync( - path.join(backlogDir, "config.yml"), - 'tracker: local\ntask_prefix: "BACK"\ndefault_status: "To Do"\nstatuses: ["To Do", "In Progress", "Done"]\n' - ); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); - return { root, backlogDir }; -} - -function installFailingGh(t, root) { - const binDir = path.join(root, "bin"); - fs.mkdirSync(binDir, { recursive: true }); - const marker = path.join(root, "gh-invoked"); - fs.writeFileSync( - path.join(binDir, "gh"), - `#!/bin/sh\necho "gh $*" >> "${marker}"\nexit 97\n` - ); - fs.chmodSync(path.join(binDir, "gh"), 0o755); - const envPath = `${binDir}:${process.env.PATH}`; - return { marker, envPath }; -} - -function localAdapter(backlogDir) { - return resolveConfiguredTracker({ tracker: "local" }, { backlogDir }); -} - -function canonical(backlogDir) { - return JSON.parse(fs.readFileSync(path.join(backlogDir, "local-tracker.json"), "utf8")); -} - -function strays(backlogDir) { - const result = []; - function walk(dir) { - for (const name of fs.readdirSync(dir)) { - const full = path.join(dir, name); - const stat = fs.lstatSync(full); - if (stat.isDirectory()) walk(full); - else if (name.endsWith(".tmp") || name.startsWith(".local-tracker.revision-")) { - result.push(full); - } - } - } - walk(backlogDir); - return result; -} - -async function runConcurrentWriters(root, backlogDir, operations) { - const worker = path.join(root, `concurrent-worker-${Date.now()}.js`); - const gateDir = path.join(root, `concurrent-gate-${Date.now()}`); - const renameDir = path.join(gateDir, "rename-ready"); - fs.mkdirSync(renameDir, { recursive: true }); - fs.writeFileSync( - worker, - [ - "const fs = require('node:fs');", - "const path = require('node:path');", - `const { createLocalAdapter } = require(${JSON.stringify(LOCAL_TRACKER_PATH)});`, - "const [backlogDir, ready, go, renameReady, renameDir, count, json] = process.argv.slice(2);", - "const wait = new Int32Array(new SharedArrayBuffer(4));", - "fs.writeFileSync(ready, 'ready');", - "while (!fs.existsSync(go)) Atomics.wait(wait, 0, 0, 5);", - "const adapter = createLocalAdapter({ backlogDir, testHooks: {", - " linkRevision: process.env.NAIVE_RMW ?", - " (tmp) => fs.renameSync(`${tmp}.naive`, path.join(backlogDir, 'local-tracker.json')) : undefined,", - " beforeRevisionClaim(tmp) {", - " if (process.env.NAIVE_RMW) fs.copyFileSync(tmp, `${tmp}.naive`);", - " fs.writeFileSync(renameReady, 'ready');", - " const deadline = Date.now() + 5000;", - " while (fs.readdirSync(renameDir).length < Number(count)) {", - " if (Date.now() > deadline) throw new Error('revision barrier timed out');", - " Atomics.wait(wait, 0, 0, 5);", - " }", - " Atomics.wait(wait, 0, 0, 50);", - " },", - "} });", - "const operation = JSON.parse(json);", - "if (operation.kind === 'create') adapter.create(operation.input);", - "else adapter.update(operation.selector, operation.changes);", - ].join("\n") - ); - const go = path.join(gateDir, "go"); - const children = operations.map((operation, index) => { - const ready = path.join(gateDir, `ready-${index}`); - const renameReady = path.join(renameDir, String(index)); - const child = spawn( - process.execPath, - [ - worker, backlogDir, ready, go, renameReady, renameDir, - String(operations.length), JSON.stringify(operation), - ], - { stdio: ["ignore", "ignore", "pipe"] } - ); - return { child, ready }; - }); - const started = Date.now(); - while (!children.every(({ ready }) => fs.existsSync(ready))) { - if (Date.now() - started > 5000) throw new Error("concurrent writer start barrier timed out"); - await new Promise((resolve) => setTimeout(resolve, 5)); - } - const completions = children.map(({ child }) => new Promise((resolve, reject) => { - let stderr = ""; - child.stderr.on("data", (chunk) => { stderr += chunk; }); - child.on("error", reject); - child.on("close", (code) => resolve(code === 0 ? null : `exit ${code}: ${stderr}`)); - })); - fs.writeFileSync(go, "go"); - return (await Promise.all(completions)).filter(Boolean); -} - -describe("offline local core sprint cycle", () => { - it("runs create → Plan → status/next → read → work-state update → close/archive with no gh", (t) => { - const { root, backlogDir } = makeOfflineStore(t); - const { marker, envPath } = installFailingGh(t, root); - const { adapter } = localAdapter(backlogDir); - - const first = adapter.create({ title: "Design offline adapter" }); - const second = adapter.create({ - title: "Prove core cycle", - body: "Human body\n\n## Acceptance Criteria\n- [ ] Keep this AC", - }); - const third = adapter.create({ title: "Archive on close" }); - assert.deepEqual([first, second, third].map((task) => task.ref), [ - "BACK-1", - "BACK-2", - "BACK-3", - ]); - assert.equal([first, second, third].every((task) => !("url" in task)), true); - - fs.writeFileSync( - path.join(backlogDir, "sprints", "cycle.md"), - [ - "---", "milestone: local cycle", "status: active", "started: 2026-07-11", "---", "", - "# Offline Local Cycle", "", "## Goal", "Prove the offline local core cycle.", "", - "## Plan", "", "### Batch 1 - core", - `- [ ] ${first.ref} Design offline adapter`, - `- [ ] ${second.ref} Prove core cycle`, - `- [ ] ${third.ref} Archive on close`, - "", "## Running Context", "", "## Progress", "", - ].join("\n") - ); - - const state = readSprintState({ backlogDir }); - assert.equal(state.active_sprint.frontmatter.milestone, "local cycle"); - assert.deepEqual(state.plan_items.map((item) => item.ref), ["BACK-1", "BACK-2", "BACK-3"]); - assert.equal( - state.plan_items.every((item) => item.tracker === "local" && item.issue_number === null), - true - ); - assert.deepEqual(findNextBatch(state.plan_items).items.map((item) => item.ref), [ - "BACK-1", - "BACK-2", - "BACK-3", - ]); - - const oriented = spawnSync( - process.execPath, - [path.join(SCRIPTS_DIR, "sprint-state.js"), "--mode", "next", backlogDir, "--json"], - { encoding: "utf8", env: { ...process.env, PATH: envPath } } - ); - assert.equal(oriented.status, 0); - assert.deepEqual(JSON.parse(oriented.stdout).plan_items.map((item) => item.ref), [ - "BACK-1", - "BACK-2", - "BACK-3", - ]); - - const body = adapter.read(second).body; - adapter.update(second, { status: "In Progress" }); - assert.equal(adapter.read(second).body, body); - assert.match( - fs.readFileSync(path.join(backlogDir, "tasks", "BACK-2 - prove-core-cycle.md"), "utf8"), - /^status: In Progress$/m - ); - - adapter.close(second); - assert.deepEqual(adapter.list().map((task) => task.ref), ["BACK-1", "BACK-3"]); - assert.deepEqual(adapter.list({ state: "closed" }).map((task) => task.ref), ["BACK-2"]); - assert.match( - fs.readFileSync(path.join(backlogDir, "completed", "BACK-2 - prove-core-cycle.md"), "utf8"), - /Keep this AC/ - ); - assert.equal(canonical(backlogDir).tasks.length, 3); - assert.equal(fs.existsSync(marker), false); - }); - - it("fails every optional capability before mutation and never falls back", (t) => { - const { backlogDir } = makeOfflineStore(t); - const resolved = localAdapter(backlogDir); - assert.deepEqual(resolved.adapter.capabilities(), []); - for (const capability of CAPABILITY_NAMES) { - let mutated = false; - assert.throws( - () => invokeCapability(resolved, capability, () => { - mutated = true; - }), - (error) => error.tracker === "local" && error.capability === capability - ); - assert.equal(mutated, false); - } - }); - - it("handles malformed input, exact active/completed collisions, and decimal identities", (t) => { - const { backlogDir } = makeOfflineStore(t); - const { adapter } = localAdapter(backlogDir); - assert.throws(() => adapter.create({ title: "" }), LocalStoreError); - assert.throws(() => adapter.create({}), LocalStoreError); - - assert.equal(adapter.create({ id: "1", title: "One" }).id, "1"); - assert.equal(adapter.create({ id: "11", title: "Eleven" }).id, "11"); - adapter.close("BACK-11"); - assert.equal(adapter.create({ title: "Next parent" }).id, "12"); - const sub = adapter.create({ title: "Decimal child", id: "1.2" }); - assert.deepEqual(sub, { tracker: "local", id: "1.2", ref: "BACK-1.2" }); - assert.throws(() => adapter.create({ title: "Dup completed", id: "11" }), /already exists/); - assert.throws(() => adapter.create({ title: "Dup decimal", id: "1.2" }), /already exists/); - }); -}); - -describe("offline local close crash recovery", () => { - it("a writer killed after claiming a revision cannot wedge the next mutation", (t) => { - const { root, backlogDir } = makeOfflineStore(t); - const { adapter } = localAdapter(backlogDir); - adapter.create({ title: "One", body: "Keep me" }); - - const worker = path.join(root, "crash-worker.js"); - fs.writeFileSync( - worker, - [ - `const { createLocalAdapter } = require(${JSON.stringify(LOCAL_TRACKER_PATH)});`, - "const [backlogDir] = process.argv.slice(2);", - "const adapter = createLocalAdapter({", - " backlogDir,", - " testHooks: { afterRevisionClaim() { process.exit(99); } },", - "});", - "adapter.close('BACK-1');", - ].join("\n") - ); - const crashed = spawnSync(process.execPath, [worker, backlogDir], { encoding: "utf8" }); - assert.equal(crashed.status, 99); - - const beforeRecovery = canonical(backlogDir); - assert.equal(beforeRecovery.revision, 1, "the killed writer did not reach its store rename"); - assert.equal(beforeRecovery.tasks[0].state, "open"); - assert.equal( - strays(backlogDir).some((file) => file.endsWith(".local-tracker.revision-2.json")), - true, - "the child died after the no-overwrite claim and before the store rename" - ); - - const recovered = adapter.create({ title: "After crash" }); - assert.equal(recovered.id, "2"); - const stored = canonical(backlogDir); - assert.equal(stored.revision, 3); - assert.equal(stored.tasks.length, 2); - assert.equal(stored.tasks[0].id, "1"); - assert.equal(stored.tasks[0].state, "closed"); - assert.equal(stored.tasks[0].status, "Done"); - assert.equal(adapter.read("BACK-1").state, "closed"); - assert.equal(adapter.read("BACK-2").title, "After crash"); - assert.deepEqual(fs.readdirSync(path.join(backlogDir, "tasks")), ["BACK-2 - after-crash.md"]); - assert.deepEqual(fs.readdirSync(path.join(backlogDir, "completed")), ["BACK-1 - one.md"]); - assert.match( - fs.readFileSync(path.join(backlogDir, "completed", "BACK-1 - one.md"), "utf8"), - /Keep me/ - ); - assert.deepEqual(strays(backlogDir), []); - }); -}); - -describe("offline concurrent mutations preserve canonical tasks", () => { - it("parallel creates all survive with distinct ids above active and completed tasks", async (t) => { - const { root, backlogDir } = makeOfflineStore(t); - const adapter = localAdapter(backlogDir).adapter; - adapter.create({ id: "1", title: "Active floor" }); - adapter.create({ id: "11", title: "Completed floor" }); - adapter.close("BACK-11"); - const titles = ["Create alpha", "Create beta"]; - const failures = await runConcurrentWriters( - root, - backlogDir, - titles.map((title) => ({ kind: "create", input: { title } })) - ); - assert.deepEqual(failures, []); - const stored = canonical(backlogDir); - const created = stored.tasks.filter((task) => titles.includes(task.title)); - assert.equal(created.length, titles.length); - assert.deepEqual(new Set(created.map((task) => task.id)).size, titles.length); - assert.deepEqual(created.map((task) => task.id).sort(), ["12", "13"]); - assert.equal(stored.tasks.find((task) => task.id === "11").state, "closed"); - assert.deepEqual(strays(backlogDir), []); - }); - - it("parallel updates to different tasks both persist on every platform", async (t) => { - const { root, backlogDir } = makeOfflineStore(t); - const adapter = localAdapter(backlogDir).adapter; - adapter.create({ title: "One" }); - adapter.create({ title: "Two" }); - const failures = await runConcurrentWriters(root, backlogDir, [ - { - kind: "update", - selector: "BACK-1", - changes: { title: "One updated", priority: "high" }, - }, - { - kind: "update", - selector: "BACK-2", - changes: { labels: ["updated-two"], status: "In Progress" }, - }, - ]); - assert.deepEqual(failures, []); - assert.equal(adapter.read("BACK-1").title, "One updated"); - assert.equal(adapter.read("BACK-1").priority, "high"); - assert.deepEqual(adapter.read("BACK-2").labels, ["updated-two"]); - assert.equal(adapter.read("BACK-2").status, "In Progress"); - assert.deepEqual(strays(backlogDir), []); - }); -}); diff --git a/skills/dev-backlog/scripts/local-tracker.js b/skills/dev-backlog/scripts/local-tracker.js deleted file mode 100644 index cbf502f..0000000 --- a/skills/dev-backlog/scripts/local-tracker.js +++ /dev/null @@ -1,598 +0,0 @@ -/** - * Local JSON storage substrate for the required tracker lifecycle. - * - * `backlog/local-tracker.json` is the only task authority. Markdown below - * `backlog/tasks/` and `backlog/completed/` is a derived, one-way projection - * with the same shape as GitHub issue mirrors. No lifecycle operation parses a - * mirror back into the store. - */ - -const fs = require("fs"); -const path = require("path"); -const crypto = require("crypto"); - -const { readConfig, slugify, escapeYaml } = require("./lib.js"); -const { parseTaskRef, parseTaskFileName } = require("./task-ref.js"); - -const STORE_FILE = "local-tracker.json"; -const TASKS_DIR = "tasks"; -const COMPLETED_DIR = "completed"; -const DONE_STATUS = "Done"; -const STORE_VERSION = 1; -const CAS_RETRIES = 100; -const REVISION_TEMP_RE = /^\.local-tracker\.revision-(\d+)\..+\.tmp$/; - -const CREATE_OPTIONS = Object.freeze([ - "title", "id", "body", "status", "labels", "priority", "dependencies", -]); -const UPDATE_OPTIONS = Object.freeze([ - "title", "status", "priority", "labels", "dependencies", "body", "updated_date", -]); -const LIST_STATES = Object.freeze(["open", "closed", "all"]); -const SCALAR_FIELDS = Object.freeze([ - "title", "status", "priority", "milestone", "created_date", "updated_date", -]); -const LIST_FIELDS = Object.freeze(["labels", "dependencies"]); -const CONTROL_CHAR_RE = /[\x00-\x1F\x7F]/; -class LocalStoreError extends Error { - constructor(message, options) { - super(message, options); - this.name = "LocalStoreError"; - this.tracker = "local"; - } -} -function rejectUnsupportedOptions(operation, options, allowed) { - if (options === null || typeof options !== "object" || Array.isArray(options)) return; - const extra = Object.keys(options).filter((key) => !allowed.includes(key)); - if (extra.length) { - throw new LocalStoreError( - `local ${operation} does not support option${extra.length === 1 ? "" : "s"}: ` + - `${extra.join(", ")}. The local tracker reports no optional capabilities, so ` + - "provider-specific fields must be handled before dispatch." - ); - } -} -function taskPrefixIssue(prefix) { - if (typeof prefix !== "string" || !prefix.length) return "task_prefix must be a non-empty string"; - if (/[\s/\\\0]/.test(prefix)) return ( - `task_prefix ${JSON.stringify(prefix)} must not contain whitespace, path separators, or NUL` - ); - if (prefix.includes("..")) return ( - `task_prefix ${JSON.stringify(prefix)} must not contain traversal segments` - ); - return null; -} -function assertNoControlChars(field, value) { - if (CONTROL_CHAR_RE.test(value)) { - throw new LocalStoreError( - `local ${field} must not contain newlines or control characters; ` + - "they would inject mirror frontmatter or corrupt task metadata" - ); - } -} -function cleanScalar(field, value, { nonEmpty = false } = {}) { - const text = String(value); - assertNoControlChars(field, text); - if (nonEmpty && !text.trim()) { - throw new LocalStoreError(`local ${field} must be a non-empty scalar`); - } - return text; -} -function cleanList(field, value) { - if (!Array.isArray(value)) throw new LocalStoreError(`local ${field} must be an array`); - return value.map((item) => cleanScalar(`${field} item`, item)); -} -function emptyStore() { return { version: STORE_VERSION, revision: 0, tasks: [] }; } -function compareByParentThenSub(left, right) { - const key = (id) => { - const [parent, sub] = id.split("."); - return [BigInt(parent), sub === undefined ? -1n : BigInt(sub)]; - }; - const [lp, ls] = key(left.id); - const [rp, rs] = key(right.id); - if (lp !== rp) return lp < rp ? -1 : 1; - if (ls !== rs) return ls < rs ? -1 : 1; - return 0; -} -function createLocalAdapter(options = {}) { - const backlogDir = options.backlogDir; - const now = options.now || (() => new Date()); - const config = options.config || readConfig(backlogDir); - const taskPrefix = config.task_prefix ?? "BACK"; - const defaultStatus = config.default_status ?? "To Do"; - const prefixIssue = taskPrefixIssue(taskPrefix); - const testHooks = options.testHooks || {}; - const refOptions = { taskPrefix }; - const storePath = path.join(backlogDir || "", STORE_FILE); - const requestedRetries = options.cas?.retries; - const casRetries = Number.isSafeInteger(requestedRetries) - ? Math.max(0, Math.min(requestedRetries, CAS_RETRIES)) : CAS_RETRIES; - function identityForId(id) { return parseTaskRef(`${taskPrefix}-${id}`, refOptions); } - function assertUsablePrefix() { - if (prefixIssue) throw new LocalStoreError(`local tracker ${prefixIssue}`); - } - function resolveIdentity(selector) { - if (selector && typeof selector === "object" && !Array.isArray(selector)) { - if (selector.tracker !== "local") { - throw new LocalStoreError("local read/update/close requires a local task identity"); - } - const identity = identityForId(String(selector.id)); - if (!identity) throw new LocalStoreError(`invalid local task id: ${String(selector.id)}`); - if (selector.ref !== undefined && selector.ref !== identity.ref) { - throw new LocalStoreError(`local task ref ${selector.ref} does not match ${identity.ref}`); - } - return identity; - } - if (typeof selector === "string") { - const byRef = parseTaskRef(selector, refOptions); - if (byRef?.tracker === "local") return byRef; - const byId = identityForId(selector); - if (byId) return byId; - } - throw new LocalStoreError(`unresolved local task selector: ${String(selector)}`); - } - function pathIssue(target, kind) { - let stat; - try { - stat = fs.lstatSync(target); - } catch (error) { - if (error.code === "ENOENT") return null; - return `local ${kind} path ${target} is unusable: ${error.message}`; - } - if (stat.isSymbolicLink()) return `local ${kind} path ${target} must not be a symlink`; - if (kind === "store" && !stat.isFile()) return `local store path ${target} is not a regular file`; - if (kind !== "store" && !stat.isDirectory()) return `local ${kind} path ${target} is not a directory`; - return null; - } - function canonicalPathIssue() { - if (typeof backlogDir !== "string" || !backlogDir.trim()) { - return "local tracker backlogDir is not configured"; - } - for (const [target, kind] of [ - [backlogDir, "backlog"], - [path.join(backlogDir, TASKS_DIR), TASKS_DIR], - [path.join(backlogDir, COMPLETED_DIR), COMPLETED_DIR], - [storePath, "store"], - ]) { - const issue = pathIssue(target, kind); - if (issue) return issue; - } - return null; - } - function assertCanonicalPaths() { - const issue = canonicalPathIssue(); - if (issue) throw new LocalStoreError(issue); - } - function validateRecord(record, seen) { - if (!record || typeof record !== "object" || Array.isArray(record)) { - throw new LocalStoreError("local JSON store is malformed: every task must be an object"); - } - if (typeof record.id !== "string" || !identityForId(record.id)) { - throw new LocalStoreError(`local JSON store has an invalid task id: ${String(record.id)}`); - } - if (seen.has(record.id)) { - throw new LocalStoreError( - `local JSON store is corrupt: ${taskPrefix}-${record.id} appears more than once` - ); - } - seen.add(record.id); - if (!["open", "closed"].includes(record.state)) { - throw new LocalStoreError( - `local task ${taskPrefix}-${record.id} has invalid state ${JSON.stringify(record.state)}` - ); - } - if (typeof record.body !== "string") { - throw new LocalStoreError(`local task ${taskPrefix}-${record.id} has a non-string body`); - } - for (const field of SCALAR_FIELDS) { - if (typeof record[field] !== "string") { - throw new LocalStoreError( - `local task ${taskPrefix}-${record.id} has a non-string ${field}` - ); - } - cleanScalar(field, record[field], { nonEmpty: field === "title" || field === "status" }); - } - for (const field of LIST_FIELDS) cleanList(field, record[field]); - return record; - } - function validateStore(store) { - if (!store || typeof store !== "object" || Array.isArray(store)) { - throw new LocalStoreError("local JSON store is malformed: expected an object"); - } - if ( - store.version !== STORE_VERSION || - !Number.isSafeInteger(store.revision) || - store.revision < 0 || - !Array.isArray(store.tasks) - ) { - throw new LocalStoreError( - `local JSON store is malformed: expected version ${STORE_VERSION}, a non-negative ` + - "integer revision, and a tasks array" - ); - } - const seen = new Set(); - for (const record of store.tasks) validateRecord(record, seen); - return store; - } - function readStore() { - assertCanonicalPaths(); - let raw; - try { - raw = fs.readFileSync(storePath, "utf8"); - } catch (error) { - if (error.code === "ENOENT") return emptyStore(); - throw new LocalStoreError(`cannot read local JSON store: ${error.message}`, { cause: error }); - } - try { - return validateStore(JSON.parse(raw)); - } catch (error) { - if (error instanceof LocalStoreError) throw error; - throw new LocalStoreError(`local JSON store is malformed: ${error.message}`, { cause: error }); - } - } - function tempPath(dir, label) { - return path.join( - dir, - `.local-tracker.${process.pid}.${label}.${crypto.randomBytes(8).toString("hex")}.tmp` - ); - } - function writeCompleteTemp(tmp, content, hook) { - if (hook) return hook(tmp, content); - const fd = fs.openSync(tmp, "wx", 0o666); - try { - fs.writeFileSync(fd, content, "utf8"); - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } - } - function fsyncDirectory(dir, target) { - if (testHooks.beforeDirectoryFsync) testHooks.beforeDirectoryFsync(dir, target); - let fd; - try { - fd = fs.openSync(dir, "r"); - fs.fsyncSync(fd); - } catch (error) { - // Windows and some filesystems refuse directory handles/fsync. The rename - // is still atomic there, so degrade durability rather than fail the write. - if (!["EACCES", "EBADF", "EISDIR", "EINVAL", "ENOSYS", "ENOTSUP", "EPERM"].includes(error.code)) throw error; - } finally { - try { if (fd !== undefined) fs.closeSync(fd); } catch {} - } - } - function replaceAtomic(target, content, hookName) { - const dir = path.dirname(target); - const issue = pathIssue(dir, path.basename(dir)); - if (issue) throw new LocalStoreError(issue); - fs.mkdirSync(dir, { recursive: true }); - const tmp = tempPath(dir, path.basename(target).replace(/[^A-Za-z0-9.-]/g, "_")); - try { - writeCompleteTemp(tmp, content, testHooks[hookName]); - if (hookName === "writeMirrorTemp" && testHooks.beforeMirrorRename) { - testHooks.beforeMirrorRename(tmp, target); - } - fs.renameSync(tmp, target); - fsyncDirectory(dir, target); - } catch (error) { - throw error instanceof LocalStoreError - ? error - : new LocalStoreError(`cannot atomically replace ${target}: ${error.message}`, { cause: error }); - } finally { - try { - fs.unlinkSync(tmp); - } catch { - // A successful rename consumed the temp; a failed partial write is cleaned. - } - } - } - function revisionPath(revision) { - return path.join(backlogDir, `.local-tracker.revision-${revision}.json`); - } - - function revisionTempPath(revision) { - const token = crypto.randomBytes(8).toString("hex"); - return path.join(backlogDir, `.local-tracker.revision-${revision}.${process.pid}.${token}.tmp`); - } - function finishClaim(claimPath, revision) { - const currentRevision = readStore().revision; - if (currentRevision !== revision - 1) return; - try { fs.renameSync(claimPath, storePath); fsyncDirectory(backlogDir, storePath); } - catch (error) { - if (error.code !== "ENOENT") throw error; - // The claimant or another helper already completed this exact revision. - } - } - function cleanRevisionDebris(currentRevision) { - for (const name of fs.readdirSync(backlogDir)) { - const match = name.match(REVISION_TEMP_RE); - if (!match || Number(match[1]) > currentRevision) continue; - try { fs.unlinkSync(path.join(backlogDir, name)); } - catch (error) { - if (!["ENOENT", "EBUSY", "EPERM"].includes(error.code)) throw error; - } - } - } - function publishStore(store, baseRevision) { - validateStore(store); - assertCanonicalPaths(); - fs.mkdirSync(backlogDir, { recursive: true }); - const revision = baseRevision + 1; - const claimPath = revisionPath(revision); - const tmp = revisionTempPath(revision); - const content = `${JSON.stringify(store, null, 2)}\n`; - try { - writeCompleteTemp(tmp, content, testHooks.writeStoreTemp); - if (testHooks.beforeRevisionClaim) testHooks.beforeRevisionClaim(tmp, claimPath); - try { - (testHooks.linkRevision || fs.linkSync)(tmp, claimPath); - } catch (error) { - if (error.code === "EEXIST") { - finishClaim(claimPath, revision); - return false; - } - // A winning writer may remove a stale contender's temp during cleanup. - if (error.code === "ENOENT") return false; - throw error; - } - if (testHooks.afterRevisionClaim) testHooks.afterRevisionClaim(claimPath, storePath); - - const currentRevision = readStore().revision; - if (currentRevision !== baseRevision) { - // A helper can publish our complete claim before this recheck. Missing - // means that exact candidate won; an extant path is our stale claim. - if (!fs.existsSync(claimPath)) return true; - fs.unlinkSync(claimPath); - return false; - } - try { fs.renameSync(claimPath, storePath); fsyncDirectory(backlogDir, storePath); } - catch (error) { - if (error.code !== "ENOENT") throw error; - // Another writer helped this content-complete claim across the window. - } - return true; - } catch (error) { - throw error instanceof LocalStoreError - ? error - : new LocalStoreError( - `cannot compare-and-swap local store revision ${revision}: ${error.message}`, - { cause: error } - ); - } finally { - try { fs.unlinkSync(tmp); } catch {} - } - } - - function bodyForMirror(body) { - const text = String(body); - const core = text.replace(/^\n/, "").replace(/\n$/, ""); - if (!core) return "\n## Description\n(No description provided)\n"; - if (/^##\s+Description/m.test(core)) return `\n${core}\n`; - return `\n## Description\n${core}\n`; - } - - function mirrorName(record) { - const slug = slugify(record.title) || record.id; - return `${taskPrefix}-${record.id} - ${slug}.md`; - } - - function renderMirror(record) { - const labels = record.labels.length - ? `\n${record.labels.map((label) => ` - ${escapeYaml(label)}`).join("\n")}` - : " []"; - return [ - "---", - `id: ${taskPrefix}-${record.id}`, - `title: ${escapeYaml(record.title)}`, - `status: ${escapeYaml(record.status)}`, - `labels:${labels}`, - `priority: ${escapeYaml(record.priority)}`, - `milestone: ${escapeYaml(record.milestone)}`, - `created_date: '${record.created_date}'`, - "---", - ].join("\n") + bodyForMirror(record.body); - } - - function refreshMirrorDir(kind, records) { - const dir = path.join(backlogDir, kind); - const issue = pathIssue(dir, kind); - if (issue) throw new LocalStoreError(issue); - fs.mkdirSync(dir, { recursive: true }); - const expected = new Set(); - for (const record of records) { - const name = mirrorName(record); - expected.add(name); - replaceAtomic(path.join(dir, name), renderMirror(record), "writeMirrorTemp"); - } - for (const name of fs.readdirSync(dir)) { - if (expected.has(name)) continue; - if (!parseTaskFileName(name, { taskPrefix, tracker: "local" })) continue; - fs.unlinkSync(path.join(dir, name)); - } - } - - function refreshMirrors(store) { - if (testHooks.beforeMirrorRefresh) testHooks.beforeMirrorRefresh(); - refreshMirrorDir(TASKS_DIR, store.tasks.filter((task) => task.state === "open")); - refreshMirrorDir(COMPLETED_DIR, store.tasks.filter((task) => task.state === "closed")); - } - - function commitMutation(mutate) { - for (let attempt = 0; attempt <= casRetries; attempt += 1) { - const store = readStore(); - const outcome = mutate(store); - if (!outcome.changed) { - refreshMirrors(readStore()); - return outcome.value; - } - const baseRevision = store.revision; - store.revision += 1; - if (!publishStore(store, baseRevision)) continue; - cleanRevisionDebris(store.revision); - refreshMirrors(readStore()); - return outcome.value; - } - throw new LocalStoreError( - `local store compare-and-swap exhausted after ${casRetries + 1} attempts; ` + - "concurrent writers kept claiming newer revisions, so no unconditional write was made" - ); - } - - function identityResult(id) { - const identity = identityForId(id); - return { tracker: "local", id: identity.id, ref: identity.ref }; - } - - function normalizedTask(record) { - return { - ...identityResult(record.id), - title: record.title, - status: record.status, - labels: [...record.labels], - priority: record.priority, - dependencies: [...record.dependencies], - created_date: record.created_date, - updated_date: record.updated_date, - body: record.body, - state: record.state, - }; - } - - function dateToday() { - return now().toISOString().slice(0, 10); - } - - function allocateParentId(store) { - let max = 0n; - for (const task of store.tasks) { - const parent = BigInt(task.id.split(".")[0]); - if (parent > max) max = parent; - } - return String(max + 1n); - } - - function buildUpdate(changes) { - const update = {}; - for (const field of ["title", "status", "priority"]) { - if (changes[field] !== undefined) { - update[field] = cleanScalar(field, changes[field], { - nonEmpty: field === "title" || field === "status", - }); - } - } - for (const field of LIST_FIELDS) { - if (changes[field] !== undefined) update[field] = cleanList(field, changes[field]); - } - if (changes.updated_date !== undefined) { - update.updated_date = cleanScalar("updated_date", changes.updated_date); - } - if (changes.body !== undefined) update.body = bodyForMirror(changes.body); - return update; - } - - function availability() { - if (prefixIssue) return { available: false, reason: `local tracker ${prefixIssue}` }; - const issue = canonicalPathIssue(); - if (issue) return { available: false, reason: issue }; - try { - readStore(); - return { available: true }; - } catch (error) { - return { available: false, reason: error.message }; - } - } - - function capabilities() { return []; } - - function list({ state = "open" } = {}) { - if (!LIST_STATES.includes(state)) { - throw new LocalStoreError( - `invalid local list state ${JSON.stringify(state)}; expected one of open, closed, all` - ); - } - const store = readStore(); - return store.tasks - .filter((task) => state === "all" || task.state === state) - .map(normalizedTask) - .sort(compareByParentThenSub); - } - - function read(selector) { - const identity = resolveIdentity(selector); - const record = readStore().tasks.find((task) => task.id === identity.id); - if (!record) throw new LocalStoreError(`local task not found: ${identity.ref}`); - return normalizedTask(record); - } - - function create(input = {}) { - rejectUnsupportedOptions("create", input, CREATE_OPTIONS); - assertUsablePrefix(); - if (typeof input.title !== "string" || !input.title.trim()) { - throw new LocalStoreError("local task creation requires a non-empty title"); - } - const title = cleanScalar("title", input.title, { nonEmpty: true }); - const status = cleanScalar("status", input.status ?? defaultStatus, { nonEmpty: true }); - const priority = cleanScalar("priority", input.priority ?? "medium"); - const labels = input.labels === undefined ? [] : cleanList("labels", input.labels); - const dependencies = - input.dependencies === undefined ? [] : cleanList("dependencies", input.dependencies); - let requestedId; - if (input.id !== undefined && input.id !== null) { - const parsed = identityForId(String(input.id)); - if (!parsed) throw new LocalStoreError(`invalid explicit local task id: ${String(input.id)}`); - requestedId = parsed.id; - } - - return commitMutation((store) => { - const id = requestedId ?? allocateParentId(store); - if (store.tasks.some((task) => task.id === id)) { - throw new LocalStoreError(`local task ${taskPrefix}-${id} already exists`); - } - const created = dateToday(); - store.tasks.push({ - id, title, status, labels, priority, dependencies, milestone: "", - created_date: created, updated_date: created, - body: bodyForMirror(input.body ?? ""), state: "open", - }); - return { changed: true, value: identityResult(id) }; - }); - } - - function update(selector, changes = {}) { - rejectUnsupportedOptions("update", changes, UPDATE_OPTIONS); - assertUsablePrefix(); - const identity = resolveIdentity(selector); - const updateFields = buildUpdate(changes); - return commitMutation((store) => { - const record = store.tasks.find((task) => task.id === identity.id); - if (!record || record.state !== "open") { - throw new LocalStoreError(`no active local task to update: ${identity.ref}`); - } - const changed = Object.entries(updateFields).some( - ([key, value]) => JSON.stringify(record[key]) !== JSON.stringify(value) - ); - Object.assign(record, updateFields); - return { changed, value: identityResult(identity.id) }; - }); - } - - function close(selector, closeOptions = {}) { - rejectUnsupportedOptions("close", closeOptions, []); - assertUsablePrefix(); - const identity = resolveIdentity(selector); - return commitMutation((store) => { - const record = store.tasks.find((task) => task.id === identity.id); - if (!record) throw new LocalStoreError(`local task not found: ${identity.ref}`); - const changed = record.state === "open"; - if (changed) Object.assign(record, { state: "closed", status: DONE_STATUS }); - return { changed, value: identityResult(identity.id) }; - }); - } - - return Object.freeze({ availability, capabilities, list, read, create, update, close }); -} - -module.exports = { - STORE_FILE, - createLocalAdapter, - LocalStoreError, -}; diff --git a/skills/dev-backlog/scripts/local-tracker.test.js b/skills/dev-backlog/scripts/local-tracker.test.js deleted file mode 100644 index dbb439a..0000000 --- a/skills/dev-backlog/scripts/local-tracker.test.js +++ /dev/null @@ -1,479 +0,0 @@ -const { describe, it } = require("node:test"); -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { createLocalAdapter, LocalStoreError } = require("./local-tracker.js"); -const { run: renderGithubMirrors } = require("./sync-pull.js"); - -const FIXED_DATE = "2026-07-26"; -const FIXED_NOW = () => new Date(`${FIXED_DATE}T12:00:00Z`); -const STORE_FILE = "local-tracker.json"; - -function makeStore(t, config = {}) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "local-tracker-")); - const backlogDir = path.join(root, "backlog"); - fs.mkdirSync(backlogDir, { recursive: true }); - fs.writeFileSync( - path.join(backlogDir, "config.yml"), - [ - "tracker: local", - `task_prefix: "${config.task_prefix || "BACK"}"`, - `default_status: "${config.default_status || "To Do"}"`, - "", - ].join("\n") - ); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); - return { - root, - backlogDir, - adapter: createLocalAdapter({ backlogDir, now: FIXED_NOW, config }), - }; -} - -function storePath(backlogDir) { - return path.join(backlogDir, STORE_FILE); -} - -function readStore(backlogDir) { - return JSON.parse(fs.readFileSync(storePath(backlogDir), "utf8")); -} - -function record(id, fields = {}) { - return { - id, - title: fields.title || `Task ${id}`, - status: fields.status || "To Do", - labels: fields.labels || [], - priority: fields.priority || "medium", - dependencies: fields.dependencies || [], - milestone: "", - created_date: fields.created_date || FIXED_DATE, - updated_date: fields.updated_date || FIXED_DATE, - body: fields.body || `\n## Description\nTask ${id}\n`, - state: fields.state || "open", - }; -} - -function seedStore(backlogDir, tasks) { - fs.writeFileSync( - storePath(backlogDir), - `${JSON.stringify({ version: 1, revision: 1, tasks }, null, 2)}\n` - ); -} - -function mirrorNames(backlogDir, kind) { - const dir = path.join(backlogDir, kind); - return fs.existsSync(dir) - ? fs.readdirSync(dir).filter((name) => name.endsWith(".md")).sort() - : []; -} - -function tempNames(backlogDir) { - const names = []; - function scan(dir) { - if (!fs.existsSync(dir)) return; - for (const name of fs.readdirSync(dir)) { - const full = path.join(dir, name); - const stat = fs.lstatSync(full); - if (stat.isDirectory()) scan(full); - else if (name.includes(".local-tracker.") && name.endsWith(".tmp")) names.push(full); - } - } - scan(backlogDir); - return names; -} - -function symlinkSupported(root) { - try { - const target = path.join(root, ".symlink-target"); - const link = path.join(root, ".symlink-probe"); - fs.mkdirSync(target); - fs.symlinkSync(target, link); - fs.unlinkSync(link); - fs.rmdirSync(target); - return true; - } catch { - return false; - } -} - -describe("local JSON substrate contract", () => { - it("exposes exactly seven operations, no optional capabilities, and a usable empty store", (t) => { - const { adapter, backlogDir } = makeStore(t); - assert.deepEqual(Object.keys(adapter), [ - "availability", - "capabilities", - "list", - "read", - "create", - "update", - "close", - ]); - assert.deepEqual(adapter.capabilities(), []); - assert.deepEqual(adapter.availability(), { available: true }); - assert.deepEqual(adapter.list(), []); - assert.equal(fs.existsSync(storePath(backlogDir)), false, "reads do not create a store"); - }); - - it("reads and writes only the JSON authority and overwrites hand-edited mirrors", (t) => { - const { adapter, backlogDir } = makeStore(t); - adapter.create({ title: "Canonical", body: "Original body" }); - const mirror = path.join(backlogDir, "tasks", "BACK-1 - canonical.md"); - fs.writeFileSync( - mirror, - "---\nid: BACK-1\ntitle: Hand edited\nstatus: Done\n---\nforged mirror body\n" - ); - - assert.equal(adapter.read("BACK-1").title, "Canonical"); - assert.equal(adapter.read("BACK-1").status, "To Do"); - assert.match(adapter.read("BACK-1").body, /Original body/); - - adapter.update("BACK-1", { priority: "high" }); - const refreshed = fs.readFileSync(mirror, "utf8"); - assert.match(refreshed, /^title: Canonical$/m); - assert.match(refreshed, /^status: To Do$/m); - assert.match(refreshed, /Original body/); - assert.doesNotMatch(refreshed, /Hand edited|forged mirror body/); - }); - - it("emits the exact GitHub mirror filename and byte shape", (t) => { - const { root, adapter, backlogDir } = makeStore(t); - const githubDir = path.join(root, "github-mirrors"); - fs.mkdirSync(githubDir); - const today = new Date().toISOString().slice(0, 10); - const local = createLocalAdapter({ - backlogDir, - now: () => new Date(`${today}T12:00:00Z`), - }); - local.create({ - id: "7", - title: "Same mirror", - body: "Human body\n\n## Acceptance Criteria\n- [ ] Same bytes", - labels: ["feature"], - priority: "high", - }); - renderGithubMirrors({ - issues: [{ - number: 7, - title: "Same mirror", - body: "Human body\n\n## Acceptance Criteria\n- [ ] Same bytes", - labels: [{ name: "feature" }, { name: "priority:high" }], - milestone: null, - }], - tasksDir: githubDir, - prefix: "BACK", - update: false, - dryRun: false, - }); - const name = "BACK-7 - same-mirror.md"; - assert.equal( - fs.readFileSync(path.join(backlogDir, "tasks", name), "utf8"), - fs.readFileSync(path.join(githubDir, name), "utf8") - ); - assert.deepEqual(adapter.list().map((task) => task.ref), ["BACK-7"]); - }); - - it("allocates exact ids across open and completed records without prefix collisions", (t) => { - const { adapter } = makeStore(t); - assert.equal(adapter.create({ id: "1", title: "One" }).id, "1"); - assert.equal(adapter.create({ id: "11", title: "Eleven" }).id, "11"); - adapter.close("BACK-11"); - assert.equal(adapter.create({ title: "Next" }).id, "12"); - assert.throws( - () => adapter.create({ id: "11", title: "Completed collision" }), - /BACK-11 already exists/ - ); - }); - - it("supports decimal ids and allocates above parents beyond Number.MAX_SAFE_INTEGER", (t) => { - const { adapter, backlogDir } = makeStore(t); - seedStore(backlogDir, [ - record("1.2"), - record("9007199254740993", { state: "closed", status: "Done" }), - ]); - assert.deepEqual(adapter.read("BACK-1.2").dependencies, []); - assert.equal(adapter.create({ title: "Large next" }).id, "9007199254740994"); - assert.throws(() => adapter.create({ id: "1.2", title: "Duplicate" }), /already exists/); - }); - - it("lists open, closed, and all tasks in numeric parent/subtask order", (t) => { - const { adapter, backlogDir } = makeStore(t); - seedStore(backlogDir, [ - record("11"), - record("2.3", { state: "closed", status: "Done" }), - record("2"), - record("2.1"), - ]); - assert.deepEqual(adapter.list().map((task) => task.id), ["2", "2.1", "11"]); - assert.deepEqual(adapter.list({ state: "closed" }).map((task) => task.id), ["2.3"]); - assert.deepEqual(adapter.list({ state: "all" }).map((task) => task.id), [ - "2", - "2.1", - "2.3", - "11", - ]); - assert.throws(() => adapter.list({ state: "maybe" }), /expected one of open, closed, all/); - }); - - it("resolves identity, ref, and bare-id selectors without accepting a foreign prefix", (t) => { - const { adapter } = makeStore(t); - const identity = adapter.create({ id: "4.2", title: "Selectors" }); - assert.equal(adapter.read(identity).id, "4.2"); - assert.equal(adapter.read("BACK-4.2").id, "4.2"); - assert.equal(adapter.read("4.2").id, "4.2"); - assert.throws(() => adapter.read("OTHER-4.2"), /unresolved local task selector/); - assert.throws( - () => adapter.read({ tracker: "github", id: "4.2", ref: "#4" }), - /requires a local task identity/ - ); - }); - - it("updates only requested canonical fields, preserves body, and renames the mirror from title", (t) => { - const { adapter, backlogDir } = makeStore(t); - adapter.create({ - title: "Before", - body: "Body\n\n## Acceptance Criteria\n- [ ] Preserve", - labels: ["one"], - dependencies: ["BACK-9"], - }); - const body = adapter.read("BACK-1").body; - adapter.update("BACK-1", { - title: "After", - status: "In Progress", - labels: ["two"], - updated_date: "2026-07-27", - }); - const updated = adapter.read("BACK-1"); - assert.equal(updated.body, body); - assert.deepEqual(updated.dependencies, ["BACK-9"]); - assert.equal(updated.updated_date, "2026-07-27"); - assert.deepEqual(mirrorNames(backlogDir, "tasks"), ["BACK-1 - after.md"]); - assert.match( - fs.readFileSync(path.join(backlogDir, "tasks", "BACK-1 - after.md"), "utf8"), - /^status: In Progress$/m - ); - }); - - it("replaces a body only when supplied and keeps it canonical across mirror refreshes", (t) => { - const { adapter } = makeStore(t); - adapter.create({ title: "Body", body: "First" }); - const first = adapter.read("1").body; - adapter.update("1", { priority: "low" }); - assert.equal(adapter.read("1").body, first); - adapter.update("1", { body: "Second\n- [ ] AC" }); - assert.equal(adapter.read("1").body, "\n## Description\nSecond\n- [ ] AC\n"); - }); - - it("closes in one canonical store commit and projects exactly one completed mirror", (t) => { - const { adapter, backlogDir } = makeStore(t); - const identity = adapter.create({ title: "Archive", body: "Keep me" }); - assert.deepEqual(adapter.close(identity), identity); - assert.deepEqual(adapter.close(identity), identity, "close is idempotent"); - assert.deepEqual(adapter.list(), []); - assert.deepEqual(adapter.list({ state: "closed" }).map((task) => task.ref), ["BACK-1"]); - assert.equal(adapter.read(identity).status, "Done"); - assert.deepEqual(mirrorNames(backlogDir, "tasks"), []); - assert.deepEqual(mirrorNames(backlogDir, "completed"), ["BACK-1 - archive.md"]); - assert.match( - fs.readFileSync(path.join(backlogDir, "completed", "BACK-1 - archive.md"), "utf8"), - /Keep me/ - ); - assert.equal(readStore(backlogDir).tasks.filter((task) => task.id === "1").length, 1); - }); - - it("rejects unsupported provider fields before any mutation", (t) => { - const { adapter, backlogDir } = makeStore(t); - for (const invoke of [ - () => adapter.create({ title: "No", milestone: "v1" }), - () => adapter.update("BACK-1", { assignees: ["me"] }), - () => adapter.close("BACK-1", { reason: "merged" }), - ]) { - assert.throws(invoke, /reports no optional capabilities/); - } - assert.equal(fs.existsSync(storePath(backlogDir)), false); - }); -}); - -describe("fail-closed JSON and filesystem validation", () => { - it("rejects control-character injection before create or update writes", (t) => { - const { adapter, backlogDir } = makeStore(t); - const injections = [ - "title\nstatus: Done", - "status\rpriority: high", - `label${String.fromCharCode(0)}value`, - "priority\tcritical", - ]; - assert.throws(() => adapter.create({ title: injections[0] }), /control characters/); - assert.throws(() => adapter.create({ title: "Safe", status: injections[1] }), /control characters/); - assert.throws(() => adapter.create({ title: "Safe", labels: [injections[2]] }), /control characters/); - assert.throws(() => adapter.create({ title: "Safe", priority: injections[3] }), /control characters/); - assert.equal(fs.existsSync(storePath(backlogDir)), false); - - adapter.create({ title: "Safe" }); - const before = fs.readFileSync(storePath(backlogDir), "utf8"); - assert.throws( - () => adapter.update("BACK-1", { status: "In Progress\nowner: attacker" }), - /control characters/ - ); - assert.equal(fs.readFileSync(storePath(backlogDir), "utf8"), before); - }); - - it("fails availability and every read on malformed or duplicate JSON records", (t) => { - const { backlogDir } = makeStore(t); - for (const raw of [ - "{\"version\":1,\"revision\":0,\"tasks\":[", - JSON.stringify({ version: 2, revision: 0, tasks: [] }), - JSON.stringify({ version: 1, revision: -1, tasks: [] }), - JSON.stringify({ version: 1, revision: 1, tasks: [record("1"), record("1")] }), - JSON.stringify({ version: 1, revision: 1, tasks: [{ ...record("1"), state: "lost" }] }), - JSON.stringify({ version: 1, revision: 1, tasks: [{ ...record("1"), title: "" }] }), - ]) { - fs.writeFileSync(storePath(backlogDir), raw); - const adapter = createLocalAdapter({ backlogDir, now: FIXED_NOW }); - assert.equal(adapter.availability().available, false); - assert.throws(() => adapter.list(), LocalStoreError); - } - }); - - it("ignores stale and forged Markdown when the JSON store is absent", (t) => { - const { adapter, backlogDir } = makeStore(t); - fs.mkdirSync(path.join(backlogDir, "tasks")); - fs.mkdirSync(path.join(backlogDir, "completed")); - fs.writeFileSync( - path.join(backlogDir, "tasks", "BACK-99 - forged.md"), - "---\nid: BACK-99\ntitle: Forged\nstatus: Done\n---\n" - ); - fs.writeFileSync( - path.join(backlogDir, "completed", "BACK-100 - forged.md"), - "---\nid: BACK-100\ntitle: Forged\nstatus: Done\n---\n" - ); - assert.deepEqual(adapter.list({ state: "all" }), []); - assert.throws(() => adapter.read("BACK-99"), /not found/); - assert.equal(adapter.create({ title: "Canonical first" }).id, "1"); - assert.deepEqual(mirrorNames(backlogDir, "tasks"), ["BACK-1 - canonical-first.md"]); - assert.deepEqual(mirrorNames(backlogDir, "completed"), []); - }); - - it("refuses symlinked canonical paths without following them", (t) => { - const { root, backlogDir } = makeStore(t); - if (!symlinkSupported(root)) return; - const external = path.join(root, "external"); - fs.mkdirSync(external); - const tasksDir = path.join(backlogDir, "tasks"); - fs.symlinkSync(external, tasksDir); - const adapter = createLocalAdapter({ backlogDir, now: FIXED_NOW }); - assert.equal(adapter.availability().available, false); - assert.throws(() => adapter.create({ title: "Escape" }), /must not be a symlink/); - assert.deepEqual(fs.readdirSync(external), []); - }); - - it("refuses a symlinked or non-regular JSON authority", (t) => { - const { root, backlogDir } = makeStore(t); - if (!symlinkSupported(root)) return; - const external = path.join(root, "outside.json"); - fs.writeFileSync(external, JSON.stringify({ version: 1, revision: 0, tasks: [] })); - fs.symlinkSync(external, storePath(backlogDir)); - let adapter = createLocalAdapter({ backlogDir, now: FIXED_NOW }); - assert.equal(adapter.availability().available, false); - assert.throws(() => adapter.list(), /must not be a symlink/); - fs.unlinkSync(storePath(backlogDir)); - fs.mkdirSync(storePath(backlogDir)); - adapter = createLocalAdapter({ backlogDir, now: FIXED_NOW }); - assert.equal(adapter.availability().available, false); - assert.throws(() => adapter.list(), /not a regular file/); - }); - - it("rejects unsafe prefixes before any path mutation", (t) => { - for (const taskPrefix of ["../ESCAPE", "BAD/PREFIX", "BAD PREFIX", ""]) { - const { backlogDir } = makeStore(t); - const adapter = createLocalAdapter({ - backlogDir, - now: FIXED_NOW, - config: { task_prefix: taskPrefix, default_status: "To Do" }, - }); - assert.equal(adapter.availability().available, false); - assert.throws(() => adapter.create({ title: "No escape" }), /task_prefix/); - assert.equal(fs.existsSync(storePath(backlogDir)), false); - } - }); -}); - -describe("atomic replacement and all-platform concurrency coverage", () => { - it("removes a partial temp and preserves the complete old store when writing fails", (t) => { - const { backlogDir } = makeStore(t); - seedStore(backlogDir, [record("1")]); - const before = fs.readFileSync(storePath(backlogDir), "utf8"); - const adapter = createLocalAdapter({ - backlogDir, - now: FIXED_NOW, - testHooks: { - writeStoreTemp(tmp) { - fs.writeFileSync(tmp, "{\"version\":1,\"tasks\":["); - const error = new Error("disk full"); - error.code = "ENOSPC"; - throw error; - }, - }, - }); - assert.throws(() => adapter.create({ title: "Never committed" }), /disk full/); - assert.equal(fs.readFileSync(storePath(backlogDir), "utf8"), before); - assert.deepEqual(tempNames(backlogDir), []); - }); - - it("fsyncs each parent directory after store, mirror, and archive renames", (t) => { - const { backlogDir } = makeStore(t); - const attempts = []; - const adapter = createLocalAdapter({ - backlogDir, - now: FIXED_NOW, - testHooks: { - beforeDirectoryFsync(dir, target) { - attempts.push([dir, target]); - assert.equal(fs.existsSync(target), true, "rename completed before directory fsync"); - }, - }, - }); - adapter.create({ title: "Durable" }); - assert.deepEqual(attempts, [ - [backlogDir, storePath(backlogDir)], - [path.join(backlogDir, "tasks"), path.join(backlogDir, "tasks", "BACK-1 - durable.md")], - ]); - - attempts.length = 0; - adapter.close("BACK-1"); - assert.deepEqual(attempts, [ - [backlogDir, storePath(backlogDir)], - [ - path.join(backlogDir, "completed"), - path.join(backlogDir, "completed", "BACK-1 - durable.md"), - ], - ]); - }); - - it("fails closed when the bounded compare-and-swap retry budget is exhausted", (t) => { - const { backlogDir } = makeStore(t); - let claims = 0; - const adapter = createLocalAdapter({ - backlogDir, - now: FIXED_NOW, - cas: { retries: 1 }, - testHooks: { - linkRevision() { - claims += 1; - const error = new Error("revision already claimed"); - error.code = "EEXIST"; - throw error; - }, - }, - }); - assert.throws( - () => adapter.create({ title: "Contended" }), - (error) => - error instanceof LocalStoreError && - /compare-and-swap exhausted after 2 attempts.*no unconditional write/.test(error.message) - ); - assert.equal(claims, 2); - assert.equal(fs.existsSync(storePath(backlogDir)), false); - }); -}); diff --git a/skills/dev-backlog/scripts/setup-dev-backlog.integration.test.js b/skills/dev-backlog/scripts/setup-dev-backlog.integration.test.js index eb7006c..38eb096 100644 --- a/skills/dev-backlog/scripts/setup-dev-backlog.integration.test.js +++ b/skills/dev-backlog/scripts/setup-dev-backlog.integration.test.js @@ -5,6 +5,11 @@ const os = require("node:os"); const path = require("node:path"); const { spawnSync } = require("node:child_process"); const { spawnBashSync } = require("./bash-runtime.js"); +const { + collectGithubEvidence, + isGithubRemote, + readLegacyTracker, +} = require("./setup-dev-backlog.js"); const SCRIPT = path.join(__dirname, "setup-dev-backlog.js"); const INIT = path.join(__dirname, "init.sh"); @@ -42,6 +47,14 @@ function snapshot(root) { return files; } +function writeConfig(root, raw) { + const backlogDir = path.join(root, "backlog"); + fs.mkdirSync(backlogDir, { recursive: true }); + const configPath = path.join(backlogDir, "config.yml"); + fs.writeFileSync(configPath, raw); + return configPath; +} + function faultPreload(t, source) { const root = makeRoot(t, "setup-preload-"); const preload = path.join(root, "fault.cjs"); @@ -49,144 +62,165 @@ function faultPreload(t, source) { return preload; } -describe("setup-dev-backlog real process integration", () => { - it("creates fresh explicit selections without config.yml", (t) => { - for (const tracker of ["local", "github"]) { - const root = makeRoot(t, `setup-fresh-${tracker}-`); - const run = runCli(root, [ - "--tracker", tracker, "--non-interactive", "--json", "--project-name", "fresh", - ]); - assert.equal(run.status, 0, run.stderr); - assert.equal(JSON.parse(run.stdout).selection, tracker); - assert.equal(fs.readFileSync(path.join(root, "backlog/.tracker"), "utf8"), `${tracker}\n`); - assert.equal(fs.existsSync(path.join(root, "backlog/config.yml")), false); +describe("legacy tracker read safety", () => { + it("reads BOM-prefixed github and ignores block, comment, and quoted-value decoys", (t) => { + const root = makeRoot(t); + const accepted = [ + `${String.fromCharCode(0xfeff)}tracker: github\r\nproject_name: legacy\r\n`, + "note: |\n tracker: local\ntracker: github\n", + "# tracker: local\ntracker: github\n", + 'note: "see tracker: local"\ntracker: "github"\n', + ]; + for (const raw of accepted) { + const configPath = writeConfig(root, raw); + assert.deepEqual(readLegacyTracker(configPath), { + found: true, + selection: "github", + }); } }); - it("migrates both legacy values while preserving exact config bytes", (t) => { - for (const tracker of ["local", "github"]) { - const root = makeRoot(t, `setup-legacy-${tracker}-`); - fs.mkdirSync(path.join(root, "backlog")); - const configPath = path.join(root, "backlog/config.yml"); - const raw = `\uFEFFproject_name: legacy\r\ntracker: "${tracker}" # stale after migration\r\nnote: |\r\n tracker: github`; - fs.writeFileSync(configPath, raw); - const run = runCli(root, ["--non-interactive", "--json"]); - assert.equal(run.status, 0, run.stderr); - assert.equal(JSON.parse(run.stdout).selectionSource, "legacy-migration"); - assert.equal(fs.readFileSync(path.join(root, "backlog/.tracker"), "utf8"), `${tracker}\n`); - assert.equal(fs.readFileSync(configPath, "utf8"), raw); + it("fails closed on ambiguous or authority-obscuring YAML keys", (t) => { + const root = makeRoot(t); + const refused = [ + "tracker: github\ntracker: local\n", + '"tracker": github\n', + "provider:\n tracker: github\n", + "- tracker: github\n", + "? tracker\n: github\n", + ]; + for (const raw of refused) { + const configPath = writeConfig(root, raw); + assert.throws( + () => readLegacyTracker(configPath), + /Ambiguous tracker authority|Unsupported tracker authority shape/ + ); } }); +}); - it("migrates a legacy selection that leads a BOM-prefixed config", (t) => { - const root = makeRoot(t, "setup-legacy-bom-first-"); - fs.mkdirSync(path.join(root, "backlog")); - const configPath = path.join(root, "backlog/config.yml"); - // The sibling migration test puts the BOM before project_name, leaving - // tracker on line 2 where the key regex still matches. Lead with tracker. - const raw = `${String.fromCharCode(0xfeff)}tracker: local\r\nproject_name: legacy\r\n`; - fs.writeFileSync(configPath, raw); +describe("GitHub evidence safety", () => { + it("accepts only strict github.com repository remotes", () => { + for (const remote of [ + "https://github.com/owner/repo.git", + "ssh://git@github.com/owner/repo.git", + "git@github.com:owner/repo.git", + "ssh://git@ssh.github.com:443/owner/repo.git", + ]) assert.equal(isGithubRemote(remote), true, remote); - const run = runCli(root, ["--non-interactive", "--json"]); - assert.equal(run.status, 0, run.stderr); - assert.equal(JSON.parse(run.stdout).selectionSource, "legacy-migration"); - assert.equal(fs.readFileSync(path.join(root, "backlog/.tracker"), "utf8"), "local\n"); - assert.equal(fs.readFileSync(configPath, "utf8"), raw); + for (const remote of [ + "https://github.com.evil.test/owner/repo.git", + "https://github.com/owner/repo/issues", + "ssh://alice@github.com/owner/repo.git", + "git@github.com:owner/../repo.git", + ]) assert.equal(isGithubRemote(remote), false, remote); }); - it("refuses an ambiguous legacy config without creating anything", (t) => { - const root = makeRoot(t, "setup-legacy-ambiguous-"); - fs.mkdirSync(path.join(root, "backlog")); - const configPath = path.join(root, "backlog/config.yml"); - const raw = "project_name: legacy\r\ntracker: local\r\ntracker: github\r\n"; - fs.writeFileSync(configPath, raw); - const before = snapshot(root); - - const refused = runCli(root, ["--non-interactive", "--json"]); - assert.notEqual(refused.status, 0); - assert.match(refused.stderr, /Ambiguous tracker authority/); - // Fail closed before any effect: no .tracker, no directories, config byte-identical. - assert.equal(fs.existsSync(path.join(root, "backlog/.tracker")), false); - assert.equal(fs.readFileSync(configPath, "utf8"), raw); - assert.deepEqual(snapshot(root), before); + it("sanitizes provider failures and never recommends fallback", () => { + const secret = "SECRET-TOKEN"; + const execFileSync = (command) => { + const error = new Error(`${command} failed ${secret}`); + if (command === "gh") error.code = "ENOENT"; + throw error; + }; + const evidence = collectGithubEvidence({ cwd: "/repo", execFileSync }); + assert.deepEqual(evidence, { + recommendation: "github", + remote: "missing", + cli: "missing", + auth: "not-checked", + }); + assert.doesNotMatch(JSON.stringify(evidence), new RegExp(secret)); }); +}); - it("pins a tracker-less legacy config before a local switch", (t) => { +describe("GitHub-only setup real process integration", () => { + it("creates a fresh GitHub selection without config.yml", (t) => { const root = makeRoot(t); - fs.mkdirSync(path.join(root, "backlog/tasks"), { recursive: true }); - const configPath = path.join(root, "backlog/config.yml"); - const raw = "project_name: legacy\r\nnotes: keep"; - fs.writeFileSync(configPath, raw); - fs.writeFileSync(path.join(root, "backlog/tasks/BACK-1.md"), "mirror bytes\r\n"); - const before = snapshot(root); - - const refused = runCli(root, ["--tracker", "local", "--non-interactive"]); - assert.notEqual(refused.status, 0); - assert.match(refused.stderr, /First pin compatibility/); - assert.deepEqual(snapshot(root), before); - - const pin = runCli(root, ["--non-interactive", "--json"]); - assert.equal(pin.status, 0, pin.stderr); + const result = runCli(root, [ + "--tracker", "github", "--non-interactive", "--json", "--project-name", "fresh", + ]); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).selection, "github"); assert.equal(fs.readFileSync(path.join(root, "backlog/.tracker"), "utf8"), "github\n"); - assert.equal(fs.readFileSync(configPath, "utf8"), raw); + assert.equal(fs.existsSync(path.join(root, "backlog/config.yml")), false); + assert.equal(fs.existsSync(path.join(root, "backlog/sprints")), true); + assert.equal(fs.existsSync(path.join(root, "backlog/tasks")), false); + }); - const switched = runCli(root, ["--tracker=local", "--non-interactive"]); - assert.equal(switched.status, 0, switched.stderr); - assert.equal(fs.readFileSync(path.join(root, "backlog/.tracker"), "utf8"), "local\n"); + it("pins legacy github while preserving exact complex config bytes", (t) => { + const root = makeRoot(t); + const raw = [ + `${String.fromCharCode(0xfeff)}project_name: legacy`, + 'note: "tracker: local"', + "body: |", + " tracker: local", + "tracker: github # preserved", + "tail: preserved", + "", + ].join("\r\n"); + const configPath = writeConfig(root, raw); + const result = runCli(root, ["--non-interactive", "--json"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).selectionSource, "legacy-migration"); + assert.equal(fs.readFileSync(path.join(root, "backlog/.tracker"), "utf8"), "github\n"); assert.equal(fs.readFileSync(configPath, "utf8"), raw); }); - it("treats .tracker as authoritative and leaves complex YAML untouched", (t) => { + it("gives .tracker precedence and leaves complex config bytes untouched", (t) => { const root = makeRoot(t); - fs.mkdirSync(path.join(root, "backlog")); - const configPath = path.join(root, "backlog/config.yml"); const raw = [ '"note:with:colons": © !text |-2', - " tracker: text in block", + " tracker: local", "single: 'first line", - " tracker: text in single quote", + " tracker: local", " last line'", - "tracker: github # stale", + "tracker: local # stale and ignored", "tail: preserved", ].join("\r\n"); - fs.writeFileSync(configPath, raw); - fs.writeFileSync(path.join(root, "backlog/.tracker"), "local\n"); - - const preserved = runCli(root, ["--non-interactive", "--json"]); - assert.equal(preserved.status, 0, preserved.stderr); - assert.equal(JSON.parse(preserved.stdout).selection, "local"); - const switched = runCli(root, ["--tracker", "github", "--non-interactive"]); - assert.equal(switched.status, 0, switched.stderr); - assert.equal(fs.readFileSync(path.join(root, "backlog/.tracker"), "utf8"), "github\n"); + const configPath = writeConfig(root, raw); + fs.writeFileSync(path.join(root, "backlog/.tracker"), "github\n"); + const result = runCli(root, ["--non-interactive", "--json"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).selection, "github"); assert.equal(fs.readFileSync(configPath, "utf8"), raw); }); it("repairs partial structure and reruns byte-idempotently", (t) => { const root = makeRoot(t); - fs.mkdirSync(path.join(root, "backlog/tasks"), { recursive: true }); - fs.writeFileSync(path.join(root, "backlog/.tracker"), "local\n"); - fs.writeFileSync(path.join(root, "backlog/tasks/BACK-2.md"), "task bytes"); + fs.mkdirSync(path.join(root, "backlog")); + fs.writeFileSync(path.join(root, "backlog/.tracker"), "github\n"); const first = runCli(root, ["--non-interactive"]); assert.equal(first.status, 0, first.stderr); + assert.equal(fs.existsSync(path.join(root, "backlog/sprints")), true); const repaired = snapshot(path.join(root, "backlog")); const second = runCli(root, ["--non-interactive"]); assert.equal(second.status, 0, second.stderr); assert.deepEqual(snapshot(path.join(root, "backlog")), repaired); }); - it("rejects invalid .tracker before any mutation", (t) => { - const root = makeRoot(t); - fs.mkdirSync(path.join(root, "backlog")); - fs.writeFileSync(path.join(root, "backlog/.tracker"), "gitlab\n"); - fs.writeFileSync(path.join(root, "backlog/config.yml"), "tracker: local\n"); + it("rejects invalid and retired local selections before effects", (t) => { + for (const selection of ["gitlab", "local"]) { + const root = makeRoot(t, `setup-invalid-${selection}-`); + fs.mkdirSync(path.join(root, "backlog")); + fs.writeFileSync(path.join(root, "backlog/.tracker"), `${selection}\n`); + const before = snapshot(root); + const result = runCli(root, ["--non-interactive"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /expected github/); + assert.deepEqual(snapshot(root), before); + } + + const root = makeRoot(t, "setup-invalid-config-local-"); + writeConfig(root, "tracker: local\n"); const before = snapshot(root); - const run = runCli(root, ["--non-interactive"]); - assert.notEqual(run.status, 0); - assert.match(run.stderr, /expected github or local/); + const result = runCli(root, ["--non-interactive"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /expected github/); assert.deepEqual(snapshot(root), before); }); - it("rolls back fresh directories and temp bytes on .tracker publication failures", (t) => { + it("rolls back fresh directories and temp bytes on atomic publication failures", (t) => { for (const failure of ["write", "rename"]) { const root = makeRoot(t, `setup-failure-${failure}-`); const source = failure === "write" @@ -212,12 +246,12 @@ describe("setup-dev-backlog real process integration", () => { '};', ].join("\n"); const preload = faultPreload(t, source); - const run = runCli(root, ["--tracker", "local", "--non-interactive"], { + const result = runCli(root, ["--tracker", "github", "--non-interactive"], { ...process.env, NODE_OPTIONS: `--require=${preload}`, }); - assert.notEqual(run.status, 0, failure); - assert.match(run.stderr, new RegExp(`injected ${failure} failure`)); + assert.notEqual(result.status, 0, failure); + assert.match(result.stderr, new RegExp(`injected ${failure} failure`)); assert.equal(fs.existsSync(path.join(root, "backlog")), false); } }); @@ -235,13 +269,13 @@ describe("setup-dev-backlog real process integration", () => { throw error; } const before = snapshot(root); - const run = runCli(root, ["--tracker", "local", "--non-interactive"]); - assert.notEqual(run.status, 0); - assert.match(run.stderr, /unsafe tracker path/); + const result = runCli(root, ["--tracker", "github", "--non-interactive"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /unsafe tracker path/); assert.deepEqual(snapshot(root), before); }); - it("keeps init.sh fresh github compatibility and legacy migration", (t) => { + it("keeps init.sh fresh and legacy GitHub behavior through cross-platform Bash", (t) => { const fresh = makeRoot(t, "setup-init-fresh-"); const freshRun = spawnBashSync([INIT, "wrapper-demo"], { cwd: fresh, @@ -251,16 +285,14 @@ describe("setup-dev-backlog real process integration", () => { assert.equal(fs.readFileSync(path.join(fresh, "backlog/.tracker"), "utf8"), "github\n"); const legacy = makeRoot(t, "setup-init-legacy-"); - fs.mkdirSync(path.join(legacy, "backlog")); - const configPath = path.join(legacy, "backlog/config.yml"); - const raw = "project_name: stable\ntracker: local\n"; - fs.writeFileSync(configPath, raw); + const configPath = writeConfig(legacy, "project_name: stable\ntracker: github\n"); + const raw = fs.readFileSync(configPath, "utf8"); const legacyRun = spawnBashSync([INIT, "ignored"], { cwd: legacy, encoding: "utf8", }); assert.equal(legacyRun.status, 0, legacyRun.stderr); - assert.equal(fs.readFileSync(path.join(legacy, "backlog/.tracker"), "utf8"), "local\n"); + assert.equal(fs.readFileSync(path.join(legacy, "backlog/.tracker"), "utf8"), "github\n"); assert.equal(fs.readFileSync(configPath, "utf8"), raw); }); }); diff --git a/skills/dev-backlog/scripts/setup-dev-backlog.js b/skills/dev-backlog/scripts/setup-dev-backlog.js index e8a5269..4b15209 100755 --- a/skills/dev-backlog/scripts/setup-dev-backlog.js +++ b/skills/dev-backlog/scripts/setup-dev-backlog.js @@ -13,14 +13,11 @@ const path = require("node:path"); const readline = require("node:readline/promises"); const { readLegacyTracker: readLegacyTrackerFile } = require("./legacy-tracker.js"); -const ALLOWED_TRACKERS = Object.freeze(["github", "local"]); +const ALLOWED_TRACKERS = Object.freeze(["github"]); const MINIMUM_DIRECTORIES = Object.freeze(["sprints"]); -const LOCAL_COMPATIBILITY_DIRECTORIES = Object.freeze(["tasks", "completed"]); -function requiredDirectories(selection) { - return selection === "local" - ? [...MINIMUM_DIRECTORIES, ...LOCAL_COMPATIBILITY_DIRECTORIES] - : [...MINIMUM_DIRECTORIES]; +function requiredDirectories() { + return [...MINIMUM_DIRECTORIES]; } function shellQuote(value) { @@ -46,7 +43,7 @@ function usage() { "Usage: setup-dev-backlog.js [project-name] [options]", "", "Options:", - " --tracker github|local Select the canonical task tracker", + " --tracker github Pin the GitHub task authority", " --non-interactive Never prompt (required with --tracker when fresh)", " --project-name NAME Project name reported for compatibility", " --json Print structured output", @@ -110,7 +107,7 @@ function parseArgs(argv = process.argv.slice(2)) { if (options.tracker !== undefined && !ALLOWED_TRACKERS.includes(options.tracker)) { throw new SetupError( - `Invalid --tracker value ${JSON.stringify(options.tracker)}; expected github or local.` + `Invalid --tracker value ${JSON.stringify(options.tracker)}; expected github.` ); } if (options.projectName !== undefined && options.projectName.length === 0) { @@ -122,7 +119,7 @@ function parseArgs(argv = process.argv.slice(2)) { function assertAllowedTracker(selection, sourcePath) { if (!ALLOWED_TRACKERS.includes(selection)) { throw new SetupError( - `Invalid tracker selection ${JSON.stringify(selection)} in ${sourcePath}; expected github or local.` + `Invalid tracker selection ${JSON.stringify(selection)} in ${sourcePath}; expected github.` ); } return selection; @@ -214,9 +211,7 @@ function collectGithubEvidence({ } } - const recommendation = remote === "github" && auth === "authenticated" - ? "github" - : "local"; + const recommendation = "github"; return Object.freeze({ recommendation, remote, cli, auth }); } @@ -289,14 +284,14 @@ function atomicPublish(targetPath, content, { fs: fsApi = fs } = {}) { return Object.freeze({ changed: true, created: !targetExists }); } -function ensureMinimumDirectories(backlogDir, fsApi, selection) { +function ensureMinimumDirectories(backlogDir, fsApi) { const structure = { backlogCreated: false, created: [] }; try { if (!lstatIfPresent(backlogDir, fsApi)) { fsApi.mkdirSync(backlogDir); structure.backlogCreated = true; } - for (const name of requiredDirectories(selection)) { + for (const name of requiredDirectories()) { const directory = path.join(backlogDir, name); if (!lstatIfPresent(directory, fsApi)) { fsApi.mkdirSync(directory); @@ -334,10 +329,7 @@ function validateExistingStructure(backlogDir, configPath, trackerPath, fsApi) { } const configExists = validateRegularFile(configPath, "config", fsApi); const trackerExists = validateRegularFile(trackerPath, "tracker", fsApi); - // Validate every recognized compatibility directory even when the selected - // tracker would not create it. Existing legacy paths must not bypass the - // same symlink/non-directory safety boundary during a GitHub setup. - for (const name of requiredDirectories("local")) { + for (const name of requiredDirectories()) { const directory = path.join(backlogDir, name); const stat = lstatIfPresent(directory, fsApi); if (!stat) continue; @@ -372,17 +364,7 @@ function defaultProjectName(cwd) { function refusalMessage() { return ( "Fresh non-interactive setup requires an explicit tracker. " + - `Recommended safe rerun: ${setupCommand(["--tracker", "local", "--non-interactive"])}` - ); -} - -function legacyLocalRefusalMessage() { - const pin = setupCommand(["--non-interactive"]); - const switchTracker = setupCommand(["--tracker", "local", "--non-interactive"]); - return ( - "Existing tracker-less config has legacy GitHub authority and cannot switch directly to local. " + - `First pin compatibility with ${pin}; then explicitly switch with ${switchTracker}. ` + - "This setup does not migrate task files." + `Recommended safe rerun: ${setupCommand(["--tracker", "github", "--non-interactive"])}` ); } @@ -440,9 +422,6 @@ async function runSetup(options = {}, dependencies = {}) { selectionSource = options.tracker === undefined ? "preserved" : "explicit"; } else if (state.configExists) { const legacy = readLegacyTracker(configPath, fsApi); - if (!legacy.found && options.tracker === "local") { - throw new SetupError(legacyLocalRefusalMessage()); - } selection = options.tracker ?? legacy.selection ?? "github"; selectionSource = options.tracker !== undefined ? "explicit" @@ -454,7 +433,7 @@ async function runSetup(options = {}, dependencies = {}) { recommendationEvidence = fresh.evidence; } - const structure = ensureMinimumDirectories(backlogDir, fsApi, selection); + const structure = ensureMinimumDirectories(backlogDir, fsApi); let publication; try { publication = atomicPublish(trackerPath, `${selection}\n`, { fs: fsApi }); @@ -524,7 +503,7 @@ async function promptForTracker({ recommendation, evidence }) { const terminal = readline.createInterface({ input: process.stdin, output: process.stdout }); try { return await terminal.question( - `Tracker [github/local] (default: ${recommendation}): ` + `Tracker [github] (default: ${recommendation}): ` ); } finally { terminal.close(); @@ -561,7 +540,6 @@ if (require.main === module) { module.exports = { ALLOWED_TRACKERS, - LOCAL_COMPATIBILITY_DIRECTORIES, MINIMUM_DIRECTORIES, SetupError, atomicPublish, diff --git a/skills/dev-backlog/scripts/setup-dev-backlog.test.js b/skills/dev-backlog/scripts/setup-dev-backlog.test.js index 05b3f75..4725c67 100644 --- a/skills/dev-backlog/scripts/setup-dev-backlog.test.js +++ b/skills/dev-backlog/scripts/setup-dev-backlog.test.js @@ -4,457 +4,132 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { spawnSync } = require("node:child_process"); -const { spawnBashSync } = require("./bash-runtime.js"); -const { readConfig } = require("./lib.js"); -const { resolveConfiguredTracker } = require("./tracker.js"); const { - LOCAL_COMPATIBILITY_DIRECTORIES, - MINIMUM_DIRECTORIES, SetupError, collectGithubEvidence, - isGithubRemote, parseArgs, - readLegacyTracker, - readTrackerFile, - requiredDirectories, runSetup, } = require("./setup-dev-backlog.js"); const SCRIPT = path.join(__dirname, "setup-dev-backlog.js"); const INIT = path.join(__dirname, "init.sh"); -function makeRoot(t, prefix = "setup-dev-backlog-") { - const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); - return root; +function root(t, prefix = "setup-github-only-") { + const value = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + t.after(() => fs.rmSync(value, { recursive: true, force: true })); + return value; } -function writeConfig(root, raw) { - const backlogDir = path.join(root, "backlog"); - fs.mkdirSync(backlogDir, { recursive: true }); - const configPath = path.join(backlogDir, "config.yml"); - fs.writeFileSync(configPath, raw); - return configPath; -} - -function writeTracker(root, raw) { - const backlogDir = path.join(root, "backlog"); - fs.mkdirSync(backlogDir, { recursive: true }); - const trackerPath = path.join(backlogDir, ".tracker"); - fs.writeFileSync(trackerPath, raw); - return trackerPath; -} - -function noProviderCalls() { - return () => { - throw new Error("provider command must not be called"); - }; -} - -function snapshotTree(root) { - const output = {}; +function snapshot(directory) { + const result = {}; function walk(current, relative = "") { if (!fs.existsSync(current)) return; - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const full = path.join(current, entry.name); - const key = path.join(relative, entry.name); + for (const name of fs.readdirSync(current).sort()) { + const full = path.join(current, name); + const key = path.join(relative, name); const stat = fs.lstatSync(full); - output[key] = { - type: entry.isDirectory() ? "directory" : "file", - ino: stat.ino, - mtimeMs: stat.mtimeMs, - bytes: entry.isFile() ? fs.readFileSync(full).toString("base64") : null, + result[key] = { + type: stat.isDirectory() ? "directory" : stat.isSymbolicLink() ? "symlink" : "file", + bytes: stat.isFile() ? fs.readFileSync(full, "base64") : null, }; - if (entry.isDirectory()) walk(full, key); + if (stat.isDirectory()) walk(full, key); } } - walk(root); - return output; + walk(directory); + return result; } -function evidenceExec({ remote, gh = "authenticated", secret = "SECRET-TOKEN" } = {}) { - const calls = []; - const execFileSync = (command, args) => { - calls.push([command, ...args]); - if (command === "git") { - if (remote === undefined) throw new Error(`no origin ${secret}`); - return remote; - } - if (gh === "missing") { - const error = new Error(`spawn gh ENOENT ${secret}`); - error.code = "ENOENT"; - throw error; - } - if (gh === "unauthenticated") throw new Error(`auth failed ${secret}`); - return "github.com logged in"; - }; - return { calls, execFileSync }; -} - -describe("selection readers", () => { - it("reads the one-line tracker file with trim semantics", (t) => { - const root = makeRoot(t); - const trackerPath = writeTracker(root, " \tlocal\r\n"); - assert.equal(readTrackerFile(trackerPath), "local"); - fs.writeFileSync(trackerPath, "github\nlocal\n"); - assert.throws(() => readTrackerFile(trackerPath), SetupError); - }); - - it("refuses every authority-obscuring legacy config the old tokenizer refused", (t) => { - const root = makeRoot(t); - // The replaced tokenizer counted a tracker key wherever it appeared and - // refused on more than one, or on a lone nested one. parseSimpleYaml cannot - // reproduce that, so these must stay fail-closed rather than migrate. - const refused = [ - ["duplicate top-level", "tracker: local\ntracker: github\n"], - ["nested plus top-level", "provider:\n tracker: github\ntracker: local\n"], - ["flow sequence plus top-level", "items: [tracker: github]\ntracker: local\n"], - ["nested only", "provider:\n tracker: github\n"], - ["sequence item only", "- tracker: github\n"], - ["quoted key", '"tracker": local\n'], - // The old lexer decoded these; this reader refuses them instead of - // rebuilding the tokenizer that decoding would require. - ["escaped quoted key", 'mapping: {"track\\x65r": github}\ntracker: local\n'], - ["explicit mapping key", "? tracker\n: github\ntracker: local\n"], - ]; - for (const [label, raw] of refused) { - const configPath = writeConfig(root, raw); - assert.throws(() => readLegacyTracker(configPath), (error) => { - assert.ok(error instanceof SetupError, label); - assert.match(error.message, /Ambiguous tracker authority/, label); - return true; - }, label); - } - }); - - it("still resolves a real top-level selection past decoy tracker text", (t) => { - const root = makeRoot(t); - // The old lexer excluded block-scalar bodies and comments, and never counted - // a tracker key inside a quoted string. Keep all three accepted. - const accepted = [ - ["block scalar body", "note: |\n tracker: github\ntracker: local\n"], - ["comment", "# tracker: github\ntracker: local\n"], - ["quoted string", 'note: "see tracker: github"\ntracker: local\n'], - ["quoted value without escapes", 'tracker: "local"\n'], - ]; - for (const [label, raw] of accepted) { - const configPath = writeConfig(root, raw); - assert.deepEqual(readLegacyTracker(configPath), { found: true, selection: "local" }, label); - } - }); - - it("reads a legacy selection that sits on the first line of a BOM-prefixed config", (t) => { - const root = makeRoot(t); - // U+FEFF is neither whitespace nor a key character, so an unstripped BOM - // makes the first line invisible to the key regex — the config reads as - // "no tracker" and a legacy local repo silently migrates to github. - const bom = String.fromCharCode(0xfeff); - const first = writeConfig(root, `${bom}tracker: local\nproject_name: legacy\n`); - assert.deepEqual(readLegacyTracker(first), { found: true, selection: "local" }); - - // The same file must still be refused when its authority is ambiguous. - const ambiguous = writeConfig(root, `${bom}tracker: local\ntracker: github\n`); - assert.throws(() => readLegacyTracker(ambiguous), (error) => { - assert.match(error.message, /Ambiguous tracker authority/); - return true; - }); - }); - - it("uses parseSimpleYaml for the legacy fallback", (t) => { - const root = makeRoot(t); - const configPath = writeConfig(root, [ - "note: |", - " tracker: github", - "tracker: 'local' # legacy authority", - "", - ].join("\n")); - assert.deepEqual(readLegacyTracker(configPath), { - found: true, - selection: "local", - }); - - fs.writeFileSync(configPath, "project_name: no-selection\n"); - assert.deepEqual(readLegacyTracker(configPath), { - found: false, - selection: undefined, - }); +describe("GitHub-only setup", () => { + it("accepts only an explicit github authority", () => { + assert.equal(parseArgs(["--tracker", "github"]).tracker, "github"); + assert.throws(() => parseArgs(["--tracker", "local"]), /expected github/); + assert.throws(() => parseArgs(["--tracker", "gitlab"]), /expected github/); }); -}); -describe("CLI argument boundary", () => { - it("rejects duplicate and unsupported tracker flags", () => { - for (const argv of [ - ["--tracker", "local", "--tracker", "github"], - ["--tracker=local", "--tracker=local"], - ]) assert.throws(() => parseArgs(argv), /only once/); - assert.throws(() => parseArgs(["--tracker", "gitlab"]), /github or local/); - }); -}); - -describe("provider evidence", () => { - it("accepts only exact github.com remote hosts", () => { - for (const remote of [ - "https://github.com/owner/repo.git", - "ssh://git@github.com/owner/repo.git", - "git@github.com:owner/repo.git", - "ssh://git@ssh.github.com:443/owner/repo.git", - ]) assert.equal(isGithubRemote(remote), true, remote); - - for (const remote of [ - "https://github.com.evil.test/owner/repo.git", - "https://github.com/owner/repo/issues", - "ssh://alice@github.com/owner/repo.git", - "git@github.com:owner/../repo.git", - ]) assert.equal(isGithubRemote(remote), false, remote); - }); - - it("uses evidence only for a fresh interactive recommendation", async (t) => { - const root = makeRoot(t); - const mock = evidenceExec({ - remote: "git@github.com:owner/repo.git", - gh: "authenticated", - }); - let promptInput; - const result = await runSetup( - { cwd: root, projectName: "interactive" }, - { - isInteractive: true, - execFileSync: mock.execFileSync, - prompt(input) { - promptInput = input; - return ""; - }, - } - ); + it("creates only .tracker and sprints for a fresh repository", async (t) => { + const cwd = root(t); + const result = await runSetup({ cwd, tracker: "github", nonInteractive: true }); assert.equal(result.selection, "github"); - assert.equal(result.selectionSource, "recommended"); - assert.equal(promptInput.recommendation, "github"); - assert.doesNotMatch(JSON.stringify(promptInput), /SECRET-TOKEN/); - }); - - it("sanitizes the availability matrix", () => { - const mock = evidenceExec({ - remote: "https://example.com/owner/repo.git", - gh: "missing", - }); - assert.deepEqual(collectGithubEvidence({ cwd: "/repo", execFileSync: mock.execFileSync }), { - recommendation: "local", - remote: "non-github", - cli: "missing", - auth: "not-checked", - }); - }); -}); - -describe("setup filesystem behavior", () => { - it("creates a mirrorless GitHub minimum and preserves local compatibility directories", async (t) => { - for (const tracker of ["github", "local"]) { - const root = makeRoot(t, `setup-fresh-${tracker}-`); - const result = await runSetup( - { cwd: root, tracker, nonInteractive: true, projectName: "demo" }, - { execFileSync: noProviderCalls() } - ); - const backlogDir = path.join(root, "backlog"); - assert.equal(result.selection, tracker); - assert.equal(result.trackerCreated, true); - assert.equal(fs.readFileSync(path.join(backlogDir, ".tracker"), "utf8"), `${tracker}\n`); - assert.equal(fs.existsSync(path.join(backlogDir, "config.yml")), false); - const expected = tracker === "github" - ? [".tracker", "sprints"] - : [".tracker", "completed", "sprints", "tasks"]; - assert.deepEqual(fs.readdirSync(backlogDir).sort(), expected); - assert.equal(resolveConfiguredTracker(readConfig(backlogDir), { backlogDir }).tracker, tracker); - } - }); - - it("declares task projection directories as local-only compatibility", () => { - assert.deepEqual(MINIMUM_DIRECTORIES, ["sprints"]); - assert.deepEqual(LOCAL_COMPATIBILITY_DIRECTORIES, ["tasks", "completed"]); - assert.deepEqual(requiredDirectories("github"), ["sprints"]); - assert.deepEqual(requiredDirectories("local"), ["sprints", "tasks", "completed"]); - }); - - it("refuses fresh non-interactive setup without a deliberate choice", async (t) => { - const root = makeRoot(t); - await assert.rejects( - runSetup({ cwd: root, nonInteractive: true }), - /--tracker local --non-interactive/ - ); - assert.equal(fs.existsSync(path.join(root, "backlog")), false); - }); - - it("migrates a legacy selection without changing one config.yml byte", async (t) => { - for (const tracker of ["github", "local"]) { - const root = makeRoot(t, `setup-legacy-${tracker}-`); - const raw = `project_name: legacy\r\ntracker: '${tracker}' # keep\r\nnote: |\r\n tracker: github`; - const configPath = writeConfig(root, raw); - const before = fs.statSync(configPath); - const result = await runSetup( - { cwd: root, nonInteractive: true }, - { execFileSync: noProviderCalls() } + assert.deepEqual(fs.readdirSync(path.join(cwd, "backlog")).sort(), [".tracker", "sprints"]); + assert.equal(fs.readFileSync(path.join(cwd, "backlog/.tracker"), "utf8"), "github\n"); + }); + + it("preserves a legacy github config byte-for-byte while pinning .tracker", async (t) => { + const cwd = root(t); + const backlogDir = path.join(cwd, "backlog"); + fs.mkdirSync(backlogDir); + const raw = "project_name: legacy\r\ntracker: github\r\n# keep\r\n"; + fs.writeFileSync(path.join(backlogDir, "config.yml"), raw); + const result = await runSetup({ cwd, nonInteractive: true }); + assert.equal(result.selectionSource, "legacy-migration"); + assert.equal(fs.readFileSync(path.join(backlogDir, "config.yml"), "utf8"), raw); + assert.equal(fs.readFileSync(path.join(backlogDir, ".tracker"), "utf8"), "github\n"); + }); + + it("refuses retired local selections before effects", async (t) => { + for (const source of ["config", "selection"]) { + const cwd = root(t, `setup-retired-${source}-`); + const backlogDir = path.join(cwd, "backlog"); + fs.mkdirSync(backlogDir); + if (source === "config") { + fs.writeFileSync(path.join(backlogDir, "config.yml"), "tracker: local\n"); + } else { + fs.writeFileSync(path.join(backlogDir, ".tracker"), "local\n"); + } + const before = snapshot(cwd); + await assert.rejects( + runSetup({ cwd, nonInteractive: true }), + /expected github/, ); - const after = fs.statSync(configPath); - assert.equal(result.selection, tracker); - assert.equal(result.selectionSource, "legacy-migration"); - assert.equal(fs.readFileSync(configPath, "utf8"), raw); - assert.equal(after.ino, before.ino); - assert.equal(after.mtimeMs, before.mtimeMs); - assert.equal(fs.readFileSync(path.join(root, "backlog/.tracker"), "utf8"), `${tracker}\n`); + assert.deepEqual(snapshot(cwd), before); } }); - it("pins a tracker-less legacy config to github before allowing local", async (t) => { - const root = makeRoot(t); - const raw = "project_name: legacy\r\nnotes: keep"; - const configPath = writeConfig(root, raw); - const before = snapshotTree(root); - await assert.rejects( - runSetup({ cwd: root, tracker: "local", nonInteractive: true }), - /First pin compatibility/ - ); - assert.deepEqual(snapshotTree(root), before); - - const pin = await runSetup({ cwd: root, nonInteractive: true }); - assert.equal(pin.selection, "github"); - assert.equal(pin.selectionSource, "legacy-pin"); - assert.equal(fs.readFileSync(configPath, "utf8"), raw); - - const switched = await runSetup({ - cwd: root, - tracker: "local", - nonInteractive: true, + it("never recommends a runtime fallback when GitHub evidence is unavailable", () => { + const evidence = collectGithubEvidence({ + cwd: "/repo", + execFileSync(command) { + const error = new Error(`${command} unavailable`); + error.code = "ENOENT"; + throw error; + }, }); - assert.equal(switched.selection, "local"); - assert.equal(fs.readFileSync(path.join(root, "backlog/.tracker"), "utf8"), "local\n"); - assert.equal(fs.readFileSync(configPath, "utf8"), raw); - }); - - it(".tracker overrides stale YAML and explicit changes touch only .tracker", async (t) => { - const root = makeRoot(t); - const configPath = writeConfig(root, "project_name: stable\ntracker: github\n# untouched\n"); - const trackerPath = writeTracker(root, "local\n"); - const beforeConfig = snapshotTree(root)["backlog/config.yml"]; - const preserved = await runSetup({ cwd: root, nonInteractive: true }); - assert.equal(preserved.selection, "local"); - assert.equal(preserved.selectionSource, "preserved"); - - await runSetup({ cwd: root, tracker: "github", nonInteractive: true }); - assert.equal(fs.readFileSync(trackerPath, "utf8"), "github\n"); - assert.deepEqual(snapshotTree(root)["backlog/config.yml"], beforeConfig); + assert.equal(evidence.recommendation, "github"); + assert.equal(evidence.auth, "not-checked"); }); - it("rewrites a non-terminated selection once and then reruns as a no-op", async (t) => { - const root = makeRoot(t); - const trackerPath = writeTracker(root, "local"); - const first = await runSetup({ cwd: root, nonInteractive: true }); - assert.equal(first.trackerChanged, true); - assert.equal(fs.readFileSync(trackerPath, "utf8"), "local\n"); - const before = snapshotTree(path.join(root, "backlog")); - const second = await runSetup({ cwd: root, nonInteractive: true }); - assert.equal(second.trackerChanged, false); - assert.deepEqual(snapshotTree(path.join(root, "backlog")), before); - }); - - it("cleans temp files and preserves config and selection on atomic failures", async (t) => { - for (const failure of ["write", "rename"]) { - const root = makeRoot(t, `setup-atomic-${failure}-`); - const configPath = writeConfig(root, "project_name: atomic\n# exact bytes\n"); - const trackerPath = writeTracker(root, "local\n"); - const before = snapshotTree(root); - const fsApi = { - ...fs, - writeFileSync(file, content, options) { - if (failure === "write" && path.basename(file).includes(".tracker.") && - path.basename(file).endsWith(".tmp")) { - fs.writeFileSync(file, "partial", options); - throw new Error("injected write failure"); - } - return fs.writeFileSync(file, content, options); - }, - renameSync(from, to) { - if (failure === "rename" && to === trackerPath) { - throw new Error("injected rename failure"); - } - return fs.renameSync(from, to); - }, - }; - await assert.rejects( - runSetup( - { cwd: root, tracker: "github", nonInteractive: true }, - { fs: fsApi } - ), - /injected/ - ); - assert.equal(fs.readFileSync(configPath, "utf8"), "project_name: atomic\n# exact bytes\n"); - assert.equal(fs.readFileSync(trackerPath, "utf8"), "local\n"); - const after = snapshotTree(root); - assert.deepEqual(after["backlog/.tracker"], before["backlog/.tracker"]); - assert.deepEqual(after["backlog/config.yml"], before["backlog/config.yml"]); - assert.deepEqual(fs.readdirSync(path.join(root, "backlog")).sort(), [ - ".tracker", "config.yml", - ]); - } - }); - - it("rejects an unsafe .tracker symlink before mutation", async (t) => { - const root = makeRoot(t); - const outside = makeRoot(t, "setup-outside-"); - fs.mkdirSync(path.join(root, "backlog")); + it("rejects unsafe .tracker publication paths", async (t) => { + const cwd = root(t); + const outside = root(t, "setup-outside-"); + fs.mkdirSync(path.join(cwd, "backlog")); try { - fs.symlinkSync(path.join(outside, "selection"), path.join(root, "backlog/.tracker")); + fs.symlinkSync(path.join(outside, "selection"), path.join(cwd, "backlog/.tracker")); } catch (error) { if (process.platform === "win32" && error.code === "EPERM") { - t.skip("Windows symlink privilege is unavailable"); + t.skip("Windows symlink privilege unavailable"); return; } throw error; } - const before = snapshotTree(root); + const before = snapshot(cwd); await assert.rejects( - runSetup({ cwd: root, tracker: "local", nonInteractive: true }), - /unsafe tracker path/ + runSetup({ cwd, tracker: "github", nonInteractive: true }), + SetupError, ); - assert.deepEqual(snapshotTree(root), before); - assert.deepEqual(fs.readdirSync(outside), []); + assert.deepEqual(snapshot(cwd), before); }); -}); -describe("CLI and compatibility wrapper", () => { - it("emits structured output and writes a newline-terminated selection", (t) => { - const root = makeRoot(t); - const run = spawnSync( - process.execPath, - [SCRIPT, "--tracker", "local", "--non-interactive", "--json", "--project-name", "cli-demo"], - { cwd: root, encoding: "utf8" } - ); - assert.equal(run.status, 0, run.stderr); - const result = JSON.parse(run.stdout); - assert.equal(result.selection, "local"); - assert.equal(result.projectName, "cli-demo"); - assert.equal(fs.readFileSync(path.join(root, "backlog/.tracker"), "utf8"), "local\n"); - assert.equal(fs.existsSync(path.join(root, "backlog/config.yml")), false); - }); - - it("init.sh preserves fresh github meaning and migrates legacy local", (t) => { - const fresh = makeRoot(t, "setup-init-fresh-"); - const freshRun = spawnBashSync([INIT, "wrapper-demo"], { - cwd: fresh, - encoding: "utf8", - }); - assert.equal(freshRun.status, 0, freshRun.stderr); - assert.equal(fs.readFileSync(path.join(fresh, "backlog/.tracker"), "utf8"), "github\n"); - assert.equal(fs.existsSync(path.join(fresh, "backlog/config.yml")), false); + it("keeps the init.sh compatibility entrypoint GitHub-only", (t) => { + const cwd = root(t); + const result = spawnSync("bash", [INIT, "demo"], { cwd, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(path.join(cwd, "backlog/.tracker"), "utf8"), "github\n"); - const legacy = makeRoot(t, "setup-init-legacy-"); - const raw = "project_name: existing\ntracker: local\n# keep\n"; - const configPath = writeConfig(legacy, raw); - const legacyRun = spawnBashSync([INIT, "ignored"], { - cwd: legacy, - encoding: "utf8", - }); - assert.equal(legacyRun.status, 0, legacyRun.stderr); - assert.equal(fs.readFileSync(path.join(legacy, "backlog/.tracker"), "utf8"), "local\n"); - assert.equal(fs.readFileSync(configPath, "utf8"), raw); + const cli = spawnSync(process.execPath, [ + SCRIPT, "--tracker", "local", "--non-interactive", + ], { cwd: root(t, "setup-cli-local-"), encoding: "utf8" }); + assert.notEqual(cli.status, 0); + assert.match(cli.stderr, /expected github/); }); }); diff --git a/skills/dev-backlog/scripts/tracker-cycle.acceptance.test.js b/skills/dev-backlog/scripts/tracker-cycle.acceptance.test.js index 604033b..3eef135 100644 --- a/skills/dev-backlog/scripts/tracker-cycle.acceptance.test.js +++ b/skills/dev-backlog/scripts/tracker-cycle.acceptance.test.js @@ -6,11 +6,8 @@ const path = require("node:path"); const { spawnSync } = require("node:child_process"); const { resolveBashExecutable, toBashArgs } = require("./bash-runtime.js"); -const trackerModule = require("./tracker.js"); - const SCRIPTS_DIR = __dirname; const TRACKER_PATH = path.join(SCRIPTS_DIR, "tracker.js"); -const SETUP_PATH = path.join(SCRIPTS_DIR, "setup-dev-backlog.js"); const SYNC_PATH = path.join(SCRIPTS_DIR, "sync-pull.js"); const SPRINT_INIT_PATH = path.join(SCRIPTS_DIR, "sprint-init.js"); const SPRINT_CLOSE_PATH = path.join(SCRIPTS_DIR, "sprint-close.sh"); @@ -184,19 +181,6 @@ childProcess.execFileSync = function (command, args, options) { }; } -function writeGhTrap(root) { - const binDir = path.join(root, "bin"); - const marker = path.join(root, "gh-was-called"); - fs.mkdirSync(binDir, { recursive: true }); - const ghPath = path.join(binDir, "gh"); - fs.writeFileSync(ghPath, `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(marker)}\nexit 97\n`); - fs.chmodSync(ghPath, 0o755); - return { - env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH || ""}` }, - calls: () => fs.existsSync(marker) ? fs.readFileSync(marker, "utf8").trim().split("\n") : [], - }; -} - function prepareGithub(t) { const root = makeRoot(t, "tracker-cycle-github-"); const backlogDir = path.join(root, "backlog"); @@ -230,35 +214,6 @@ function prepareMirrorlessGithub(t) { }; } -function prepareLocal(t) { - const root = makeRoot(t, "tracker-cycle-local-"); - const trap = writeGhTrap(root); - const setup = parseJsonResult(run(process.execPath, [ - SETUP_PATH, "--tracker", "local", "--non-interactive", "--json", "--project-name", "offline-cycle", - ], { cwd: root, env: trap.env }), "local setup"); - assert.equal(setup.selection, "local"); - return { - tracker: "local", root, cwd: root, backlogDir: path.join(root, "backlog"), - worker: writeWorker(root), env: trap.env, providerCalls: trap.calls, - }; -} - -const CYCLE_ROWS = [ - { tracker: "github", prepare: prepareGithub }, - { tracker: "local", prepare: prepareLocal }, -]; - -function writeLocalSprint(fixture, identity) { - const sprintPath = path.join(fixture.backlogDir, "sprints", "2026-07-local-cycle.md"); - fs.writeFileSync(sprintPath, [ - "---", "milestone: local cycle", "status: active", "started: 2026-07-12", "---", "", - "# Local cycle", "", "## Goal", "Prove the local core cycle.", "", "## Plan", "", - "### Batch 1 - local", `- [ ] ${identity.ref} Offline canonical task`, "", - "## Running Context", "Local files are canonical.", "", "## Progress", "", - ].join("\n")); - return sprintPath; -} - function orient(fixture) { const status = parseJsonResult( run("bash", [STATUS_PATH, "--json", fixture.backlogDir], fixture), @@ -413,134 +368,43 @@ function runMirrorlessGithubCycle(fixture) { assertNoTaskDirectories(); } -function runLocalCycle(fixture) { - const body = "\n## Description\nHuman local body\n\n## Acceptance Criteria\n- [ ] Keep this AC\n"; - const created = runWorker(fixture, "create", { id: "7.2", title: "Offline canonical task", body }); - assert.deepEqual(created, { tracker: "local", id: "7.2", ref: "BACK-7.2" }); - const taskPath = path.join(fixture.backlogDir, "tasks", "BACK-7.2 - offline-canonical-task.md"); - assert.equal(fs.readFileSync(taskPath, "utf8").endsWith(body), true); - - const sprintPath = writeLocalSprint(fixture, created); - const { status, next } = orient(fixture); - assert.deepEqual(status.plan_items.map(({ tracker, id, ref, issue_number }) => ({ tracker, id, ref, issue_number })), [ - { tracker: "local", id: "7.2", ref: "BACK-7.2", issue_number: null }, - ]); - assert.equal(next.next_batch.items[0].ref, "BACK-7.2"); - assert.equal(runWorker(fixture, "read", { selector: "BACK-7.2" }).body, body); - - const bodyBefore = fs.readFileSync(taskPath, "utf8").slice(fs.readFileSync(taskPath, "utf8").indexOf("\n## Description")); - runWorker(fixture, "update", { selector: "BACK-7.2", changes: { status: "In Progress" } }); - const afterUpdate = fs.readFileSync(taskPath, "utf8"); - assert.match(afterUpdate, /^status: In Progress$/m); - assert.equal(afterUpdate.slice(afterUpdate.indexOf("\n## Description")), bodyBefore); - - runWorker(fixture, "close", { selector: "BACK-7.2" }); - const archivedPath = path.join(fixture.backlogDir, "completed", "BACK-7.2 - offline-canonical-task.md"); - assert.equal(fs.existsSync(taskPath), false); - assert.match(fs.readFileSync(archivedPath, "utf8"), /^status: Done$/m); - assert.equal(fs.readFileSync(archivedPath, "utf8").slice(fs.readFileSync(archivedPath, "utf8").indexOf("\n## Description")), bodyBefore); - - finishSprint(fixture, sprintPath); - assert.deepEqual(runWorker(fixture, "list", { state: "open" }), []); - assert.equal(runWorker(fixture, "list", { state: "closed" })[0].ref, "BACK-7.2"); - const finalRead = runWorker(fixture, "read", { selector: "BACK-7.2" }); - assert.equal(finalRead.state, "closed"); - assert.equal(finalRead.status, "Done"); - assert.deepEqual(fixture.providerCalls(), [], "local cycle must make zero provider calls"); -} - -describe("tracker core cycle acceptance matrix", () => { - for (const row of CYCLE_ROWS) { - it(`${row.tracker}: setup/config → create → Plan → orient/read → update → complete → final read/list`, (t) => { - const fixture = row.prepare(t); - if (row.tracker === "github") runGithubCycle(fixture); - else runLocalCycle(fixture); - }); - } +describe("GitHub tracker core cycle acceptance", () => { + it("setup/config → create → Plan → orient/read → update → complete → final read/list", (t) => { + runGithubCycle(prepareGithub(t)); + }); }); describe("mirrorless GitHub core acceptance", () => { it("runs create → Plan → orient/effective read → update → complete with no task directories", (t) => { runMirrorlessGithubCycle(prepareMirrorlessGithub(t)); }); -}); -describe("typed unsupported-capability contract", () => { - for (const capability of trackerModule.CAPABILITY_NAMES) { - it(`local ${capability} fails before side effects with the shared serialized shape`, (t) => { - const fixture = prepareLocal(t); - const resolved = trackerModule.resolveConfiguredTracker({ tracker: "local" }, { backlogDir: fixture.backlogDir }); - let sideEffects = 0; - let caught; - try { - trackerModule.invokeCapability(resolved, capability, () => { sideEffects += 1; }); - } catch (error) { - caught = error; - } - assert.equal(sideEffects, 0); - assert.ok(caught instanceof trackerModule.UnsupportedTrackerCapabilityError); - assert.equal(typeof trackerModule.serializeTrackerError, "function"); - assert.deepEqual(trackerModule.serializeTrackerError(caught), { - code: "TRACKER_CAPABILITY_UNSUPPORTED", - tracker: "local", - capability, - message: `Tracker "local" does not support capability "${capability}".`, - remediation: `Use tracker "local" without "${capability}", or explicitly change ${path.join(fixture.backlogDir, ".tracker")} to a tracker that supports it before retrying. No tracker switch was attempted.`, - }); - assert.deepEqual(fixture.providerCalls(), []); - }); - } -}); + it("does not require Relay, Matt/craftkit, Projects, or Backlog.md tooling", (t) => { + const fixture = prepareMirrorlessGithub(t); + const optionalPaths = [ + path.join(fixture.root, ".relay"), + path.join(fixture.root, ".agents", "skills"), + path.join(fixture.root, "spec"), + path.join(fixture.root, "node_modules"), + path.join(fixture.backlogDir, "tasks"), + path.join(fixture.backlogDir, "completed"), + ]; + for (const optionalPath of optionalPaths) { + assert.equal(fs.existsSync(optionalPath), false, `${optionalPath} must start absent`); + } -describe("unsupported capability public CLI boundaries", () => { - const boundaries = [ - { name: "sprint-init", script: SPRINT_INIT_PATH, args: () => ["blocked", "--json"], capability: "milestones", configPath: () => "backlog/.tracker" }, - ]; - - for (const boundary of boundaries) { - it(`${boundary.name} emits one structured JSON error and matching human remediation`, (t) => { - const fixture = prepareLocal(t); - const before = snapshotFiles(fixture.backlogDir); - const boundaryArgs = boundary.args(fixture); - const json = run(process.execPath, [boundary.script, ...boundaryArgs], fixture); - assert.notEqual(json.status, 0); - assert.equal(json.stderr, ""); - const payload = JSON.parse(json.stdout); - assert.deepEqual(Object.keys(payload), ["error"]); - assert.deepEqual(payload.error, { - code: "TRACKER_CAPABILITY_UNSUPPORTED", - tracker: "local", - capability: boundary.capability, - message: `Tracker "local" does not support capability "${boundary.capability}".`, - remediation: `Use tracker "local" without "${boundary.capability}", or explicitly change ${boundary.configPath(fixture)} to a tracker that supports it before retrying. No tracker switch was attempted.`, - }); - - const humanArgs = boundaryArgs.filter((arg) => arg !== "--json"); - const human = run(process.execPath, [boundary.script, ...humanArgs], fixture); - assert.notEqual(human.status, 0); - assert.match(human.stderr, new RegExp(escapeRegExp(payload.error.remediation))); - assert.deepEqual(snapshotFiles(fixture.backlogDir), before, "capability failure must precede side effects"); - assert.deepEqual(fixture.providerCalls(), [], "capability failure must not call gh"); - assert.equal(fs.readFileSync(path.join(fixture.backlogDir, ".tracker"), "utf8"), "local\n"); - }); - } -}); + runMirrorlessGithubCycle(fixture); -function snapshotFiles(root) { - const snapshot = {}; - function walk(dir, relative = "") { - for (const name of fs.readdirSync(dir).sort()) { - const full = path.join(dir, name); - const key = path.join(relative, name); - const stat = fs.lstatSync(full); - if (stat.isDirectory()) walk(full, key); - else snapshot[key] = fs.readFileSync(full).toString("base64"); + for (const optionalPath of optionalPaths) { + assert.equal(fs.existsSync(optionalPath), false, `${optionalPath} must remain absent`); } - } - walk(root); - return snapshot; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} + assert.equal( + fixture.providerCalls().some((args) => + args[0] === "project" || + args.some((arg) => /projects(?:V2)?/i.test(String(arg))) + ), + false, + "core execution must not require GitHub Projects" + ); + }); +}); diff --git a/skills/dev-backlog/scripts/tracker.js b/skills/dev-backlog/scripts/tracker.js index 9e444a5..64fffa4 100644 --- a/skills/dev-backlog/scripts/tracker.js +++ b/skills/dev-backlog/scripts/tracker.js @@ -6,12 +6,11 @@ */ const { createGithubAdapter } = require("./github-tracker.js"); -const { createLocalAdapter } = require("./local-tracker.js"); const fs = require("node:fs"); const path = require("path"); const { configDisplayPath } = require("./portable-path.js"); -const TRACKER_KEYS = Object.freeze(["github", "local"]); +const TRACKER_KEYS = Object.freeze(["github"]); const REQUIRED_ADAPTER_OPERATIONS = Object.freeze([ "availability", "capabilities", @@ -72,20 +71,16 @@ class TrackerUnavailableError extends Error { } class UnsupportedTrackerCapabilityError extends Error { - constructor( - tracker, - capability, - configPath = configDisplayPath(DEFAULT_BACKLOG_DIR, TRACKER_SELECTION_FILE) - ) { + constructor(tracker, capability) { super(`Tracker "${tracker}" does not support capability "${capability}".`); this.name = "UnsupportedTrackerCapabilityError"; this.code = UNSUPPORTED_CAPABILITY_CODE; this.tracker = tracker; this.capability = capability; this.remediation = - `Use tracker "${tracker}" without "${capability}", or explicitly change ` + - `${configPath} to a tracker that supports it before retrying. ` + - "No tracker switch was attempted."; + `Use tracker "${tracker}" without "${capability}", or restore that ` + + "tracker's capability transport before retrying. " + + "No tracker switch or fallback was attempted."; } } @@ -230,7 +225,6 @@ function resolveConfiguredTracker(config, { const registered = adapters || { ...TRACKER_ADAPTERS, github: execFile ? createGithubAdapter({ execFile }) : TRACKER_ADAPTERS.github, - local: backlogDir ? createLocalAdapter({ backlogDir }) : TRACKER_ADAPTERS.local, }; const storedSelection = backlogDir ? readTrackerSelection(backlogDir, { fs: fsApi || fs }) @@ -241,7 +235,6 @@ function resolveConfiguredTracker(config, { ); return Object.freeze({ ...resolved, - configPath: configDisplayPath(backlogDir || DEFAULT_BACKLOG_DIR, TRACKER_SELECTION_FILE), }); } @@ -333,18 +326,13 @@ function invokeCapability(resolved, capability, operation, ...args) { const supported = readCapabilities(resolved.tracker, resolved.adapter); if (!supported.includes(capability)) { - throw new UnsupportedTrackerCapabilityError( - resolved.tracker, - capability, - resolved.configPath - ); + throw new UnsupportedTrackerCapabilityError(resolved.tracker, capability); } return operation(...args); } const TRACKER_ADAPTERS = Object.freeze({ github: createGithubAdapter(), - local: createLocalAdapter({ backlogDir: DEFAULT_BACKLOG_DIR }), }); module.exports = { @@ -370,5 +358,4 @@ module.exports = { resolveConfiguredTracker, invokeCapability, createGithubAdapter, - createLocalAdapter, }; diff --git a/skills/dev-backlog/scripts/tracker.test.js b/skills/dev-backlog/scripts/tracker.test.js index b5b311e..03a5589 100644 --- a/skills/dev-backlog/scripts/tracker.test.js +++ b/skills/dev-backlog/scripts/tracker.test.js @@ -14,375 +14,241 @@ const { TrackerUnavailableError, UnsupportedTrackerCapabilityError, invokeCapability, + readCapabilities, readTrackerSelection, resolveConfiguredTracker, resolveTracker, selectTracker, + serializeTrackerError, validateAdapter, validateIdentity, + writeTrackerCliError, } = require("./tracker.js"); -function makeLocalBacklog(t) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "tracker-local-")); - const backlogDir = path.join(root, "backlog"); - fs.mkdirSync(path.join(backlogDir, "tasks"), { recursive: true }); - fs.mkdirSync(path.join(backlogDir, "completed"), { recursive: true }); - fs.writeFileSync(path.join(backlogDir, ".tracker"), "local\n"); - fs.writeFileSync(path.join(backlogDir, "config.yml"), 'task_prefix: "BACK"\n'); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); - return backlogDir; -} - -function makeAdapter(overrides = {}) { +function adapter(overrides = {}) { return { availability: () => ({ available: true }), - capabilities: () => [], + capabilities: () => [...CAPABILITY_NAMES], list: () => [], - read: () => null, - create: (task) => task, - update: (task) => task, - close: (task) => task, + read: () => ({}), + create: (value) => value, + update: (value) => value, + close: (value) => value, ...overrides, }; } -describe("configured tracker selection", () => { - it("persists the github selection outside config.yml in this repository", () => { - const backlogDir = path.resolve(__dirname, "../../../backlog"); - assert.equal(fs.readFileSync(path.join(backlogDir, ".tracker"), "utf8"), "github\n"); - const configPath = path.resolve(__dirname, "../../../backlog/config.yml"); - const raw = fs.readFileSync(configPath, "utf8"); - assert.deepEqual(raw.split(/\r?\n/).filter((line) => /^tracker:/.test(line)), []); - }); - - it("uses github as the deterministic compatibility default", () => { - assert.equal(selectTracker({}), "github"); +describe("GitHub-only authority selection", () => { + it("defaults to and explicitly accepts only github", () => { assert.equal(selectTracker(), "github"); - }); - - it("accepts explicit github and local selections", () => { + assert.equal(selectTracker({}), "github"); assert.equal(selectTracker({ tracker: "github" }), "github"); - assert.equal(selectTracker({ tracker: "local" }), "local"); - }); - - it("reads .tracker with trim semantics and gives it precedence over legacy config", (t) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "tracker-selection-")); - const backlogDir = path.join(root, "backlog"); - fs.mkdirSync(backlogDir); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); - - assert.equal(readTrackerSelection(backlogDir), undefined); - assert.equal(resolveConfiguredTracker({ tracker: "local" }, { - backlogDir, - adapters: { github: makeAdapter(), local: makeAdapter() }, - }).tracker, "local"); - - fs.writeFileSync(path.join(backlogDir, ".tracker"), " github\r\n"); - assert.equal(readTrackerSelection(backlogDir), "github"); - assert.equal(resolveConfiguredTracker({ tracker: "local" }, { - backlogDir, - adapters: { github: makeAdapter(), local: makeAdapter() }, - }).tracker, "github"); - }); - - it("rejects invalid and non-string selections before adapter use", () => { - for (const value of ["gitlab", "", 7, false, null, undefined, [], {}]) { - let adapterRead = false; - const adapters = {}; - Object.defineProperty(adapters, "github", { - get() { - adapterRead = true; - return makeAdapter(); - }, - }); - + for (const value of ["local", "gitlab", "", 7, null]) { assert.throws( - () => resolveTracker({ tracker: value }, { adapters }), - (error) => { - const rendered = typeof value === "string" - ? value - : JSON.stringify(value) ?? String(value); - assert.ok(error instanceof TrackerConfigurationError); - assert.match(error.message, /github/); - assert.match(error.message, /local/); - assert.ok(error.message.includes(rendered)); - return true; - } + () => selectTracker({ tracker: value }), + (error) => error instanceof TrackerConfigurationError && /expected one of: github/.test(error.message), ); - assert.equal(adapterRead, false); - } - }); -}); - -describe("configured-only resolution", () => { - it("resolves the built-in github slot for missing and explicit selection", () => { - for (const config of [{}, { tracker: "github" }]) { - const resolved = resolveTracker(config); - assert.equal(resolved.tracker, "github"); - assert.equal(resolved.adapter, TRACKER_ADAPTERS.github); - assert.deepEqual(resolved.availability, { available: true }); } }); - it("resolves explicitly selected local against a usable store without github fallback", (t) => { - const backlogDir = makeLocalBacklog(t); - let githubProbes = 0; - const github = makeAdapter({ - availability: () => { - githubProbes += 1; - return { available: true }; - }, - }); - - const resolved = resolveConfiguredTracker( - { tracker: "local" }, - { backlogDir, adapters: undefined } - ); - - assert.equal(resolved.tracker, "local"); - assert.deepEqual(resolved.availability, { available: true }); - assert.deepEqual(resolved.adapter.capabilities(), []); - assert.equal(githubProbes, 0); - void github; - }); - - it("fails a malformed local store with an actionable reason and no fallback", (t) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "tracker-local-bad-")); + it("uses .tracker when present and rejects a retired local selection", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tracker-github-only-")); + const backlogDir = path.join(root, "backlog"); + fs.mkdirSync(backlogDir); t.after(() => fs.rmSync(root, { recursive: true, force: true })); - const filePath = path.join(root, "backlog"); - fs.writeFileSync(filePath, "not a directory"); + assert.equal(readTrackerSelection(backlogDir), undefined); + fs.writeFileSync(path.join(backlogDir, ".tracker"), "github\n"); + assert.equal(resolveConfiguredTracker({}, { backlogDir, adapters: { github: adapter() } }).tracker, "github"); + fs.writeFileSync(path.join(backlogDir, ".tracker"), "local\n"); assert.throws( - () => resolveConfiguredTracker({ tracker: "local" }, { backlogDir: filePath }), - (error) => { - assert.ok(error instanceof TrackerUnavailableError); - assert.equal(error.tracker, "local"); - assert.ok(error.reason && error.reason.trim().length > 0); - return true; - } + () => resolveConfiguredTracker({}, { backlogDir, adapters: { github: adapter() } }), + /Invalid tracker configuration "local"/, ); }); - it("probes and returns only the configured adapter", () => { - let githubProbes = 0; - let localProbes = 0; - const github = makeAdapter({ - availability: () => { - githubProbes += 1; - return { available: true }; - }, - }); - const local = makeAdapter({ - availability: () => { - localProbes += 1; - throw new Error("must not probe local"); - }, - }); - - const resolved = resolveTracker({ tracker: "github" }, { adapters: { github, local } }); - - assert.equal(resolved.tracker, "github"); - assert.equal(resolved.adapter, github); - assert.equal(githubProbes, 1); - assert.equal(localProbes, 0); - }); - - it("fails without fallback when the configured adapter is unavailable", () => { - let localProbes = 0; - const github = makeAdapter({ + it("never falls back when the GitHub adapter is unavailable", () => { + const github = adapter({ availability: () => ({ available: false, reason: "gh authentication expired" }), }); - const local = makeAdapter({ - availability: () => { - localProbes += 1; - return { available: true }; - }, - }); - assert.throws( - () => resolveTracker({ tracker: "github" }, { adapters: { github, local } }), - (error) => { - assert.ok(error instanceof TrackerUnavailableError); - assert.equal(error.tracker, "github"); - assert.equal(error.reason, "gh authentication expired"); - assert.match(error.message, /github/); - assert.match(error.message, /gh authentication expired/); - return true; - } + () => resolveTracker({}, { adapters: { github } }), + (error) => ( + error instanceof TrackerUnavailableError && + error.tracker === "github" && + /no fallback was attempted/i.test(error.message) + ), ); - assert.equal(localProbes, 0); }); - it("wraps throwing and unusable availability probes with configured-tracker context", () => { - for (const availability of [ - () => { - throw new Error("socket reset"); + it("normalizes throwing, undefined, malformed, and reasonless availability failures", () => { + const cases = [ + { + availability: () => { + throw new Error("socket reset"); + }, + reason: /availability probe threw: socket reset/, }, - () => undefined, - () => ({ available: "yes" }), - () => ({ available: false }), - ]) { + { + availability: () => undefined, + reason: /returned an unusable report/, + }, + { + availability: () => ({ available: "yes" }), + reason: /returned an unusable report/, + }, + { + availability: () => ({ available: false }), + reason: /reported unavailable without an actionable reason/, + }, + ]; + + for (const row of cases) { assert.throws( () => resolveTracker( { tracker: "github" }, - { adapters: { github: makeAdapter({ availability }), local: makeAdapter() } } + { adapters: { github: adapter({ availability: row.availability }) } }, ), (error) => { assert.ok(error instanceof TrackerUnavailableError); assert.equal(error.tracker, "github"); - assert.ok(error.reason); - assert.match(error.message, /github/); + assert.match(error.reason, row.reason); + assert.match(error.message, /no fallback was attempted/i); return true; - } + }, ); } }); }); -describe("adapter contract", () => { - it("contains exactly the seven required operations", () => { - assert.deepEqual(REQUIRED_ADAPTER_OPERATIONS, [ - "availability", - "capabilities", - "list", - "read", - "create", - "update", - "close", - ]); - }); - - it("validates both built-in adapter slots against the same exact shape", () => { +describe("retained adapter portability seam", () => { + it("keeps one exact operation shape for production and injected fake-gh adapters", () => { + assert.deepEqual(Object.keys(TRACKER_ADAPTERS), ["github"]); assert.equal(validateAdapter("github", TRACKER_ADAPTERS.github), TRACKER_ADAPTERS.github); - assert.equal(validateAdapter("local", TRACKER_ADAPTERS.local), TRACKER_ADAPTERS.local); - assert.deepEqual(Object.keys(TRACKER_ADAPTERS.github), REQUIRED_ADAPTER_OPERATIONS); - assert.deepEqual(Object.keys(TRACKER_ADAPTERS.local), REQUIRED_ADAPTER_OPERATIONS); + const injected = adapter(); + assert.equal(validateAdapter("github", injected), injected); + assert.deepEqual([...REQUIRED_ADAPTER_OPERATIONS], [ + "availability", "capabilities", "list", "read", "create", "update", "close", + ]); }); - it("rejects missing, non-function, and provider-specific required methods", () => { - const missingClose = makeAdapter(); + it("rejects malformed adapters before any operation", () => { + assert.throws( + () => resolveTracker({ tracker: "github" }, { adapters: {} }), + /No adapter is registered/, + ); + const missingClose = adapter(); delete missingClose.close; - assert.throws(() => validateAdapter("github", missingClose), TrackerContractError); + assert.throws(() => validateAdapter("github", missingClose), /missing: close/); assert.throws( - () => validateAdapter("github", makeAdapter({ read: true })), - TrackerContractError + () => validateAdapter("github", adapter({ read: true })), + /not functions: read/, ); assert.throws( - () => validateAdapter("github", { ...makeAdapter(), milestones: () => [] }), - TrackerContractError + () => validateAdapter("github", { ...adapter(), extra: () => {} }), + /not part of the required interface/, ); }); -}); -describe("normalized tracker identity", () => { - it("accepts opaque ids and an optional provider URL", () => { - const withoutUrl = { tracker: "local", id: "task:alpha/7", ref: "BACK-7" }; - const withUrl = { + it("validates GitHub identities and rejects missing, empty, extra, or invalid fields", () => { + const identity = { tracker: "github", - id: "I_kwDOOpaqueNodeId", - ref: "#273", - url: "https://github.com/sungjunlee/dev-backlog/issues/273", + id: "42", + ref: "#42", + url: "https://github.com/acme/widgets/issues/42", }; - - assert.equal(validateIdentity(withoutUrl), withoutUrl); - assert.equal(validateIdentity(withUrl), withUrl); - }); - - it("rejects missing, empty, fabricated, and extra identity fields", () => { - for (const identity of [ + assert.equal(validateIdentity(identity), identity); + for (const invalid of [ null, {}, - { tracker: "github", id: "id" }, - { tracker: "github", ref: "#1" }, - { tracker: "", id: "id", ref: "#1" }, - { tracker: "gitlab", id: "id", ref: "#1" }, - { tracker: "github", id: "", ref: "#1" }, - { tracker: "github", id: "id", ref: "" }, - { tracker: "github", id: "id", ref: "#1", url: "not a url" }, - { tracker: "github", id: "id", ref: "#1", number: 1 }, - Object.create({ tracker: "github", id: "id", ref: "#1" }), + { tracker: "github", id: "42" }, + { tracker: "github", ref: "#42" }, + { tracker: "", id: "42", ref: "#42" }, + { tracker: "local", id: "42", ref: "#42" }, + { tracker: "github", id: "", ref: "#42" }, + { tracker: "github", id: "42", ref: "" }, + { tracker: "github", id: "42", ref: "#42", url: "not a url" }, + { tracker: "github", id: "42", ref: "#42", number: 42 }, + Object.create({ tracker: "github", id: "42", ref: "#42" }), ]) { - assert.throws(() => validateIdentity(identity), TrackerIdentityError); + assert.throws(() => validateIdentity(invalid), TrackerIdentityError); } }); -}); -describe("local adapter and optional capabilities", () => { - it("registers a usable, capability-free local adapter in the default slot", () => { - const local = TRACKER_ADAPTERS.local; - assert.deepEqual(Object.keys(local), REQUIRED_ADAPTER_OPERATIONS); - assert.deepEqual(local.capabilities(), []); - - const report = local.availability(); - assert.equal(typeof report.available, "boolean"); - if (!report.available) { - assert.ok(report.reason && report.reason.trim().length > 0); + it("rejects unknown, duplicate, and non-array capability reports", () => { + for (const [reported, pattern] of [ + [["projects"], /unknown: projects/], + [["comments", "comments"], /duplicate: comments/], + [new Set(["comments"]), /must be reported as an array/], + ]) { + assert.throws( + () => readCapabilities("github", adapter({ capabilities: () => reported })), + pattern, + ); } }); - it("selects the local adapter without probing github when local is configured", (t) => { - const backlogDir = makeLocalBacklog(t); - const resolved = resolveConfiguredTracker({ tracker: "local" }, { backlogDir }); - const created = resolved.adapter.create({ title: "Seam smoke" }); - assert.deepEqual(created, { tracker: "local", id: "1", ref: "BACK-1" }); - assert.equal("url" in created, false); - }); - - it("reports only the four named optional provider capabilities", () => { - assert.deepEqual(CAPABILITY_NAMES, [ - "milestones", - "pull-request-relationships", - "comments", - "closing-semantics", - ]); - assert.deepEqual(TRACKER_ADAPTERS.github.capabilities(), CAPABILITY_NAMES); - assert.deepEqual(TRACKER_ADAPTERS.local.capabilities(), []); - }); - - it("rejects unsupported capability invocation before mutation", () => { - let mutations = 0; - const resolved = { - tracker: "local", - adapter: TRACKER_ADAPTERS.local, - }; - + it("retains capability gates for injected transports without adding providers", () => { + const noProjects = adapter({ capabilities: () => [] }); + const resolved = { tracker: "github", adapter: noProjects }; + assert.deepEqual(readCapabilities("github", TRACKER_ADAPTERS.github), [...CAPABILITY_NAMES]); assert.throws( - () => invokeCapability(resolved, "comments", () => { - mutations += 1; - }), - (error) => { - assert.ok(error instanceof UnsupportedTrackerCapabilityError); - assert.equal(error.tracker, "local"); - assert.equal(error.capability, "comments"); - assert.match(error.message, /local/); - assert.match(error.message, /comments/); - return true; - } + () => invokeCapability(resolved, "milestones", () => "effect"), + UnsupportedTrackerCapabilityError, ); - assert.equal(mutations, 0); }); - it("names the selected custom backlog tracker file in unsupported remediation", (t) => { - const backlogDir = makeLocalBacklog(t); - const resolved = resolveConfiguredTracker({ tracker: "local" }, { backlogDir }); - - assert.throws( - () => invokeCapability(resolved, "milestones", () => undefined), - (error) => { - assert.ok(error instanceof UnsupportedTrackerCapabilityError); - assert.ok(error.remediation.includes(path.join(backlogDir, ".tracker"))); - assert.equal(error.remediation.includes("change backlog/.tracker"), false); - return true; - } + it("serializes unsupported capability errors for JSON and human CLIs", () => { + const error = new UnsupportedTrackerCapabilityError("github", "comments"); + const serialized = serializeTrackerError(error); + assert.deepEqual(serialized, { + code: "TRACKER_CAPABILITY_UNSUPPORTED", + tracker: "github", + capability: "comments", + message: 'Tracker "github" does not support capability "comments".', + remediation: + 'Use tracker "github" without "comments", or restore that ' + + "tracker's capability transport before retrying. " + + "No tracker switch or fallback was attempted.", + }); + assert.equal(serializeTrackerError(new Error("other")), null); + + const jsonWrites = []; + const humanWrites = []; + assert.equal(writeTrackerCliError(error, { + json: true, + stdout: { write: (value) => jsonWrites.push(value) }, + stderr: { write: () => assert.fail("JSON errors must not use stderr") }, + }), true); + assert.deepEqual(JSON.parse(jsonWrites.join("")), { error: serialized }); + + assert.equal(writeTrackerCliError(error, { + prefix: "dev-backlog: ", + stdout: { write: () => assert.fail("human errors must not use stdout") }, + stderr: { write: (value) => humanWrites.push(value) }, + }), true); + assert.equal( + humanWrites.join(""), + `dev-backlog: ${serialized.message}\n${serialized.remediation}\n`, ); + assert.equal(writeTrackerCliError(new Error("other"), { + stdout: { write: () => assert.fail("unrecognized errors must not be written") }, + stderr: { write: () => assert.fail("unrecognized errors must not be written") }, + }), false); }); - it("invokes a supported capability only after the gate succeeds", () => { + it("runs a supported capability effect exactly once and returns its value", () => { + let effects = 0; const resolved = { tracker: "github", - adapter: makeAdapter({ capabilities: () => ["comments"] }), + adapter: adapter({ capabilities: () => ["comments"] }), }; - const result = invokeCapability(resolved, "comments", (value) => `commented:${value}`, 273); - assert.equal(result, "commented:273"); + const result = invokeCapability(resolved, "comments", (issue) => { + effects += 1; + return `commented:${issue}`; + }, 42); + + assert.equal(result, "commented:42"); + assert.equal(effects, 1); }); }); diff --git a/spec/capabilities.md b/spec/capabilities.md index a262480..2f70f1f 100644 --- a/spec/capabilities.md +++ b/spec/capabilities.md @@ -11,8 +11,8 @@ The former `spec-charter`, `spec-system-map`, and `spec-grill` capability blocks The 2026-08 target authority boundary is [`../skills/dev-backlog/references/authority-contract.md`](../skills/dev-backlog/references/authority-contract.md). The human-confirmed 2026-07-31 amendment makes GitHub Issues the sole task and -lifecycle authority. Compatibility code retained during the staged migration -does not widen these capability contracts. +lifecycle authority. The zero-adopter local tracker is removed; one-way legacy +import/export does not widen these capability contracts. --- @@ -24,7 +24,7 @@ does not widen these capability contracts. - Live GitHub Issue list/read/create/update/close lifecycle - Stable `#N` identity, Issue URLs, labels, milestone, assignees, and native relationships - Fail-loud GitHub availability and authentication errors -- Read-only compatibility during the staged retirement of local-tracker and task-mirror paths +- Read-only compatibility for explicit one-way Backlog.md import/export **Out-of-scope:** - Synchronizing multiple canonical trackers @@ -59,6 +59,7 @@ does not widen these capability contracts. | 2026-07-26 | Make `backlog/local-tracker.json` the sole local task authority and derive both Markdown directories from it | one atomic JSON store removes Markdown/YAML round-trip and close-compensation machinery while satisfying the no-co-authority constraint directly | canonical local Markdown shape from PR #298 | | 2026-07-27 | Move tracker selection to `backlog/.tracker`; `config.yml` becomes a read-only legacy fallback that is never written | the selection tokenizer existed only to write one key into user-owned YAML safely, so not writing removes its reason to exist and preserves user bytes permanently; the legacy read refuses authority-obscuring shapes rather than decoding them | tracker key in `config.yml` from PR #301 | | 2026-07-31 | Narrow the target capability to live GitHub Issue authority and freeze adapter/mirror expansion | 0 of 17 observed consumers selected a non-default tracker, and all 18 known consumers had a GitHub remote; compatibility remains only long enough to stage resolver and mirrorless pilots safely | 2026-07-11 generic configured-tracker target | +| 2026-07-31 | Remove the zero-adopter local tracker and retain Backlog.md only as one-way legacy import/export | the mirrorless GitHub pilot and optional-integration absence tests preserve the complete core path while deleting an unmeasured storage substrate | 2026-07-26 local JSON authority; staged local compatibility in the preceding 2026-07-31 decision | --- @@ -84,9 +85,7 @@ does not widen these capability contracts. - One successful `sprint-close.sh` invocation flips the sprint to `status: completed` and appends final Progress. Mirrorless GitHub requires no task directories; checked legacy GitHub mirrors are archived only when - present. Local tasks are closed separately through the configured adapter, - which updates canonical JSON and its completed projection before sprint - close finalizes the sprint. + present. ### Hard Constraints - Never mutate a sprint's `status: completed` back to `active`; completed sprints are immutable history. diff --git a/spec/charter.md b/spec/charter.md index a6a5598..cd59479 100644 --- a/spec/charter.md +++ b/spec/charter.md @@ -64,4 +64,5 @@ No server, no daemon, no hidden state, no silent sync. | 2026-07-27 | Objective status splits `implemented` (producer-side proof) from `validated` (cited use outside this repo); O8 and O9 move to `implemented` | measured 2026-07-27 across 17 other repos consuming dev-backlog: `local` has 0 adopters and 0 set a non-default tracker, yet both objectives read `validated` on merged-PR proof while v0.9.0 was deleting 2,556 lines from one of those axes for being built at the wrong size. Vocabulary defined by craftkit `spec-charter` (craftkit#165) | — | | 2026-07-28 | O8 stays `[implemented]` by design: `local` proved the seam admits a non-GitHub adapter, and user adoption of `local` is not a goal of O8 | measured 2026-07-28: all 18 repos consuming dev-backlog have a GitHub remote, so `local`'s premise has zero instances and O8 cannot reach `[validated]` by waiting. Restating the objective's purpose is honest; manufacturing an adopter or deleting a working adapter is not. O8's predicate also drops `mirror` and `progress` because #340 deleted that behavior — that is removing a reference to deleted code, **not** weakening a predicate so its proof looks sufficient | — | | 2026-07-31 | GitHub Issues become the sole task-definition/lifecycle authority; sprints are admitted by execution complexity, while tracker generalization and task mirrors leave the target product boundary | measured adoption found 0 of 17 consumers selecting a non-default tracker and all 18 known consumers on GitHub; preserving unused generality would prolong dual-state and compatibility cost without user evidence. O8/O9 cease to direct product work but remain implemented historical IDs so completed sprint references still resolve | 2026-07-11 configured-tracker direction; 2026-07-28 O8 retention | +| 2026-07-31 | Remove the zero-adopter local tracker; retain Backlog.md only as explicit one-way legacy import/export | mirrorless GitHub execution covers create/read/update/close and sprint continuity without task directories or optional ecosystem tools, so the unused local substrate has no remaining product or portability invariant | 2026-07-26 local JSON authority decision | | 2026-07-31 | Relay, Matt Pocock skills, GitHub Projects, Backlog.md compatibility, and retrieval/memory experiments remain optional projections or techniques | the standalone Issue → PR and complex-sprint paths must survive without ecosystem dependencies; projections cannot acquire write authority, and memory requires a separate measured gate | — | diff --git a/spec/system-map.md b/spec/system-map.md index d2487a3..97ebac5 100644 --- a/spec/system-map.md +++ b/spec/system-map.md @@ -3,7 +3,7 @@ ## System Shape dev-backlog is a skill suite plus deterministic Node/Bash helpers. The target -core reads task definition and lifecycle from GitHub Issues. Sprint Markdown is +core reads task definition and lifecycle from GitHub Issues. There is no required task mirror. Sprint Markdown is created only for complex execution continuity; optional projections never accept independent writes. @@ -21,8 +21,7 @@ GitHub repository history (historical evidence) retrieval / Projects / Relay / Backlog.md (optional, non-authoritative) ``` -The following is the transition implementation shape, retained while the live -resolver, mirrorless pilot, and compatibility subtraction land: +The implementation shape is GitHub-only: ```text backlog/.tracker @@ -30,11 +29,8 @@ backlog/.tracker v tracker.js (configured-only resolve, availability, capability gate) | - +-- github-tracker.js -> gh -> GitHub Issues (canonical) - | `-> optional legacy export - | - `-- local-tracker.js -> backlog/local-tracker.json (canonical) - `-> backlog/tasks/ + completed/ derived mirrors + `-- github-tracker.js -> gh -> GitHub Issues (canonical) + `-> explicit one-way legacy export backlog/sprints/ (canonical execution hub) +-> sprint-state.js -> status.sh --json / next.sh --json @@ -42,13 +38,10 @@ backlog/sprints/ (canonical execution hub) `-> capability-gated GitHub optional transports ``` -`setup-dev-backlog.js` persists a deliberate `github` or `local` choice in -`.tracker`. A missing file falls back to a legacy `config.yml` key, then to -GitHub, without runtime mutation. Setup migrates the resolved legacy choice -without editing `config.yml`. Availability failure is never a selection -mechanism, and runtime never probes or falls back to the other adapter. -These compatibility paths are frozen; they do not alter the target authority -contract. +`setup-dev-backlog.js` persists `github` in `.tracker`. A missing file accepts +only a legacy `tracker: github` config key, then defaults to GitHub without +runtime mutation. Any other selection fails. Availability failure is never a +selection mechanism or fallback trigger. ## Runtime Boundaries @@ -59,14 +52,14 @@ contract. work has no separate sprint state. - `skills/dev-backlog/scripts/tracker.js` owns configured resolution, the exact seven-operation adapter contract, identity validation, capability discovery/gating, and the shared unsupported-capability error/serializer. - `github-tracker.js` owns required GitHub task lifecycle argv/translation. Named GitHub modules own milestones, PR relationships, comments, and other optional transports. -- `local-tracker.js` owns the canonical local JSON lifecycle and its one-way Markdown projection. It reports no optional provider capabilities and never invokes `gh`. -- `task-ref.js` owns complete `#N` and `{PREFIX}-N[.M]` parsing/rendering. GitHub keeps numeric `issue_number`; local exposes `null` for that compatibility alias. +- `task-ref.js` owns complete `#N` runtime parsing/rendering plus historical Backlog.md filename parsing for explicit import/export. GitHub keeps numeric `issue_number`. - `sprint-state.js` remains the single machine parser of sprint Markdown; `status.sh --json`, `next.sh --json`, and doctor projections consume its state. - `skills/backlog-triage/` owns advisory grooming. Provider enrichment/mutation remains capability-gated and explicit. - Craftkit-installed spec authoring skills own human-gated changes to `spec/`; dev-backlog reads those files as optional yardsticks. -Detailed adapter mechanics, the pre-seam inventory, and the compatibility matrix -are single-sourced in [`docs/tracker-adapter-design.md`](../docs/tracker-adapter-design.md). +The retained seam inventory and compatibility subtraction evidence are +single-sourced in +[`docs/compatibility-subtraction.md`](../docs/compatibility-subtraction.md). ## Core Flows @@ -83,16 +76,16 @@ are single-sourced in [`docs/tracker-adapter-design.md`](../docs/tracker-adapter - `backlog/sprints/`: admitted complex execution state, committed at explicit boundaries. - `spec/*`: human-gated durable project, system, and capability decisions. - GitHub repository history: original historical evidence. -- `backlog/.tracker`, `backlog/config.yml`, and `backlog/local-tracker.json`: frozen transition compatibility. -- `backlog/tasks/` and `backlog/completed/`: non-authoritative transition projections. -- `gh`: GitHub-mode bridge only; acceptance tests replace it with an argv recorder and local tests trap it. +- `backlog/.tracker` and `backlog/config.yml`: GitHub selection and frozen legacy Backlog.md settings. +- `backlog/tasks/` and `backlog/completed/`: non-authoritative one-way legacy exports. +- `gh`: GitHub bridge; acceptance tests replace it with an argv recorder. - Git: versioned Markdown, scripts, and durable specs. ## Project-Wide Invariants - GitHub Issues own task truth; no runtime fallback, co-authority, dual write, or background sync. - Existing tracker-less repositories remain GitHub-backed with zero migration and unchanged `#N`, numeric aliases, task-mirror bytes, argv, milestones, comments, and closing behavior. -- Local compatibility is frozen pending staged retirement. It never fabricates provider semantics or URLs. +- Unknown tracker selections fail explicitly; GitHub unavailability never falls back to another store. - Task projections are diagnostic/export material only. A failed live Issue read stops execution; stale projection bytes cannot authorize task work or lifecycle changes. - Unsupported optional capabilities have stable code `TRACKER_CAPABILITY_UNSUPPORTED`, tracker, capability, message, and remediation; JSON and human boundaries share that one serializer contract. - A sprint is triggered by execution complexity, never duration alone; the @@ -105,18 +98,16 @@ are single-sourced in [`docs/tracker-adapter-design.md`](../docs/tracker-adapter ## Executable Evidence -`skills/dev-backlog/scripts/tracker-cycle.acceptance.test.js` proves both full -cycles with real temporary files and subprocesses, no network, exact GitHub -compatibility evidence, local zero-provider evidence, body-preserving updates, -Done archive/final reads, and every optional-capability failure shape. This -implementation proof merged as PR #303 (2026-07-12). It is transition evidence, -not a current objective or a reason to expand generic tracker compatibility. +`skills/dev-backlog/scripts/tracker-cycle.acceptance.test.js` proves the full +GitHub lifecycle and mirrorless cycle with real temporary files and +subprocesses, no network, exact argv, live effective-spec reads, and no +Relay/Matt/craftkit/Projects/Backlog.md runtime dependency. ## Accepted Capability Contracts - `sprint-execution` — plan state, context, progress, and active/completed sprint invariants. -- `tracker-task-truth` — live GitHub Issue ownership and lifecycle, with frozen transition compatibility. -- `backlog-sync` — safe, non-authoritative transition projection pending retirement. +- `tracker-task-truth` — live GitHub Issue ownership and lifecycle. +- `backlog-sync` — explicit, one-way, non-authoritative legacy export. - `triage-grooming` — advisory classification, relationships, stale signals, Alignment, and Decision Review. ## Optional Boundaries @@ -133,4 +124,4 @@ milestone. - Capability contracts: [`capabilities.md`](capabilities.md) - Sprint execution contract: [`../skills/dev-backlog/SKILL.md`](../skills/dev-backlog/SKILL.md) - Actor/JSON contract: [`../skills/dev-backlog/references/integration-contract.md`](../skills/dev-backlog/references/integration-contract.md) -- Adapter compatibility/proof: [`../docs/tracker-adapter-design.md`](../docs/tracker-adapter-design.md) +- Compatibility subtraction/proof: [`../docs/compatibility-subtraction.md`](../docs/compatibility-subtraction.md)