Commit Graph

20 Commits

Author SHA1 Message Date
Tom Boucher
9de4d67118 fix(#3579): a pointer-less session inherits the repo active-workstream marker (#3616)
* test(3579): failing-first coverage for repo-marker inheritance

A session that carries an identity but has never run 'workstream use' reads an
absent session pointer, resolves null, and composes the flat .planning tree even
when .planning/active-workstream names a live workstream. These tests fail on that
and pin the invariants the fix must not break: a session with its own pointer is
never repointed, and a session that merely lacked a pointer must never clear the
shared marker on another session's behalf.

* fix(3579): a pointer-less session inherits the repo active-workstream marker

RED proven at 157cae26: the three inheritance tests failed while every isolation and
negative control passed on base — the gap, and nothing else.

pickActiveWorkstreamAdapter returned exactly ONE adapter: the session-scoped one
whenever a session key existed, so the shared .planning/active-workstream marker was
never consulted. getWorkstreamSessionKey resolves a key from ~13 env vars or the
controlling TTY, so on any normal interactive terminal a key almost always exists —
which is why a session that had never run 'workstream use' read an absent pointer,
resolved null, and composed the FLAT planning tree even though the repo marker named a
live workstream. Reads misreported; writes corrupted the superseded flat STATE. Silent,
because the stale tree is well-formed.

This was a genuine design fork, not an oversight: references/workstream-flag.md
documented step 4 as a fallback 'when no session key exists', and the session isolation
that buys is deliberate (#2850). The issue's Agent Brief left the choice open and said
the reference doc should match whatever semantics ship. The maintainer ruled in chat for
inheritance.

Resolution now walks an ORDERED chain — session adapter first, shared second — and only
a null from the session adapter falls through to the marker. Strictly additive: it can
only turn a null into a name, never change a name that already resolves.

The dangerous part is clear() ownership. resolveFromChain treats chain[0] as owned: only
it is ever cleared, and only under selfHeal (getActiveWorkstream, never peek). An
INHERITED marker is read-only — a stale value there resolves null and the file is left
alone. Without that, one pointer-less session's read would delete the repo marker for
every other session, which is a worse bug than the one being fixed. Covered by a test
that asserts the marker still exists on disk after such a read.

peekActiveWorkstream inherits but still mutates nothing (#2850 — the statusline draws on
every render).

references/workstream-flag.md's Resolution Priority is rewritten to match, keeping the
session-isolation rationale and noting that inheritance does not weaken it: a session
that owns a pointer is never repointed.

Fixes #3579

* fix(3579): correct the guard diagnostics and lock the clear-semantics

Three review passes; every finding fixed inline.

MISSING ACCEPTANCE CRITERION (spec pass). The brief requires refusal diagnostics that
distinguish 'marker present but the session lookup missed it' from 'no workstream set at
all', and the two workstream-mode fail-safe guards were byte-for-byte untouched — still
emitting a generic 'no active workstream is set' even when a marker exists and merely
names a missing directory. Both guards (cmdPhaseComplete, cmdInitProgress) now branch on
a new read-only diagnoseUnresolvedActiveWorkstream, which reuses the SAME
resolvesToExistingWorkstream predicate resolveFromChain uses, so the diagnosis and the
resolution cannot disagree. Two typed reasons added to ERROR_REASON; both arms still
refuse — the fail-closed behavior is unchanged, only the message is now true.

REAL TEST FAILURE, not a flake. The remote run failed 'clearing one session does not
clear another session pointer'. That describe uses before() rather than beforeEach, so
one tmpDir is shared and an earlier test writes active-workstream=beta into it; under
inheritance the just-cleared session picks that marker up and resolves beta instead of
null. The failure is a CORRECT consequence of Option A surfaced through an
order-dependent fixture. The test now establishes its own marker state explicitly — its
real intent (clearing A must not disturb B's pointer) is preserved and not weakened — and
a new test pins the semantic deliberately: clearing a session pointer returns that
session to INHERITING the marker, it does not force flat mode. Documented in
references/workstream-flag.md, including how to actually get flat behavior.

Also from review: partial activeWorkstreamAdapters injection no longer silently
synthesizes a REAL filesystem adapter for the missing half (a latent test-isolation
trap); the duplicated validate-then-existsSync logic is factored into one predicate; and
the two try/finally test bodies are converted to t.after per CONTRIBUTING.

New coverage: whitespace/empty shared marker; a session whose OWN pointer is stale while
the marker names a different valid workstream (must self-heal to null, never inherit —
the isolation guarantee at its sharpest); and both new diagnostic arms asserted on
structured --json-errors output rather than prose.

* fix(3579): read resolvability with the non-mutating peek, not the self-healing resolver

Three of our own new tests failed on 7f5e706a. All three had ONE root cause, and none
was fixed by relaxing an assertion.

gsd-tools.cjs's bootstrap called the MUTATING getActiveWorkstream unconditionally on
every invocation, purely to populate routing env. On an unresolvable pointer that
self-healed — cleared it — BEFORE the dispatched command ran its own resolution. A second
read in the same process then observed already-cleared state:

- Isolation violation: a session whose own pointer was stale had it cleared by the
  bootstrap, so cmdWorkstreamGet's own resolution found a pointer-LESS session and
  inherited the shared marker ('beta' instead of null). Exactly the guarantee #2850 exists
  to protect, defeated across two calls rather than within one.
- Guard diagnostics: the guards' own truthiness check also used the mutating resolver, so
  it cleared the invalid marker and the immediately-following read-only diagnosis found
  nothing and reported none_active instead of marker_unresolved.

So a single invocation's answer depended on how many times it resolved. The bootstrap
self-heal is PRE-EXISTING and was harmless while pointer-less meant flat — inheritance is
what made it answer-changing, so this fix belongs here.

Every call site that only CHECKS resolvability — the bootstrap, both fail-safe guards'
truthiness check, and two informational init report fields — now uses the non-mutating
peekActiveWorkstream. Self-heal is unchanged in active-workstream-store and still fires
exactly once, at whichever site actually consumes the workstream.

Verified by driving the real CLI against temp fixtures, since the suite cannot run
locally: stale-own-pointer resolves null with the marker intact; both guard arms report
marker_unresolved with missing_workstream_dir / invalid_name and the marker survives;
no-marker still reports none_active; identity-less self-heal still deletes an invalid
marker byte-identically to pre-#3579; and a session with a valid own pointer still wins.

* chore(3579): backfill changeset PR number (#3616)

* test(3579): kill the surviving mutants in the new resolution code

CI's Stryker gate failed: active-workstream-store scored 79.45% against a break
threshold of 80 — 259 killed, 67 survived, at 'Ran 1.00 tests per mutant on average'.
The survivors cluster in the code this PR added (pickActiveWorkstreamAdapterChain,
resolvesToExistingWorkstream, resolveFromChain, diagnoseUnresolvedActiveWorkstream):
the CLI-level tests exercise those paths but do not DISCRIMINATE their branches, which
is precisely what a surviving mutant means.

Raised by strengthening assertions, never by touching the threshold. 21 unit tests added
to the existing unit suite, each written to fail under a specific named mutant, using the
module's injected adapter seams and createMemoryPointerAdapter so they stay hermetic
under Stryker's per-mutant reruns:

- chain shape with and without a session key, asserting length AND element identity
  (kills the if(false), the ': []' array mutant, and the block removal)
- partial adapter injection, asserting the missing half is an inert memory adapter that
  never touches the filesystem (kills the three '??' -> '&&' mutants)
- both arms of '!name || !validateWorkstreamName(name)' as SEPARATE tests — an absent
  name and a non-empty invalid one — which is what kills the '||' -> '&&' mutant
- self-heal discrimination: getActiveWorkstream must clear an unresolvable owned pointer
  and peekActiveWorkstream must not, asserted on adapter state after each
  (kills if(selfHeal) -> if(true))
- fallback arm both ways: a fallback that resolves and one that does not
- diagnoseUnresolvedActiveWorkstream asserted as a full object per case, with the reason
  strings compared exactly (kills present:true -> false and both StringLiteral mutants)

One mutant is deliberately left: 'if (chain.length === 0)' -> 'if (false)'. The branch is
structurally unreachable — the only chain source always returns a 1- or 2-element array
literal — and resolveFromChain is not exported. Killing it would mean exporting an
internal or deleting a defensive guard; neither is worth doing for a mutant, and the
score clears 80 without it. Recorded here rather than left unexplained.

Every new assertion was evaluated against the built module with real fixtures before
committing, since the suite cannot run locally.

---------

Co-authored-by: sim <sim@local>
2026-08-18 11:37:44 -04:00
Tom Boucher
e201cde73c refactor(#3186): one shared phase-completion predicate, disk-strict (#3306)
* docs(#3186): record the disk-strict completion decision in ADR-3180 7.4

The maintainer decided #2957 on 2026-08-08: disk state is authoritative and a
ROADMAP checkbox is a human annotation with no machine authority. Section 7.4
still carried the OPEN QUESTION and was marked blocked, so the contract said one
thing and the tracker another.

Recorded per section 7's own rule - a behavior not stated there is not decided,
and amending a rule is an ADR amendment rather than a code change with a comment.
The decision comment names Phase 4's PR as the carrier of this edit and makes it
an acceptance criterion that the text be in the tree before implementation
begins, so this lands first, alone, ahead of any code.

Also clears the stale blocked-on-2957 row in the guard roster.

* refactor(#3186): one shared phase-completion predicate, disk-strict

isPhaseComplete in verification.cts becomes the single owner. It calls
readVerificationStatus UNCONDITIONALLY - plan count is not a precondition - so a
zero-plan phase with a passing VERIFICATION.md is complete. That is #3168: init
gated the read on a plan count and synthesized a not_required sentinel, so
phase.complete succeeded while init.manager reported incomplete for the same
phase.

The guard, built and run before scope was fixed per Amendment 3, found 9
re-derivations where the ADR named 3. Four were unnamed, including one in the
prompt layer: mvp-phase.md ORed a ticked checkbox with disk status, which under
disk-strict is the divergence itself.

Per the #2957 decision, a ticked ROADMAP checkbox is a human annotation with no
machine authority. The overrides in roadmap analyze and init manager are deleted
rather than generalized; the user's checkbox stays in ROADMAP.md, only its
authority goes.

scanPhasePlans.completed and buildWorkstreamInventory are deliberately NOT folded
- they answer 'are all plans summarized', which is a different question, and
folding them would either over-report completion or invert the dependency
direction between Phase 1's owner and this one.

Verified on the remote runner.

* fix(#3186): close seven review findings and record the missing-verdict rule

The isolated review reproduced a write-path regression I introduced: migrating
cmdRoadmapUpdatePlanProgress dropped its summaryCount>=planCount gate, so a phase
with a fresh passing verification plus a newly-added unsummarized plan reported
complete AND wrote a checkbox into ROADMAP.md while phase complete refused. The
owner stays right per 7.4 - plan count is not a completion precondition - so the
gate is restored at the write site as an explicit composition, mirroring the
separate 2648 unexecuted-plan gate cmdPhaseComplete already carries.

The spec axis was right that my 0.x-split reasoning was too permissive. The 2957
decision names buildStateFrontmatter as one of the three that must converge, and
buildWorkstreamInventory combined a summaries-met local with verification data to
decide the same verdict - Decision 4(c)'s named bypass, and it reproduced 3168 in
a third surface. Both now route through the owner. The raw scanPhasePlans helper
stays: it answers are-plans-summarized, which genuinely is a different question.

Maintainer decision recorded in 7.4: a missing verdict is not a passing one, so
an absent VERIFICATION.md means not complete everywhere. That retires 2645's
verifier-disabled tolerance and inverts its Goodhart incentive - deleting the
evidence now lowers completion instead of raising it.

Guard hardened: block-form count gates and algebraic restatements are caught, and
the header now discloses its remaining limits instead of overclaiming.

Verified on the remote runner.

* fix(#3186): route state sync through the owner and catch bare completed reads

The matrix found 52 failures. 51 were fixtures asserting the old semantics: a
phase with plans and summaries but no VERIFICATION.md used to count complete and
correctly no longer does. Each fixture now carries a passing verification where
that is what the test was actually about, rather than having its assertion
weakened.

The 52nd was a real 10th re-derivation the guard could not see. cmdStateSync
destructured scanPhasePlans().completed directly - a bare field read, not a
comparison - and used it as a completion verdict, so state sync and state json
disagreed on completed_phases for identical disk state. Routed through the owner.

Guard gains shape (d): any read of .completed off a scanPhasePlans() result
outside plan-scan.cts, in chained, destructured and indirect forms, function
scoped with no line window. It cannot tell a summaries-met read from a completion
read - that is data flow - so it flags every one and requires a written-reason
exemption, which is the same discipline shapes a-c already use. The blind spot is
disclosed in the header rather than overclaimed.

The emitted-attribution failure was also mine, not pre-existing: the mvp-phase.md
checkbox-OR removal moves emitted bytes, acknowledged in tests/emitted-drift-acks.

Verified on the remote runner.

* test(#3186): give the nested-plans sync fixture a passing verification

Last 3 matrix failures were one failure echoing up two describe levels. Phase
01-alpha had plans and summaries but no VERIFICATION.md, so under disk-strict
completed stayed 0 and no Progress change was emitted - correct new behavior, not
a regression.

Added the passing verification rather than dropping the Progress expectation, so
the test still covers what #3257 is about: that a nested plans/ layout is counted
and not undercounted. Probe against the built lib confirms
Progress: 0% -> 50% alongside Total Plans in Phase: 0 -> 3.

* chore(#3186): backfill changeset PR number

pr:0 placeholder replaced with the real number now that #3306 exists.

---------

Co-authored-by: sim <sim@local>
2026-08-10 10:18:29 -04:00
Adnan
137f3fbb9c fix(#2562): scope workstream progress/status to the current milestone (#2588)
* fix(#2562): scope workstream progress/status to the current milestone

`workstream progress` / `workstream status` / `workstream list` share one
derivation that could report a workstream's CURRENT milestone as
"milestone complete" / 100% while phases in that milestone were unstarted,
in progress, or failing verification. Three coupled defects:

1. The shipped signal was project-lifetime, not milestone-scoped:
   workstreamMilestoneShipped() returned true if ANY *-ROADMAP.md snapshot
   existed or "SHIPPED" appeared anywhere in ROADMAP.md. Every prior shipped
   milestone leaves a permanent collapsed <summary>✅ … SHIPPED</summary>
   block, so any post-v1.0 workstream was pinned to "milestone complete"
   forever (over-correction from #1913).
2. The denominator dropped declared-but-unscaffolded phases, and completed
   PRIOR-milestone phase directories inflated the numerator, letting
   progress_percent round to 100 while real work remained.
3. Phase completeness ignored the VERIFICATION verdict — SUMMARY >= PLAN
   count alone marked a phase complete even with a human_needed verdict.

Fix: derive both numerator and denominator from artifacts scoped to the
current milestone. The current version comes from the workstream STATE.md
`milestone:` field (ROADMAP in-progress markers can be stale); the ROADMAP
`## Progress` table maps every phase — including dirless ones — to its
milestone, and the matching set is both the denominator and the directory
membership filter. The shipped signal now requires the CURRENT version's
archived ROADMAP snapshot (REQUIREMENTS snapshots are not accepted; they can
be written at milestone start) or the current milestone's own line marked
shipped. Phases with an explicit failing verdict (gaps_found/human_needed)
count as in_progress; missing/unknown/stale are left untouched so
verifier-disabled projects do not regress to never-complete.

Greenfield roadmaps with no versioned Progress table, and projects whose
current version cannot be determined, keep the prior behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#2562): add changeset for workstream milestone-scoping fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(#2562): parse the Progress table via findTableWithColumns

The ad-hoc pipe-table regex tripped the local/no-adhoc-markdown-parsing
ESLint rule. Use the canonical markdown-table helper instead: the
milestone-grouped RoadmapProgress variant is located by its required
`Phase` + `Milestone` columns and cells are addressed by column NAME,
so the parser tolerates column reordering and injected columns. The
`flat` variant (no Milestone column) yields no attribution, which is
the intended fallback to legacy counting.

Behaviour is unchanged: verified against a real multi-workstream project
(same status/percent/phase and plan counts before and after).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#2562): count table-only phases in the unscoped denominator

Addresses the reporter's repro detail: a phase declared as a `## Progress`
table row with no `### Phase N` heading is missed by countRoadmapPhases
EVEN WHEN other headings exist — the heading regex counts 1 for a
"1 heading + 1 table-only" roadmap — not just in the zero-heading fallback
path. Milestone scoping did not cover this, because a flat Progress table
(no Milestone column) carries no per-phase attribution, so greenfield and
single-milestone projects kept the old heading-only denominator and the
declared phase silently vanished from it.

When milestone scoping cannot engage, the denominator is now the union of
the Progress table's declared phase numbers and the phase directories, so
neither source can shrink it. Verified against the reporter's minimal
fixture (phase 1: 1 PLAN + 1 SUMMARY + gaps_found; phase 2: table row only,
no heading, no dir), which now reports 0/2 at 0% across all four
table/STATE permutations instead of 1/1 at 100%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#2562): attribute dir-only sub-phases to their parent's milestone

A sub-phase directory inserted mid-milestone (e.g. `30.1-…` under a
table-declared phase 30) usually has no ROADMAP Progress-table row, so it
had no milestone attribution and was scoped out of the rollup entirely —
its completed work was invisible and it could never hold the percentage
below 100.

It now inherits its parent phase's milestone and joins BOTH sides of the
calculation. Both sides is the load-bearing part: adding it to the
numerator alone would let completed_phases exceed a denominator that never
counted it, cap back to 100% via Math.min, and reintroduce exactly the
defect this issue reports. A regression test pins that failure mode (all
declared phases complete + an in-progress dir-only sub-phase → 75%, not
100%).

Attribution is deliberately one-directional: a sub-phase counts only when
its PARENT is in the current milestone, so a follow-up created in a later
milestone under an older parent is excluded rather than misattributed —
conservative (under-count) rather than falsely inflating.

Verified on a real project: the reported workstream moves from 2/6 (33%)
to 3/7 (43%), the 3/7 being the honest figure — a completed sub-phase that
was previously invisible now counts, and so does its plan total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#2562): describe the denominator + sub-phase fixes in the changeset

The fragment was written at the first commit and only covered the three
original defects. Bring it up to date with what actually ships: the
table-only-phase denominator union (heading-only counting dropped a
declared phase even when other headings existed) and sub-phase milestone
inheritance across both sides of the calculation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(#2562): promote the canonical phase-key surface to phase-id

state.cts kept `phaseKeyFromToken`/`phaseKeyFromDir` private, so every other
module that had to compare two independently-derived phase references — a
ROADMAP table cell against a phase directory, say — wrote its own regex. That
is the defect class #2562 reports: a padded `01` and an unpadded `1-slug` land
in different key spaces and the comparison silently yields nothing.

Move the pair to the phase-id owner module and add `phaseKeyFromProse` (for
ROADMAP/STATE prose, markdown emphasis stripped) and `parentPhaseKey` (a
sub-phase's parent). state.cts imports them; its call sites are unchanged.

* fix(#2562): own milestone-shipped detection and accept a workstream scope

Three changes to the module that owns milestone parsing, so its consumers stop
reimplementing it:

- `isMilestoneShippedInRoadmap(content, version)` answers "does the ROADMAP mark
  THIS milestone shipped" from heading and `<summary>` lines only. A bullet that
  merely names the version (`- [x] 03-01: ship the v2.0 login endpoint`) is prose
  about a phase, not a milestone verdict. The version token is boundary-matched
  with `(?![\w.-])` — `\b` does not bound it, since `.` is a non-word character,
  so a shipped `v2.0.1` heading would otherwise close `v2.0`.
- `extractCurrentMilestone` and `getMilestonePhaseFilter` take an optional
  trailing workstream name and thread it to `planningDir(cwd, ws)`. A caller
  iterating workstreams cannot set `GSD_WORKSTREAM` per iteration, which is what
  the existing resolution falls back to. Omitted, resolution is unchanged.
- `getMilestonePhaseFilter` exposes `versionScoped`, true only when the phase set
  really is one milestone's. On an unversioned roadmap `phaseCount` spans the
  project's lifetime and must not be read as a current-milestone denominator.

The closed/active milestone-marker patterns were kept in three byte-identical
copies; they are hoisted to module scope as one `isClosedMilestoneHeading`.

* fix(#2562): derive membership and denominator from one phase-key space

The milestone scoping added earlier in this PR derived the ROADMAP table key and
the phase-directory key with two different regexes, and dropped rows it could not
attribute. Each of those was another way to reproduce the symptom this issue
reports — a rollup contradicting its own `phases[]` listing:

- a padded `| 01. … |` row never matched a `1-slug` directory (and a bespoke
  `^0*(\d+…)` never matched `PROJ-05-…` at all), so phases fell out of the
  milestone entirely and the percentage collapsed or pinned;
- a blank or malformed Milestone cell deleted the phase from BOTH sides, letting
  an unstarted phase vanish and the remainder round to 100%;
- shipped detection scanned bullets, so any checkmarked line naming the version
  closed the milestone;
- the numerator counted per-directory while the denominator counted distinct
  phases, so a stale same-numbered directory (Bug #2445's scenario) pushed
  `completed_phases` past the denominator, where `Math.min` capped it to 100%
  and hid the unstarted phase.

Both sides now key off the phase-id owner module (`phaseKeyFromDir` /
`phaseKeyFromProse`), directory membership additionally consults
`getMilestonePhaseFilter` when that filter is genuinely version-scoped, and the
denominator is the union of the roadmap's declarations with the member
directories' keys — so `completed_phases <= denominator` holds by construction.
The Builder asserts it and throws; the `Math.min` cap survives only on the legacy
unscoped path, where the denominator is a heading count that cannot bound the
numerator. An unattributable row degrades over-inclusively (kept, never dropped),
matching the degrade direction roadmap-parser already commits to.

* test(#2562): boundary coverage for each milestone-scoping reproduction

One test per way the scoping could still report "milestone complete"/100% while
phases are incomplete: zero-padded rows vs padded dirs (and the mirror),
project-code-prefixed dirs, a blank/malformed Milestone cell, a checkmarked
bullet naming the version, a shipped `v2.0.1` heading against a current `v2.0`,
and a stale same-numbered directory. Plus the current milestone's own shipped
heading (the signal must survive the boundary fix), the Builder's
numerator-above-denominator throw, a parity check that every non-`passed`
verifier status blocks completeness, and a guard that scoping reads the
workstream's ROADMAP rather than the project root's.

Reverting only `src/` reddens six of them.

* docs(#2562): record the milestone-scoped semantics and its consumer impact

CONTEXT.md: the Workstream Inventory Module's completion fields now describe the
current milestone, not the workstream's lifetime; phase-id owns the canonical
phase-key surface; roadmap-parser owns milestone shipped/active classification
and takes an optional workstream scope.

Changeset: name the behaviour change explicitly — `roadmap_phase_count`,
`completed_phases` and `progress_percent` change meaning with no schema signal,
and `getOtherActiveWorkstreamInventories` filters on the derived status, so
consumers see real movement.

* fix(#2562): collapse every zero-padding spelling to one phase key

A property test over the key surface — table cell and directory decorated
INDEPENDENTLY, which is the point — found a divergence neither review named:
`padStart(2, '0')` is a no-op once the input is already ≥2 characters, so `5`
normalised to `05` while `005` stayed `005`. A `| 5. … |` row and a `005-slug`
directory therefore never compared equal, which is the same failure mode as the
padded-vs-unpadded blocker, one level down.

The strip belongs in `phaseKeyFromToken`, not in `normalizePhaseName`: applying
it to the latter regressed multi-decimal leading-zero plan IDs (`001.10-PLAN.md`
capture + wave assignment), which rely on its verbatim rendering. Confining it
to the key surface fixes the comparison and leaves rendering untouched.

Also tightens `isMilestoneShippedInRoadmap`'s patterns to anchored,
complementary character classes so an untrusted ROADMAP cannot drive
backtracking, and makes the project-code test discriminating — it previously
passed pre-fix, because an unresolvable key collapsed scoping to the whole
roadmap and happened to land on the same number. It now carries a
prior-milestone directory that a collapse would wrongly admit.

* fix(#2562): prefer the milestone-attributing Progress table; pin the seams

Three gaps the earlier self-check missed:

- Both RoadmapProgress variants carry a `Plans Complete` column, so probing it
  first picked a FLAT table appearing earlier in the document over the
  milestone-grouped one that actually carries the attribution. Every row came
  back unattributed, was treated as current-milestone, and silently re-admitted
  prior-milestone phases. The attributing shape is probed first; flipping the
  order reddens the new test.
- `lint-phase-id-drift` exempts phase-id.cts by design, so it is silent on
  `phaseKeyFromToken`'s own segment strip by construction — not evidence. Its
  interaction with `stripProjectCodePrefix` (which runs AFTER) is pinned across
  project codes and hyphenated ids, including the pre-existing `M1-46-6` vs
  `M1-46-6-rs` asymmetry, which is `extractPhaseToken`'s #2043/#2232 slug-word
  rule and not something to "fix" by accident.
- `listWorkstreamInventories` loops every workstream with no try/catch, so a
  REACHABLE Builder-invariant throw would take down `workstream list`/`status`/
  `progress` for all of them. The invariant test only exercised the pure Builder
  with hand-built inputs. A test now drives `inspectWorkstream` over every
  adversarial shape at once (prior-milestone dirs, three colliding duplicates,
  a dirless declaration, an unattributed row, a project-code prefix, a dir-only
  sub-phase) and asserts it does not throw and the invariant holds — so the
  throw stays a contract assertion for external callers, not a runtime path.

Also covers the active-marker-wins rule (`## v2.0 — 🚧 IN PROGRESS … ✅` must not
mark shipped), which nothing exercised.

* fix(#2562): scope a declared-but-empty current milestone instead of falling back to history

The review's open MAJOR. `STATE.md`'s `milestone:` field updates the moment
`/gsd-new-milestone` writes the heading, while the `## Progress` table and phase
sections land later. In that window nothing attributes a phase to the current
milestone, `scoped` went false, and the fallback counted the project's ENTIRE
phase history as both numerator and denominator — a milestone with zero work
done reported 100% off its predecessors'. That is #2562's own symptom reached by
a different precondition, and none of the 16 tests covered it.

Reproduced first, four ROADMAP shapes, at `inspectWorkstream` rather than the
Builder — the Builder takes the scoping decision as an input, so a Builder-level
test proves it honours a flag, not that the derivation sets it. Three of the
four reported 2/2 100% with no phase of the current milestone begun.

Which signal witnesses the state depends on the ROADMAP's shape, and no single
one covers all three:

- `## v3.0` exists but declares no phases. `getMilestonePhaseFilter` DOES locate
  the section and sets `versionScoped`, then the zero-phase pass-all degrade
  resets it to false — erasing the only evidence the milestone exists. Neither
  existing flag survives that path, so this adds `versionSectionFound`, set
  beside `versionScoped` and deliberately preserved through the degrade.
- No section for this version at all, in a roadmap that versions its others —
  the existing `missingExplicitVersion`, already exposed and tested.
- Unversioned headings, but a Progress table attributing every row elsewhere:
  neither filter flag fires and the table is the only witness.

A ROADMAP that attributes NO versions anywhere matches none of them, which is
the point. Its rows parse with `version: null`, land in `currentMilestoneKeys`,
and never reach the new branch. `readCurrentMilestoneVersion` returns a non-null
version for very nearly every project (`getMilestoneInfo` defaults to `v1.0`),
so keying off `currentVersion` alone would have zeroed out every free-form
legacy project — the condition looks fussy for that reason. A test pins it.

Within an empty milestone, membership inverts: a directory belongs unless
another milestone's row claims it. Excluding everything would have dropped a
phase scaffolded before the roadmap caught up from BOTH sides of the rollup, and
hiding real work is the same class of defect as inventing it — this codebase
degrades over-inclusive, never under.

Scoping is now stated by the caller (`milestoneScoped`) rather than inferred
from `currentMilestonePhaseCount > 0`. That inference was the root cause: it
cannot represent a milestone that is scoped AND legitimately zero-phase, so the
Builder read "no phases yet" as "no scoping" and reopened the whole-history
path. The count-derived value stays the default for callers that say nothing.

A regression test also pins that a zero denominator does not trip the Builder's
`completed_phases <= denominator` throw, since `listWorkstreamInventories` has
no try/catch and a crash on every freshly-declared milestone would be worse than
a wrong percentage.

The changeset and CONTEXT.md no longer claim membership is derived in "ONE" /
"a SINGLE" phase-key space. `getMilestonePhaseFilter` still runs its own
`normalizePhaseIdSegments`; the signals are OR'd so a divergence can only widen
membership, but two normalisers coexist and the docs now say so.

* fix(#2562): cross-validate the shipped marker against the milestone's artifacts

`status: "milestone complete"` was asserted from the shipped marker alone, so a
single payload could report it beside `progress_percent: 67` — this issue's own
symptom, reached through `status` rather than the percentage.

The marker is now a claim checked against the milestone's own artifacts, and the
two signals are checked at DIFFERENT strengths because one check cannot serve
both. A `heading` marker (operator-typed, live ROADMAP) is refused on a short
completion ratio, which also catches phases declared but never scaffolded. A
`snapshot` marker is NOT ratio-gated: `milestone complete` moves the milestone's
phase dirs into `milestones/<version>-phases/` (milestone.cts:755-762) while
copying — never truncating — the live ROADMAP (:671-674), so a correctly
archived milestone reads 0/N by construction and a ratio gate would strip
`milestone complete` from every archived milestone. It is refused instead when
an in-milestone phase dir is still live and unfinished, reachable because
`milestone complete` does not advance STATE's `milestone:` field
(state-transition.cts:1335 vs :1224). `legacy` stays ungated — only reachable
when scoping is off.

A refused marker does not fall through to a STATE field claiming the same thing;
against contradicting artifacts neither source may report completion. The
refusal surfaces as `milestone_shipped_unverified` rather than staying silent,
distinct from `status_conflict` (derived-vs-field only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

* test(#2562): pin both marker strengths, the archived guard, and the owner modules

workstream-inventory: four tests, all four red against the prior src and green
with it. A live-ROADMAP SHIPPED heading over an incomplete milestone is refused;
an archived snapshot SURVIVES its phase dirs being moved out (the regression the
obvious single ratio-gate would cause — swapping the snapshot branch to that
gate reddens this AND the pre-existing `CURRENT-version snapshot marks the
milestone complete` at :321); an archived snapshot is refused once a phase is
reopened under it; and a refused marker is not re-asserted by a STATE field
claiming the same.

roadmap-parser: `isMilestoneShippedInRoadmap` gets unit coverage at its owner
module rather than only through the inventory that consumes it, plus two
characterisation tests for `getMilestonePhaseFilter`'s legacy call surface —
omitting the new trailing `ws` param is indistinguishable from `undefined`/`null`,
and the `GSD_WORKSTREAM` env fallback still resolves. These characterise the
call surface; they do not stand in for coverage of its individual callers.

phase-id: the `phaseKeyFrom*` / `parentPhaseKey` one-key-space contract, incl. a
property that padding a directory number never changes its key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

* docs(#2562): record the two-strength shipped cross-check + milestone_shipped_unverified

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

* fix(#2562): refuse an archived snapshot on a DIRTY archive, not just a live-unfinished dir

The snapshot arm checked `liveIncompletePhases > 0`, which misses the shape
@davesienkowski reproduced: a COMPLETE live dir beside a phase declared in the
Progress table with no directory. Nothing is live-and-unfinished, the marker
sails through, and `cmdWorkstreamProgress` returns
`{"status":"milestone complete","progress_percent":50}` — the reported symptom
verbatim, from one payload. Reproduced at 483a3ba30 before changing anything.

His diagnosis is the right one and better than mine: an in-milestone directory
outliving the archive means the archive is not CLEAN, and once that is true the
completion ratio is meaningful again. So the check is the conjunction — any live
in-milestone dir AND `completedPhases < effectivePhaseCount`. That strictly
subsumes the old predicate (an incomplete member dir is in the denominator and
not the numerator, so the ratio is always short when one exists) and leaves the
clean-archive guard green, since a clean archive has no live dirs at all.

Also corrects the module comment: the `scoped &&` prefix ungates all three
signals, not just `legacy`. That is correct behaviour — unscoped, the
denominator is the whole-roadmap count and membership is everything, so there is
no current-milestone artifact set to check a current-milestone claim against —
but the comment claimed otherwise. And the `milestone.cts` citations were ~28
lines stale after the rebase; they are now :700-702 (copy) and :783-790 (move),
re-verified against this head.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

* fix(#2562): project milestone_shipped_unverified from list, status and progress

The inventory carried the field and every renderer dropped it — `workstream.cts`
was not in this PR's diff at all — so at the CLI a refused marker looked exactly
like no marker: a fallback `status` and nothing saying one was seen and rejected.
That is the silent collapse this issue is about, reintroduced one layer up, and
it made the changeset's "visible rather than silent" claim false at every
surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

* test(#2562): pin the dirty-archive shape and the CLI projection

Five tests, all five red against the prior src and green with it.

The reviewer's repro at the builder: an archived snapshot with a COMPLETE live
dir beside a dirless declared phase must be refused, and status must not
contradict the percentage.

Four at the CLI via runGsdTools, the surface that was dropping the field rather
than the builder that already had it: `workstream progress`/`status`/`list` each
project `milestone_shipped_unverified: true` for that workstream, and a clean
archive still reports `false` with `status: "milestone complete"`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

* docs(#2562): correct the snapshot check, the scoped-only caveat and the CLI claim

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-08-01 23:06:43 -04:00
Tom Boucher
8b70db343b fix(#2204): phase-completion writes 'All phases complete' per ADR-2207 (#2259)
* fix(#2204): phase-completion writes 'All phases complete' per ADR-2207

completePhaseCore was writing the overloaded bare 'Milestone complete' on the
last phase — the same string space the milestone-close verb owns for terminal
state. Per ADR-2207, phase-completion now writes the existing intermediate
value 'All phases complete' (already used in gsd2-import.cts). Milestone
termination ('<version> milestone complete' / 'Awaiting next milestone')
remains solely with milestoneCompleteCore.

Status lifecycle: Ready to plan → All phases complete → <version> milestone
complete → Awaiting next milestone.

Changes:
- src/state-transition.cts: completePhaseCore status value
- src/phase.cts: #2028 guard comment
- tests/state-transition.test.cjs: assertion + test name
- tests/phase.test.cjs: 8 assertion updates (positive + negative)
- tests/state.test.cjs: normalizeStateStatus test case + reset regex
- tests/workstream.test.cjs: fixture status to terminal value
- gsd-core/workflows/progress.md: Route D label
- gsd-core/workflows/transition.md: Route B label
- CONTEXT.md: Status lifecycle glossary entry (ADR-2207)
- .changeset/brave-geese-jump.md

* test(#2204): regenerate golden-install-parity fixtures + workflow-size baseline

Workflow file edits (progress.md, transition.md) changed install payload
hashes and pushed past the committed workflow-size baseline. Regenerated
all 17 golden-install-parity fixtures + claude-local via the standalone gen
script (which now also covers the local-scope claude layout). Updated
workflow-size-baseline.json and agent-size-baseline.json via size:baseline.

* fix(#2204): correct claude-local golden hashes + document gen-script limitation

The gen-script's claude-local generation produces macOS-specific hashes
incompatible with Linux CI (local-scope install embeds platform-varying
node-runner paths). Reverted to manual update using Linux FAILURES.md
+actual hashes for the 2 changed workflow files. Added explanatory
comment in the gen script.

* test(#2204): add isCompletedInventory coverage + clarify CONTEXT.md glossary

Addresses orthogonal code-review findings (Medium #1 + #2):
- Add isCompletedInventory test cases for ADR-2207 status lifecycle
  (terminal 'milestone complete' → true; intermediate 'All phases
  complete' → false; archived → true; active statuses → false)
- Clarify CONTEXT.md glossary: note that isCompletedInventory
  intentionally excludes the intermediate value

* docs: backfill changeset PR number (#2259)

* docs(#2204): add Status lifecycle table to state-md reference (ADR-2207)
2026-07-14 14:47:03 -04:00
Tom Boucher
c76827afbc refactor(#1291): T6 — migrate test files off the core spine ahead of deletion (#1293)
The convergence lint only scanned src/ + gsd-core/bin, so ~35 test files
still imported core.cjs. Repoint all 33 behaviour importers to the leaf
modules directly (same symbol->leaf map as the src migration; leaves are the
objects core re-exported by reference), delete the now-meaningless
shim-identity describe blocks in the 8 leaf tests, and delete tests/core.test.cjs
(forwarded-behaviour coverage now lives at the leaves; resolveWorktreeRoot
test relocated to worktree-safety in T0) and tests/lint-core-spine-imports.test.cjs
(the lint is removed in T-final). Dropped the stale core.test.cjs entries from
the allow-test-rule-refs allowlist; eslint-rules RuleTester fixture path
pointed at io.cjs.

After T6: ZERO test imports core.cjs. core.cts still builds (now fully unused);
T-final deletes it. No behaviour change.

Closes #1291

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:11:20 -04:00
Tom Boucher
ba231ecbfc chore: clean up clear-cut ESLint warnings (#732) (#734)
Pay down pre-existing error→warn lint debt. Removes dead imports/vars, unused functions, redundant regex/string escapes, and stale eslint-disable directives; converts unused `catch (_e)` to optional catch binding (src/*.cts).

No behavior change. Lint 345→125 warnings (0 errors); deferred categories (n/no-process-exit, test-sleeps, control-regex) tracked in #732 for follow-up. Full test suite green (0 failures); code-review verified all removals unused and all escape fixes semantics-preserving.

Closes #732

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:24:48 -04:00
Tom Boucher
463cffd894 chore(#604): rename get-shit-done/ runtime directory to gsd-core/ (#615)
* chore(#604): rename get-shit-done/ runtime directory to gsd-core/

Renames the installed runtime directory `get-shit-done/` to `gsd-core/` so the
on-disk name matches the package (`@opengsd/gsd-core`), repo, and binary
(`gsd-tools`). The npm package name and binary are unchanged; npx/npm consumers
are unaffected.

Mechanical (bulk, ~90% of the diff):
- `git mv get-shit-done gsd-core`
- Swept path/identifier references across the repo via
  `perl -pe 's/get-shit-done(?!-\w)/gsd-core/g'`. The negative lookahead
  preserves the five legitimate slug variants that are NOT the directory:
  get-shit-done-{OLD,cc,classic,cli,redux} (old package/repo names).
- Build/manifest wiring: package.json (bin, files, coverage globs),
  tsconfig.build.json (outDir), ~86 .gitignore build-output entries,
  stryker.config.mjs, scan-ignore files, install.js path strings.
- Frozen (not rewritten): CHANGELOG.md history; translated docs
  (README.<locale>.md and docs/{ja-JP,ko-KR,pt-BR,zh-CN}/).

New logic (review here):
- src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts: a proper
  ADR-0008 installer migration. On upgrade it walks the legacy
  `~/.claude/get-shit-done/` tree, classifies each file via the prior install
  manifest, and emits remove-managed / backup-and-remove for managed files
  while PRESERVING unknown user-added files. Symlink-safe (skips a symlinked
  root and symlinked entries; bounds-checks every path under configDir). The
  framework rolls back on install failure. Emptied dirs may remain (framework
  has no recursive dir-removal primitive) — documented.
- scripts/lint-legacy-dir-name.cjs: CI regression guard forbidding the bare
  `get-shit-done` directory token (split token to avoid self-match; case-
  insensitive; `(?!-\w)` lookahead allows the slug variants; allowlists
  CHANGELOG, translated docs, and `gsd-allow-legacy-name` marker lines).
  Wired into the lint-tests CI job.
- Restored scripts/lint-package-identity-drift.cjs detection regexes (the
  mechanical sweep had wrongly rewritten the old-name patterns it exists to
  detect) and marked them as intentional legacy references.
- TDD tests for the migration and the guard; do.md slash-command guard regex
  tightened so a `/gsd-core/bin` path segment is not mistaken for a command;
  changeset + docs/installer-migrations.md row added.

Breaking: the installed runtime path moves `~/.claude/get-shit-done/` ->
`~/.claude/gsd-core/`. Migration 003 removes the stale legacy dir's managed
files (preserving user files) on upgrade. Users with custom hooks/configs
hardcoding the old path must update them.

Closes #604

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): unsweep pending changesets + allowlist injection-example docs

CI fixes for the rename PR:
- Do not sweep pending .changeset/*.md (ephemeral release-note fragments,
  like CHANGELOG); reverted those body edits so 5 pre-existing malformed
  fragments (missing type/pr) no longer enter the PR diff and trip docs-lint.
  Allowlisted .changeset/ in the legacy-name guard accordingly.
- Allowlisted TEST-EXAMPLES.md and docs/explanation/security-model.md in
  prompt-injection-scan.sh: they contain intentional injection examples /
  security-model prose; the path-reference rewrites are kept.

CodeQL alerts on this PR are pre-existing (alert lines unchanged by this PR;
none in the new migration/guard) and are out of scope for the rename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): resolve CodeQL alerts surfaced on this PR

The rename diff touched files carrying pre-existing CodeQL findings; per the
no-pre-existing-dismissal rule, fixing every surfaced alert rather than waving
them off. All behavior-preserving:

- scripts/ci-test-scope.cjs: build the config-path match from string
  .includes() instead of a RegExp over an arg-derived value (js/regex-injection).
- src/profile-output.cts: escape backslashes before pipe-escaping desc/safeName
  so the table-cell escape is complete (js/incomplete-sanitization).
- tests/{bug-2643,bug-2808,docs-parity-live-registry}: two-pass HTML-comment
  strip so a bare/unclosed `<!--` cannot survive (js/incomplete-multi-character-sanitization).
- tests/inline-plan-threshold: drop the no-op `\s`->`\s` identity replace,
  keep the meaningful POSIX-class conversion (js/identity-replacement).

Verified: build:lib green; the touched test files + ci-test-scope + profile-output
suites pass; lint:legacy-name clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): correctly resolve remaining CodeQL alerts (regex-injection + sanitization)

The prior commit's fixes for two alerts were ineffective:
- ci-test-scope.cjs js/regex-injection: the alert is the CLI-arg-derived `file`
  reaching static regex `.test(file)` calls (not the config rule). Removed ALL
  regex over file/t — startsWith/includes/=== string checks + an isWindowsHint
  helper — so there is no regex sink for the tainted value.
- js/incomplete-multi-character-sanitization (3 test files): a single
  `.replace(/<!--...-->/g,'')` can let `<!--` re-form. Replaced with a fixpoint
  loop (replace until stable) plus a final bare-opener strip.

Verified: no regex over file/t remains; ci-test-scope + the 3 test suites pass;
lint:legacy-name clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): make ci-test-scope + comment-strippers regex-free to clear CodeQL

CodeQL flags the regex PATTERNS syntactically (regex-injection on the
--files arg split; incomplete-multi-character-sanitization on the <!--...-->
replace), so loop fixes do not satisfy it. Made these paths regex-free:
- ci-test-scope.cjs splitFiles: char-by-char separator tokenizer (no /[,\\s]+/).
- 3 test files: indexOf/slice HTML-comment stripper (no .replace(/<!--/)).
Behavior preserved; ci-test-scope + the 3 suites pass; guard clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): unblock security base64 scan on the large rename diff

The security job hit its 10m timeout: base64-scan.sh choked on the binary
test fixture tests/feat-3594-parser-property-style.test.cjs (embedded NUL/
non-UTF8 bytes -> thousands of bogus blobs + "ignored null byte" warnings),
and the ~800-file rename diff is slow to scan regardless.

- scripts/base64-scan.sh: skip binary-by-content files (grep -Iq .) — they
  can't carry base64-obfuscated *text* and feeding NUL bytes through the
  per-line scanner is pathologically slow. collect_files already filtered
  binary *extensions*; this catches binary *content* in text extensions.
- .github/workflows/security-scan.yml: raise the security job timeout 10m->30m
  to accommodate very large diffs (the scan itself is unchanged).

Verified locally: scan skips the fixture, 0 "ignored null byte" warnings,
0 findings, exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): sweep get-shit-done refs introduced by merging next

The branch was updated with next (#614/#384/#618 etc.), which reference the
get-shit-done/ dir (still named that on next). Swept the stale references in
the merged files to gsd-core so the rename stays consistent and lint:legacy-name
passes:
- commands/gsd/discuss-phase.md (runtime-launcher shim paths)
- src/core.cts (getAgentsDir layout comments)
- tests/bug-384-agents-runtime-aware.test.cjs (require path to runtime lib)

Verified: guard 0 violations; build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): exclude gsd-core/ path segments from bug-3683 command cross-ref invariant

The #614 runtime-launcher shim added to discuss-phase.md references
`${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`. bug-3683's REF_PATTERN excluded path-y
refs only via lookbehind, but `}` precedes `/gsd-core/` in the shim, so it
mis-read the directory path as a dangling `/gsd-core` command ref (same class as
the #604 bug-2954 fix). Added a trailing `(?![\w-]*\/)` so `/gsd-<x>/...` path
segments are not treated as slash-command references.

Verified locally on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22 image) full suite: 0 failures
- bug-3683 + bug-2954 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): lazily resolve findProjectRoot in gsd-tools (harden flaky CI)

CI intermittently failed state.test's gsd-tools subprocess with
"findProjectRoot is not a function" (flip-flopping across legs; not reproducible
on mac full suite, gsd-test linux full suite, test:unit, or state.test x8).
findProjectRoot is a re-export from core.cjs (sourced from project-root.cjs);
binding it via destructure at module-load can be undefined under a load-ordering
edge. Resolve it lazily at call time via a small wrapper so the lookup happens
after core.cjs is fully initialized.

Verified green on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22) full suite: 0 failures
- state.test.cjs: 106/106; gsd-tools loads cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): allowlist verification-patterns.md placeholder examples in secret scan

The rename git-mv'd references/verification-patterns.md into gsd-core/, pulling
it into the secret-scan diff. It documents stub/placeholder RED-FLAG env-var
examples (illustrative Stripe test-key / database-URL / API-key placeholders) —
not real credentials. Added it to .secretscanignore with the strict annotation,
mirroring the existing gsd-core/workflows/plan-phase.md exception.

Verified locally: secret-scan-lint --strict OK; secret-scan --diff origin/next
exits 0 with 0 findings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:35:29 -04:00
Tom Boucher
a28dcec981 chore(#597): replace count-based ratchet guards with AST lint + named-set allowlists (#603)
The windows-test-parity ratchet greps test source for fs.rmSync-without-
maxRetries (and six other Windows-portability anti-patterns), failing when an
integer offender COUNT exceeds a frozen baseline (rmSync: 95). A count ratchet
is a Goodhart metric: fixing one offender and adding another keeps the count
constant, so a new defect slips through green. Replace it — and every other
count ratchet in the repo — with a layered, masking-proof design.

Behavioral seam test
- tests/helpers-cleanup.test.cjs proves helpers.cleanup() carries the Windows
  EBUSY retry budget. cleanup() delegates retries to Node's fs.rmSync via
  maxRetries (it owns no loop), so the test asserts the option contract
  (recursive/force/maxRetries>0/retryDelay>0) + real-FS removal + the cwd-guard,
  rather than a loop that does not exist. The EBUSY risk is now tested ONCE at
  the helper, not approximated textually at every call site.

Write-time ESLint rule (AST-accurate, replaces the grep)
- eslint-rules/no-raw-rmsync-in-tests.cjs (error in tests/**/*.test.cjs) bans
  raw fs.rmSync, steering to cleanup(). Catches member, computed (fs['rmSync']),
  destructured and aliased forms; escape hatch is inline
  `// eslint-disable-next-line local/no-raw-rmsync-in-tests -- <reason>` only.
- Migrated 336 raw fs.rmSync teardown calls across ~116 test files to cleanup().
  ~18 genuinely load-bearing sites (mid-test SUT/fault-injection removals,
  error-swallowing or name-colliding local teardown helpers) keep the raw call
  with an inline eslint-disable + reason.

Shared anti-ratchet primitive
- scripts/lib/allowlist-ratchet.cjs:
  - assertWithinAllowlist: fails on NOVEL ids (new offender introduced) AND on
    STALE ids (a known offender was fixed but not pruned) — identity, not count,
    and a ratchet DOWN toward zero.
  - assertTightCeiling: a size/length budget whose ceiling must stay within a
    grace band of the high-water mark, so budgets may only tighten, never creep.

Ratchets converted onto the primitive
- windows-test-parity-guard.test.cjs: rmSync rule deleted (now ESLint-enforced);
  the remaining six patterns moved from integer baselines to named-set
  allowlists with ratchet-down.
- scripts/lint-test-file-count.{cjs,allowlist.json}: per-module integer counts →
  named filename sets (closes the swap-a-file-keep-the-count blind spot); a
  module dropping under cap now FAILS to force pruning its allowlist entry.
- enh-2790 skill-count `<= 63` → named skill allowlist (ratchets toward ~58).

Size budgets hardened (tighten-only)
- agent-size / workflow-size / feat-3039 help-tiered: ceilings lowered to the
  current high-water mark and an assertTightCeiling anti-creep check added per
  tier. Fixed external-contract limits (description ≤100 chars, agent ≤100 KB)
  are intentionally left as-is — they are not grandfathered creeping budgets.

No user-facing behavior change (tests + tooling only); no USER_FACING_PREFIXES
touched, so no changeset fragment is required.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:43:49 -04:00
Tom Boucher
e905ca1c32 feat(#293): stabilize workstream inventory ordering (#294) 2026-05-26 12:20:40 -04:00
Tom Boucher
f76a53e055 feat(#291): deepen workstream name policy seam (#292) 2026-05-26 11:24:12 -04:00
Tom Boucher
06cf7826e3 test: deepen fixture module v2 for reliability (#329)
* test: deepen fixture seam for init/state/workstream suites

* test: deepen fixture module v2 with declarative builders
2026-05-26 11:23:42 -04:00
Tom Boucher
26dcdb1ad0 Refactor SDK-first architecture seams (#3316)
* refactor: tighten sdk-first architecture seams

Refs #3312

* refactor: finish state document seam cleanup

Refs #3312

* test: harden minimal install cleanup assertion

* ci: support sdk-scoped package lock

* fix(3316): restore root package-lock.json and align changeset pr ref

Reverts dec57a83 ("ci: support sdk-scoped package lock") and restores
the root package-lock.json that c249d34d deleted. The deletion was the
wrong direction:

- The root package.json declares its own runtime and dev deps
  (@anthropic-ai/claude-agent-sdk, ws, c8). Without a root lockfile,
  `npm install --no-package-lock` resolves whatever satisfies semver at
  install time — CI today and CI in six months can install different
  transitive trees, defeating reproducibility.
- The lockfile has been part of every release on this repo (long
  history on main); removing it loses the npm audit / Dependabot
  target without compensating benefit.
- The CI workaround pattern (cache-dependency-path: sdk/package-lock.json
  + `npm install --no-package-lock`) papered over the symptom rather
  than fix the cause.

Also fix the changeset pr: from 3312 (issue) to 3316 (PR). CONTEXT.md
flags this exact failure mode as a recurring CodeRabbit finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address coderabbit review findings

* fix: close remaining coderabbit threads

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:21:42 -04:00
Tom Boucher
8bc255c266 fix(workstream): normalize migration workstream names (#3269)
* fix(workstream): normalize migrate-name to valid slug

* docs(context): record workstream migrate-name slug invariant

* fix(catalog-cjs): balanced fallback for unknown profile (CR finding A)

profiles[profile] could return undefined for any profile key absent from
the catalog entry, causing downstream callers like formatAgentToModelMapAsTable
to crash on .length. Add ?? profiles.balanced fallback to match the SDK adapter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(sdk): anchor path resolution on import.meta.url not cwd (CR finding B)

resolve(process.cwd(), '..') breaks when Vitest is invoked from the repo root
because cwd is already the repo root and '..' goes one level above. Replace
with a file-relative path using fileURLToPath(new URL('../../../', import.meta.url))
anchored at the test file's location (sdk/src/query/).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: derive Group B runtime list from catalog (CR finding C)

Hardcoded ['kilo', 'cline', ...] throws TypeError if a runtime name is
removed from the catalog. Derive group B dynamically via
Object.keys(catalog.runtimeTierDefaults).filter(r => !r.opus) so the
test never goes stale and auto-covers future Group B additions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(workflow): add hermes to Step B runtime options (CR finding D)

hermes appears in the Group A built-in defaults table but was missing from
the AskUserQuestion options in Step B, forcing users to manually type it via
'Other (Group B or custom)'. Add explicit hermes entry for UI consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(config): refresh dynamic_routing tier table; fix stale L671 (findings E+F)

Finding E: tier table was missing 6 heavy-tier agents and 15 standard/light
agents added by this PR. Updated all three rows to match catalog routingTier
assignments (33 agents total).

Finding F: removed stale '18 of 31' claim and agent enumeration; replaced
with accurate note that all 33 agents have explicit catalog entries. Updated
authoritative source pointers to model-catalog.cjs / model-catalog.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(core): add profile-fallback unit tests for quality and budget (CR nitpick G)

The PR introduced quality→opus and budget→haiku unknown-agent fallbacks but
only balanced→sonnet and inherit→inherit were tested. Add two tests covering
the remaining two branches to complete coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* adr: define planning workspace and worktree seam

* refactor(worktree): extract worktree safety policy module

* refactor(workstream): extract active workstream pointer store seam

* test(worktree): cover policy branch paths and persist seam guardrails

* refactor(worktree): centralize health inventory seam for W017

* fix(workspace): align SDK project path policy with CJS planningDir

* refactor(query): unify SDK planning path projection seam

* refactor(init): route workspace projection through planningPaths seam

* docs(adr): add SDK architecture and planning path ADRs

* refactor(worktree): deepen name, pointer, inventory, and config seams

* docs(config): harmonize claude-opus-4-6 to 4-7 in resolve_model_ids example (CR finding 2)

* fix(sdk): return undefined for model_profile='inherit' sentinel (CR finding 3)

* docs(adr): renumber conflicting 0003-sdk-package-seam-module to 0007, update seam-map reference (CR finding 4)

* fix(workstream): align CJS and SDK name validation to accept dots, guard path traversal via includes('..') (CR finding 5)

* fix(sdk): guard writeActiveWorkstream against non-existent workstream directory, k014/k031 parity (CR finding 6)

* chore(changeset): add #3269 changeset (CR finding 1 — proper changeset for this PR)

* docs(inventory): register 3 new CLI modules in INVENTORY.md/MANIFEST (active-workstream-store, workstream-name-policy, worktree-safety)

* fix(sdk): use relPlanningPath(workstream) in planningPaths, fix setActiveWorkstream/getActiveWorkstream name errors in workstream.ts

* fix(sdk): validate GSD_WORKSTREAM in planningPaths before use (#3269 regression)

planningPaths() called resolveWorkspaceContext() which returned GSD_WORKSTREAM
raw (no validation). An invalid value like '../evil' was used as effectiveWorkstream,
constructing a bad path; roadmapAnalyze() caught the ENOENT and returned a
no-phase_count error object instead of the root ROADMAP result.

Fix: validate envCtx.workstream with validateWorkstreamName() in planningPaths()
before accepting it as effectiveWorkstream. Invalid env → null → root .planning/
fallback, preserving the bug-2791 contract: invalid GSD_WORKSTREAM is silently
ignored and falls back to the root context (phase_count: 0 for empty root ROADMAP).

The bug-2791 regression test now passes. No other call sites read GSD_WORKSTREAM
without validation: query-runtime-context.ts already validates; cli.ts already
validates; context-engine.ts takes a caller-validated workstream parameter.

Closes #3268 (regression introduced by #3269 workstream-name-policy work).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:15:04 -04:00
LakshmanTurlapati
caec78ed38 fix: harden workstream session routing fallback 2026-04-02 21:36:36 -05:00
LakshmanTurlapati
5ce8183928 fix: isolate active workstream state per session 2026-04-02 20:01:08 -05:00
Tibsfox
89f82d5483 fix(workstream): require name arg for set, add --clear flag (#1527)
workstream set with no argument silently cleared the active workstream,
a footgun for users who forgot the name. Now requires a name arg and
errors with usage hint. Explicit clearing via --clear flag, which also
reports the previous workstream in its output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:35:00 -07:00
Tom Boucher
616c1fa753 refactor: replace try/finally with beforeEach/afterEach + add CONTRIBUTING.md
Test suite modernization:
- Converted all try/finally cleanup patterns to beforeEach/afterEach hooks
  across 11 test files (core, copilot-install, config, workstream,
  milestone-summary, forensics, state, antigravity, profile-pipeline,
  workspace)
- Consolidated 40 inline mkdtempSync calls to use centralized helpers
- Added createTempDir() helper for bare temp directories
- Added optional prefix parameter to createTempProject/createTempGitProject
- Fixed config test HOME sandboxing (was reading global defaults.json)

New CONTRIBUTING.md:
- Test standards: hooks over try/finally, centralized helpers, HOME sandboxing
- Node 22/24 compatibility requirements with Node 26 forward-compat
- Code style, PR guidelines, security practices
- File structure overview

All 1382 tests pass, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:45:39 -04:00
Tom Boucher
7c762058e1 fix: address post-merge review concerns from PR #1268
Three non-blocking findings from the adversarial re-review of the
workstream namespacing PR, addressed as a follow-up:

1. setActiveWorkstream now validates names with the same regex used
   at CLI entry and cmdWorkstreamSet — defense-in-depth so future
   callers can't poison the active-workstream file

2. Replaced tautological test assertion (result.success || !result.success
   was always true) with actual validation that cmdWorkstreamSet returns
   invalid_name error for path traversal attempts. Added 8 new tests
   for setActiveWorkstream's own validation.

3. Updated stale comment in copilot-install.test.cjs (said 31, actual 56)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 10:47:55 -04:00
SalesTeamToolbox
1ad5ab8097 fix: use planningRoot in setActiveWorkstream and add path traversal tests
Address review feedback on PR #1268:
- setActiveWorkstream now uses planningRoot(cwd) instead of hardcoded
  path, matching getActiveWorkstream for consistency
- Add 33 path traversal rejection tests covering all entry points:
  CLI --ws flag, GSD_WORKSTREAM env var, cmdWorkstreamSet, and
  poisoned active-workstream file

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:21:13 -07:00
SalesTeamToolbox
c83b69bbb5 feat: workstream namespacing for parallel milestone work
Enable multiple Claude Code instances to work on the same codebase
simultaneously by scoping .planning/ state into workstreams.

Core changes:
- planningDir(cwd, ws?) and planningPaths(cwd, ws?) are now workstream-aware
  via GSD_WORKSTREAM env var (auto-detected from --ws flag or active-workstream file)
- All bin/lib modules use planningDir(cwd) for scoped paths (STATE.md, ROADMAP.md,
  phases/, REQUIREMENTS.md) and planningRoot(cwd) for shared paths (milestones/,
  PROJECT.md, config.json, codebase/)
- New workstream.cjs module: create, list, status, complete, set, get, progress
- gsd-tools.cjs: --ws flag parsing with priority chain
  (--ws > GSD_WORKSTREAM env > active-workstream file > flat mode)
- Collision detection: transition.md checks for other active workstreams before
  suggesting next-milestone continuation (prevents WS A from trampling WS B)
- ${GSD_WS} routing propagation across all 9 workflow files ensures workstream
  scope chains automatically through the workflow lifecycle

New files:
- get-shit-done/bin/lib/workstream.cjs (CRUD + collision detection)
- get-shit-done/commands/gsd/workstreams.md (slash command)
- get-shit-done/references/workstream-flag.md (documentation)
- tests/workstream.test.cjs (20 tests covering CRUD, env var routing, --ws flag)

All 1062 tests passing (1042 existing + 20 new workstream tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:21:13 -07:00