a731a45cd6a68cf5bfa1144f4a09f550555db56e
5000 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a731a45cd6 |
fix(#3116): strip trailing CR per line in parseFrontmatterStrict for CRLF WINDOWS.md (#3137)
* test(#3116): failing-first — parseLedger throws on CRLF WINDOWS.md On repos with core.autocrlf=true (Windows default), .planning/WINDOWS.md is checked out CRLF. The \n--- close-fence scan leaves the last frontmatter line's CR attached, and the key:value regex's . doesn't match CR, so the parser throws WINDOWS_LEDGER_MALFORMED on the last key. * fix(#3116): strip trailing CR per line in parseFrontmatterStrict The \n--- close-fence scan lands on the LF of the last frontmatter line's CRLF, so yamlBody ends with a bare \r. split(/\r?\n/) strips CR from interior lines but the last line's \r survives. The key:value regex fails because . doesn't match CR. Fix: strip \r per line (rawLine.replace(/\r$/, '')) rather than normalizing raw — the writer round-trips raw byte-exact. * chore(#3116): add changeset fragment * chore(#3116): backfill changeset PR number 3137 --------- Co-authored-by: sim <sim@local> |
||
|
|
0e6fa2e2cf |
enhance(#3118): close the dead injectables and the shell projection follow-on — Wave 4 (#3124)
* test(#3118): failing-first coverage for the dead injectables and the shell projection Adds the counter-tests Wave 4 closes against, before any fix: - antigravityWatermark had zero test references. The four existing tests that look like watermark coverage hand the fallback a literal mark and never call the producer, so nothing pinned whether a real run's mark is correct. Covers all six branches plus the non-object cache classes. - Pins the fail-open: a transcript read that throws reports lines:0, indistinguishable from a genuinely empty transcript, and the consumer then replays a previous run's review as this run's. - Pins the export-line escaping across the repair, persist and win32 bash lanes, including the parity assertion that they must not diverge. - sliceCurrentPositionSection: empty-vs-absent, fenced heading, second occurrence, H3, CRLF. - Proves deps.progressProvider is inert by supplying a throwing stub to all ten transition intents. Verification through the remote runner only. Refs #3118 * fix(#3118): distinguish an unreadable transcript from an empty one antigravityWatermark's final read can throw on a transcript that indisputably exists. It returned lines:0, which is the same value a genuinely empty transcript produces, so the caller could not tell the two apart. antigravityTranscriptFallback derives its skip from that count. A mark of {convId:'c1', lines:0} for a conversation that pre-dates the run makes it skip nothing and return the last PLANNER_RESPONSE in a transcript written before this run started — a previous review presented as this one's, which is exactly what the function's own 'never stale' docstring promises cannot happen. The unreadable case now sets unreadable:true and the fallback declines for a same-conv-id unreadable mark. An absent or empty transcript is untouched: those genuinely have zero prior lines. * fix(#3118): escape the export line for the file it lands in, not the echo Three lanes emit export PATH="<dir>:$PATH". repair escaped it with escapePosixDoubleQuoted; persist and the win32 Git Bash lane escaped it with escapeSingleQuotedShellLiteral instead. The single-quoting is correct for the echo, so nothing runs when the user pastes the command. But the bytes appended to ~/.bashrc are the export line itself, and inside double quotes in an rc file a $(...) or a backtick in the directory name is command substitution that runs on every new shell. Those characters are legal in a path on both POSIX and Windows, so the path was reachable. projectPathExportLine is now the single source of that line and escapes for its final rc-file context; each lane still applies its own transport escaping on top. fish keeps the single-quote escaper — its value really does stay single-quoted. The cmd.exe lane interpolated into a cmd double-quoted string with no cmd-level escaping, so a quote closed the region and &cmd& ran. A quote is reserved on Windows and cannot appear in a real path, so there is no correct command to suggest: the win32 lanes now fail closed for one. Metacharacter-free paths render byte-identically on every lane. * fix(#3118): drop a stray carriage return and a deps field nobody reads locateCurrentPosition subtracted a fixed one byte to exclude the newline before the next heading, which assumes LF. On a CRLF document the slice kept an unpaired trailing carriage return. It now walks back over the newline and over a preceding carriage return if there is one. StateTransitionDeps also required a progressProvider that 33 sites supplied and no site ever called. A required field nothing reads widens the module's interface without changing its implementation, which is the shape epic #3051 cites as its reason for refusing blanket injection. Removed along with the ProgressRecord alias that existed only as its return type; state-document.cts's unrelated interface of the same name is untouched. * fix(#3118): stop an empty span duplicating bytes, and name the empty results Three findings from the isolated review pass. locateCurrentPosition could return end < start when the section was empty and the next heading followed with no blank line between. Every mutator splices with slice(0,start) + body + slice(end), so an inverted span duplicated the region between them — a blank line silently inserted into STATE.md on every transition, two bytes on CRLF. The span is now clamped, and an empty section is a zero-length span, which is what it always meant. The win32 fail-closed path left the installer printing 'Add it with one of:' with nothing under it. An empty shellActions folded two different facts together, so projectPathActionProjection now carries a frozen PATH_ACTION_REASON and the installer branches on it. Two empty results with different causes staying distinguishable is the subject of the epic this belongs to. fish_add_path parses a leading dash as an option, so a directory named -v printed 'No paths to add' instead of being added. Verified against fish 4.8.1: the end-of-options separator fixes it. Replaces the console-prose test the second fix first arrived with — a regex over captured stdout is what CONTRIBUTING prohibits, and the typed reason is the surface it asks for instead. * fix(#3118): escape TOML control characters, and stop a test name overstating Five findings from the two review axes. escapeTomlDoubleQuotedString escaped only backslash and quote. TOML basic strings also require U+0000-U+0008, U+000A-U+001F and U+007F to be escaped, so a value carrying a raw newline or NUL wrote a config.toml no parser accepts — rejecting the whole file, not just that value. Four of its call sites write real config. Tab stays raw; the grammar exempts it. The byte-identity test claimed every lane was unchanged for an ordinary path, which is false: fish now takes the end-of-options separator on every path, not only hostile ones. Renamed, and the one intended delta now has its own named test instead of hiding inside a claim that read as broader than it was. Also: exact-equality assertions in place of substring checks that could pass on a subtly wrong escape, newline and null-byte cases for all five quoting primitives, and a temp dir registered with t.after so it is removed when an assertion fails. * docs(#3118): add the changeset fragments * fix(#3118): degrade instead of throwing on a null conversation cache A cache file whose whole content is the literal null — what a truncated or zeroed write leaves behind — made both antigravityWatermark and antigravityTranscriptFallback throw. JSON.parse('null') succeeds, so the try/catch wrapping the parse never fired, and resolveConvId then called hasOwnProperty on null. Both functions advertise the opposite; the existing test next to them is named 'a missing cache or transcript degrades to empty, never throws'. Parsing successfully is not the same fact as the payload being usable, and a guard that only wraps the parse cannot tell them apart. resolveConvId is now total for any non-object input, so one guard covers both callers. Caught by the null case in this wave's own cache matrix. * test(#3118): correct a stale fish expectation and a parity comparison The pre-existing 'POSIX persist mode escapes single quotes' test pinned fish_add_path without the end-of-options separator this wave adds, so it asserted behavior that is no longer correct. A repo-wide scan found one such hardcoded expectation; every other site derives its expectation from the projection. The new parity test compared the token from a POSIX path against the win32 lane, which posix-normalizes its input first — two different inputs, so the tokens differed for a reason that had nothing to do with the parity it claims to check. It now derives the win32 expectation from the same input the lane receives. * docs(#3118): reword a comment the injection scanner reads as an instruction The scanner pattern act\s+as\s+(?:a|an|the)\s+ carries no word boundary, so 'the same fact as the payload' matched on the tail of 'fact'. Reworded per the documented remedy for this collision. The missing boundary is a scanner defect rather than a prose problem — any contributor writing 'fact as the' trips it — but the pattern is gate plumbing, which the sibling epic owns, so it is surfaced rather than changed here. * chore(#3118): backfill changeset pr number to 3124 * chore(#3118): backfill changeset pr number to 3124 * fix(#2784): make the negation scan single-pass and index it correctly Three defects in the negation suppression added by #3127, all in one block, none of which had a test. The pair scan was verbs.some(nouns.some(...)) with a slice and a split per pair, so it grew cubically with clause length: 1.1ms before that PR and 8462ms after, on 800 verb+noun pairs in one clause. api-coverage's property test generates documents large enough to reach the runner's 600s file cap, which is why it hangs as 'fail 0, cancelled 1' rather than failing an assertion. Every (verb, noun) window is a subset of the single widest one, so one scan of that window answers the same question in a linear pass. Verified equivalent against the old predicate over 20,000 generated clauses. Both checks also subtracted clause.start from offsets that collectTerm- Matches already returns clause-local. The first clause on a line has start 0 so it worked there and nowhere else: later clauses went negative, and slice reads a negative index from the end, so suppression silently examined unrelated text. The comment claimed 'without any API integration' was suppressed. It is not — the qualifier sits outside the two-word lookback and the noun precedes the verb. Widening the window would trade a false positive that costs one declaration line for a false negative that slips a real integration past a blocking gate, so the behavior stands and the comment now says so. Pinned by a test. The qualifier sets were also rebuilt for every line of every document. |
||
|
|
0ccc18dd3c |
fix(#2665): scrub config-location env vars in TEST_ENV_BASE (#3134)
* fix(#2665): scrub config-location env vars in TEST_ENV_BASE TEST_ENV_BASE scrubbed 14 session-identity vars but omitted CLAUDE_CONFIG_DIR, GSD_RUNTIME, and CODEX_HOME. The config-home resolver (runtime-homes.cts) consults these env vars BEFORE the HOME-derived fallback, so an ambient value won unconditionally over a sandboxed HOME. npm test wrote fixtures into the developer's live config directory when any of these were set. One leaked fixture was a registered skill (gsd-dev-preferences/SKILL.md) carrying behavioral directives that loaded into subsequent sessions. All three config-location vars are now blanked in TEST_ENV_BASE. Per-site overrides still win (env is spread last in the child-env merge). * chore(#2665): backfill changeset PR number 3134 * ci: retry shard timeout flake (#2665) * ci: retry shard-2 timeout flake (#2665) --------- Co-authored-by: sim <sim@local> |
||
|
|
94bf32a2bb |
fix(#2786): skip sentinel phase ids in phase-complete stage 2 heading scan (#3130)
* fix(#2786): skip sentinel phase ids in phase-complete stage 2 heading scan Stage 2 of the next-phase cascade accepted any higher-numbered roadmap heading without checking the 999.x backlog sentinel convention that stage 1 already checks. A Phase 999.1: Backlog Item heading was treated as the next real phase, advancing STATE.md into the backlog and making the milestone perpetually 'Ready to plan' instead of 'All phases complete'. Added isSentinelPhaseId(pm[1]) guard (mirrors stage 1's /^999(?:\.|$)/ check) so both sentinel ranges (0.x drafts, 999.x backlog) are skipped. * chore(#2786): backfill changeset PR number 3130 --------- Co-authored-by: sim <sim@local> |
||
|
|
1c9f6a08e3 |
fix(#2784): suppress detectApiIntegration on negated prose (#3127)
* fix(#2784): suppress detectApiIntegration on negated prose clauses The compound verb+noun rule had no negation awareness. A clause like "This phase integrates no external API" matched verb=integrates + noun=API and fired a false positive, halting the blocking verify:pre gate and forcing a coverage matrix for a non-existent API. Added clause-local negation suppression: if the clause contains an unambiguous negation qualifier (no, not, without, zero, neither, nor, none, and common contractions), the pair is suppressed. True positives ("integrate the Stripe API") are unaffected. The human override (COVERAGE.md "no integration" declaration) remains valid. * chore(#2784): backfill changeset PR number 3125 * chore(#2784): fix changeset PR number to 3127 * chore(#2784): trigger CI re-run --------- Co-authored-by: sim <sim@local> |
||
|
|
fee72d5560 |
fix(#3044): add zh-CN verification-patterns.md to secret-scan exclusions (#3122)
* fix(#3044): add zh-CN verification-patterns.md to secret-scan exclusions The translated document carries the same illustrative placeholder examples (Stripe test-key, database-URL, API-key) as the English source, which is already excluded. The exclusion was never extended to the zh-CN translation, causing the strict-mode scan to fail. No other locale has a translation of this file (verified: ja-JP, ko-KR, pt-BR do not have it). * chore(#3044): backfill changeset PR number 3122 --------- Co-authored-by: sim <sim@local> |
||
|
|
610ebdebe8 |
docs(#3043): add caution blocks for --dangerously-skip-permissions (#3121)
* docs(#3043): add caution blocks for --dangerously-skip-permissions The flag was presented without a caveat in docs/USER-GUIDE.md, docs/tutorials/onboarding-an-existing-codebase.md, and all four translated locales. Only the English first-project tutorial carried a proper [!CAUTION] block. All 10 uncaveated occurrences now carry the same caution block (optional flag, throwaway/low-stakes use, how to keep confirmations, link to security model). * chore(#3043): backfill changeset PR number 3121 --------- Co-authored-by: sim <sim@local> |
||
|
|
b181c2f8c3 |
fix(#3039): clamp max/xhigh effort to high for Claude-runtime skills (#3119)
* fix(#3039): clamp max/xhigh effort to high for Claude-runtime skills effort: max in plan-phase, execute-phase, and autonomous SKILL.md frontmatter was passed through as output_config.effort, which the Anthropic API rejects when extended thinking is disabled (400: effort 'max' is not supported when thinking is disabled on this model). The frontmatter is static at install time and the installer cannot know whether thinking will be on or off at invocation. normalizeClaudeSkillEffort now clamps both 'max' and 'xhigh' to 'high' — the maximum value that works in both thinking states on all supported models. Applied in both src/runtime-artifact-conversion.cts and bin/install.js. * chore(#3039): backfill changeset PR number 3119 * fix(#3039): regenerate skills with clamped effort: high --------- Co-authored-by: sim <sim@local> |
||
|
|
077028584f |
fix(#3036): accept non-numeric-leading phase ids in roadmap.analyze (#3117)
* fix(#3036): accept non-numeric-leading phase ids in roadmap.analyze roadmap.analyze's phase-heading discovery and checklist discovery regexes required a digit-first id (\d+...). A project using letter-prefixed ids (e.g. B7, P0.3-2) got phase_count: 0, current_phase: null, next_phase: null — even though get-phase and execute-phase resolved the same ids fine. Widened all three regex sites (phasePattern, checklistPattern, nextHeader section boundary) to accept an optional leading letter prefix ([A-Za-z]?\d+...). Existing numeric-leading ids are unchanged. * chore(#3036): backfill changeset PR number 3117 --------- Co-authored-by: sim <sim@local> |
||
|
|
e7ce60fd21 |
fix(#3035): add kimi-code detection and flag to review workflow (#3115)
* fix(#3035): add kimi-code detection and flag to review workflow The kimi-code reviewer lane was declared in REVIEWER_LANES, documented in docs/COMMANDS.md, resolved via --kimi-code, and functional when reached — but review.md's detect_clis hardcoded 11 of 12 lanes (no kimi probe) and the flag-parse list omitted --kimi-code. /gsd:review --kimi-code could never reach SELECTED_REVIEWERS. Added command -v kimi detection and the --kimi-code flag to the review workflow's CLI detection and flag-parse steps. * chore(#3035): backfill changeset PR number 3115 --------- Co-authored-by: sim <sim@local> |
||
|
|
fb3ee56651 |
fix(#3033): resolve zero-plan split-parent phase as complete when roadmap checkbox is checked (#3114)
* fix(#3033): resolve zero-plan split-parent phase as complete when roadmap checkbox is checked A phase split into sub-phases (parent kept as shared context, zero plans by design) was permanently stuck as 'researched' because the roadmap- checkbox override at line 2266 required completion.phase_complete (derived from plan/summary counts), which is always false for zero-plan phases. The parent was permanently eligible for current-phase selection and re-planning recommendations. The override now fires when roadmapComplete AND planCount === 0 (the split-parent shape), treating it as complete regardless of the plan-count derivation. A zero-plan phase whose checkbox is still unchecked stays in-progress (researched). Ordinary phases with plans are unchanged. * chore(#3033): backfill changeset PR number 3114 --------- Co-authored-by: sim <sim@local> |
||
|
|
2061919b1a |
fix(#3029): remove redundant hooks declaration from plugin manifest (#3113)
* fix(#3029): remove redundant hooks declaration from plugin manifest .claude-plugin/plugin.json declared "hooks": "./hooks/hooks.json" explicitly. Claude Code already auto-loads hooks/hooks.json by default, so the explicit declaration caused a duplicate-rejection that silently disabled every hook the plugin ships — security guards, monitors, injection scanners. The failure had no visible signal in normal use. Removed the redundant hooks field. Updated the manifest test to assert ABSENCE of the field and that the auto-loaded file still exists on disk. * chore(#3029): backfill changeset PR number 3113 --------- Co-authored-by: sim <sim@local> |
||
|
|
60cf18999b |
fix(#3026): document --pi and --gemini in installer --help (#3112)
* fix(#3026): document --pi and --gemini in installer --help --help documented 16 runtime flags but the installer accepts 18: --pi (named only in banner prose) and --gemini (invisible everywhere). Both install correctly when passed but are undiscoverable via --help. Added both to the Options section. Added a parity test that asserts every accepted runtime flag appears in --help output, with an exclusion set for legacy aliases (--both, --kimi-code). * chore(#3026): backfill changeset PR number 3112 --------- Co-authored-by: sim <sim@local> |
||
|
|
d39072b637 |
fix(#3022): suppress spurious typebox fallback warning on Pi startup (#3111)
* fix(#3022): suppress spurious typebox fallback warning on Pi startup The Pi adapter's buildGsdInvokeParameters attempts require('typebox') and falls back to a plain JSON-Schema object when it's unavailable (typebox is NOT a gsd-core dependency). The fallback is the normal, expected path — typebox is never installed in a Pi extension context. But the code emitted a process.stderr.write warning on every startup, alarming users. Removed the warning; the catch comment now explains the fallback is normal. * chore(#3022): backfill changeset PR number 3111 --------- Co-authored-by: sim <sim@local> |
||
|
|
10da377794 |
fix(#3021): recognize worktree-wf_* branch namespace in all guards (#3109)
* fix(#3021): recognize worktree-wf_* branch namespace in all guards The Claude-orchestration Workflow backend (#1143) creates per-plan worktrees on branches named worktree-wf_<runid>-<n>. Four independent copies of the agent branch allow-list regex (^(worktree-)?agent-...) never learned this namespace: - hooks/gsd-worktree-path-guard.js:176 — FAILED OPEN (process.exit(0)), silently disabling path containment for exactly the concurrent dispatch mode where cross-worktree writes are most likely - src/worktree-safety.cts:21 — silently dropped cleanup-wave manifest entries - agents/gsd-executor.md:503 — FATAL halt on branch check - gsd-core/references/worktree-branch-check.md:33 — same FATAL halt Extended all four to ^((worktree-)?agent-|worktree-wf_)[A-Za-z0-9._/-]+$. The path guard now correctly blocks cross-worktree writes for Workflow- backend branches instead of no-op'ing. * chore(#3021): backfill changeset PR number 3109 --------- Co-authored-by: sim <sim@local> |
||
|
|
bbdf34a332 |
Merge pull request #3106 from open-gsd/test/3057-wave3-negative-space
fix(#3057): reach the branches nothing could reach, and stop reaping on a PID we never probed — Wave 3 |
||
|
|
d6cce9e2c3 |
fix(#3020): verify graphify tool identity before reporting compatibility (#3107)
* fix(#3020): verify graphify tool identity before reporting compatibility checkGraphifyVersion ran 'graphify --version' on PATH and trusted any plausible version string — a foreign binary named 'graphify' that happened to print a version would silently report compatible:true with no warning. Downstream graphify work would then proceed against the wrong tool and fail later in ways that looked like GSD defects. After getting a version from the binary, verify the graphifyy Python package via importlib.metadata. If the package cannot be confirmed, emit a warning naming the mismatch regardless of version-range compatibility. The compatible flag is now correctly false for unverified tools (it was previously read by nobody — the warning is what surfaces). * chore(#3020): backfill changeset PR number 3107 --------- Co-authored-by: sim <sim@local> |
||
|
|
2134d97396 |
test(#3103): expect git's separators, not the ones this machine happens to use
The Windows shard failed on the worktree-root assertion: actual C:/Users/runneradmin/AppData/Local/Temp/gsd-wt-info-Nn4hj6 expected C:\Users\runneradmin\AppData\Local\Temp\gsd-wt-info-Nn4hj6 The value comes straight from `git rev-parse --show-toplevel`, and git reports POSIX separators on every platform. The expected side was built with the native realpath, so the test encoded the separator convention of the machine it was written on. Normalising the expected side keeps the assertion exact — on POSIX the replacement is a no-op, so nothing is weakened where it already passed. This is the same assertion that was strengthened earlier today from a typeof-string-and-non-empty shape check. Pinning the exact path was right; the weaker version would have passed on Windows precisely because it asserted almost nothing. Getting a real assertion wrong on one platform is the better failure, and the Linux-only matrix could not see it — the platform shards caught it, as they did twice in the previous wave. Every other path comparison in the two new test files was swept for the same mistake. The remaining ones are safe: the base-branch tests compare against literal POSIX strings supplied to a mocked git, and the reap tests put both sides through one canonicalising helper, so they cannot disagree on separators. Drive-letter case can differ in principle at the fixed site; it did not here and no case-folding was added on speculation. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
96fe8a81b4 |
chore(#3103): add the Fixed changeset now that the PR number exists
The fragment carries the real PR number because changeset-lint rejects the pr: 0 placeholder, so it can only be written once the PR is open. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8fb6681a1e |
fix(#3017): scope state write disk scan to stored milestone (#3105)
* fix(#3017): scope state write's disk scan to stored milestone buildStateFrontmatter called getMilestonePhaseFilter(cwd) WITHOUT the stored milestone version, so it auto-derived from ROADMAP.md — and when getMilestoneInfo mis-bound (the stored milestone had no matching non-✅ heading), it picked a confidently-wrong milestone and clobbered the stored value + rewrote progress with whole-project counts on every state.* write. Pass the stored milestone from STATE.md frontmatter through to buildStateFrontmatter and use it as the explicit versionOverride for getMilestonePhaseFilter. When the stored milestone is available, the filter scopes to it instead of auto-deriving. * chore(#3017): backfill changeset PR number 3105 --------- Co-authored-by: sim <sim@local> |
||
|
|
1e844b942c |
fix(#3103): only "no such process" means the owner is dead
Review found the previous commit fixed the wrong cliff. process.kill accepts a pid up to 2147483647 and throws a TypeError above it, so 2147483648 — an ordinary finite number — sailed past the finite check, threw, and was classified dead. Measured here: 2147483646 and 2147483647 raise ESRCH, 2147483648 and above raise ERR_INVALID_ARG_TYPE. The digit-length boundary the last commit pinned was a different, earlier gate, and its tests implied it was the meaningful one. The liveness helper now treats only ESRCH as dead. EPERM, a type error from an out-of-range pid, an error with no code at all — every outcome it does not recognise returns alive, because the value feeds a forced worktree removal and an unrecognised failure must never read as permission to delete. Against a build with the old catch, a lock holding 2147483648 reaps the worktree; with this one it is skipped and the directory survives. The finite check stays. It no longer carries the safety, but it still names garbage input accurately rather than reporting it as a live owner, and its comment now says that so it is not removed as redundant. The freshness verdict is split. An unreadable lock mtime and a genuinely recent lock both reported lock_too_fresh, which tells an operator to wait when waiting cannot help — the same conflation this module already separated for parse failures. The unreadable case is now lock_age_unknown. Only this module and its tests read these strings; no workflow or command consumes them. The dependency spread in the prune path is kept as it is, with a test that kills it. A caller-supplied porcelain parser displacing the default is the intended seam, not an accident: the parser is a declared member of the dependency type and the planner already prefers an injected one, so the hard-coded key restates the same default rather than guarding against override. Previously nothing exercised that line at all. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
573d39ea60 |
fix(#3103): refuse to reap on a PID the parse could not represent
A lock file whose PID is a digit string longer than 308 characters parses to Infinity, not NaN. The default liveness helper then calls process.kill with it, which throws a TypeError rather than an errno error, and that helper's catch only recognises EPERM — so it returns false, meaning "the owner is dead", and the worktree becomes eligible to be removed. The reaper already fails closed for this exact situation. It wraps the liveness call in a catch that sets alive and does not reap, with a comment saying liveness could not be determined. That protection never fires here, because the inner catch swallowed the error first and answered confidently instead of admitting it did not know. A guard that cannot verify safety reporting success is the defect this whole epic is named for, and it was sitting inside the one function in the tree that deletes things. The guard deleted earlier on this branch tested for NaN. That test really was dead — a digits-only capture cannot parseInt to NaN — but the reachable failure is non-finite, so removing it without correcting the predicate left the hole open. The check is now for a finite value, and a malformed PID reports the same lock_owner_unknown skip as an unparseable one, since both mean the same thing: the owner is unknown, so nothing is removed. Measured, not assumed: 308 nines still parse finite, 309 are Infinity. All three of that boundary are covered, along with a 400-digit case that asserts the worktree is still on disk afterwards — the consequence, not just the verdict. Against a build with the guard removed, that case reports pid_dead_and_merged and the worktree is gone. The liveness helper's own catch still maps every non-EPERM error to "dead". The fix belongs where the value stops being trustworthy rather than at the far end of it, but that helper is worth revisiting on its own terms. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a7fdedac6a |
test(#3103): drive the orphan reaper through its injected dependencies
Thirty-four tests covering every branch in the reaping path that no test reached, which was all of the fail-closed ones. The function has always accepted an injectable dependency bag; nothing used it. Every existing test drove real git and injected only the clock and the liveness probe, so each guard that exists for a failure — an unreadable git dir, a null directory listing, an unresolvable remote ref, a missing pointer file, an unlocked sibling, an ambiguous remote — had never executed. They are now driven by injecting exactly the fault that selects them, and each asserts its specific status and reason rather than that something happened. The last one needed no new mechanism, only the right one. It was reported as unreachable without a cross-user PID, but the default liveness helper is reachable by not injecting over it and patching process.kill, which is the deterministic injection this repo requires over real OS conditions. Its three outcomes — EPERM, ESRCH, and a clean return — now assert their verdicts. The assertions were kill-tested rather than assumed. Against mutated copies of the built module, renaming the six reason strings fails eighteen tests, neutralising the fail-closed returns fails seven more, dropping the ambiguous-remote guard fails one, and removing the prune catch and its timeout guard fails both prune tests. Flipping the EPERM arm to false turns a skip into a reap and fails that test. Four places where production folds distinct causes into one verdict are recorded in the tests rather than papered over. A lock is too fresh whether its mtime is unreadable or merely recent; a branch tip fails to resolve for three different reasons; a PID reads as alive whether the owner lives or the probe threw. Where the return value cannot separate them the tests assert the git call sequence instead, and where even that cannot, the test says so. Six weak assertions already in the older file are replaced rather than left beside the new ones: five guarded their assertions behind `if (entry)`, so a missing entry skipped the check and passed, and one asserted only that the reaper returned a non-empty array. Three JSON parses wrapped in doesNotThrow now parse directly, so a malformed payload reports its own syntax error instead of a generic message. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4926c2e904 |
fix(#3004): update Codex adapter collaboration-tool vocabulary (#3104)
* fix(#3004): update Codex adapter collaboration-tool vocabulary The generated Codex skill adapter documented stale tool vocabulary: - wait(ids) → collaboration.wait_agent(timeout_ms=...) (the real tool), with explicit disambiguation from the unrelated exec-cell functions.wait - close_agent(id) unconditional → gated on tool visibility (same schema- detection pattern already used for spawn_agent's agent_type field) - Missing required task_name field and fork_turns parameter → added alongside the existing fork_context guidance (coexist, not replace) Updated the regression test to assert the new vocabulary (wait_agent not wait(ids), functions.wait disambiguation, task_name, fork_turns, tool_search gate on close_agent). * chore(#3004): backfill changeset PR number 3104 --------- Co-authored-by: sim <sim@local> |
||
|
|
63404ca45b |
test(#3103): pin every branch in the base-branch resolver to the value it returns
Thirty-eight tests, covering every previously unreached branch in this module: malformed and non-object config at each nesting level, the origin/HEAD path returning a bare remote prefix, the remote-show parse missing its HEAD line and its "(unknown)" sentinel, three catch arms, the spawn-failure path that reports unverified, the worktree probe answering something other than "true", a non-zero toplevel query, a blank toplevel, and the default diagnostic sink on both arms. The one worth naming is the local-branch fallback that was deleted earlier today as unreachable and restored. It is hard to pin because it and the empty-stdout guard above it both return null, so the return value cannot tell them apart. The test drives it through a result object whose stdout is a getter that counts reads: one read means the guard fired, two means the guard passed and the fallback ran. Deleting the line again fails the value assertion — the function would return undefined — and changing the guard to swallow whitespace fails the read count, which is the other way that branch dies. Tier attribution is now pinned. Four tests rig the lower tiers to answer with DIFFERENT branch names and assert the recorded call sequence, so a result from symref, remote-show, local-branch, or config can no longer be mistaken for another. Previously any of them could have produced the answer and the test would not have noticed. Each tier's argv and timeout are asserted exactly. The boundary trio applies to the branch listing rather than a numeric limit: zero matching lines, one, and two. Three assertions already in this file were removed rather than left beside the new ones. A doesNotThrow around the worktree probe became an exact-value test of the catch arm; a typeof-string-and-non-empty shape check on the resolved root became an equality check against the realpath; and the unverified-fallback diagnostic was asserted only to be non-empty, so it now asserts the exact sentence, which fails if the reason changes rather than only if it vanishes. Two folded bodies used try/finally for teardown and now use t.after. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c2d5b528e5 |
refactor(#3103): give the reap and worktree-info paths the seam their callers already have
Twenty-two branches in the orphan-reaping path are unreachable from the test suite, and the reason is not that they are hard to reach — it is that nothing can reach them. The reaper already accepts an injectable dependency bag, but every test drives real git and injects only the clock and the liveness probe, so each fail-closed return inside it has never executed under test. The two entry points above it took no dependencies at all, so a test could not drive them even if it wanted to. Both now accept the same bag and thread it down, with stdout and stderr writers defaulting to the process streams. The worktree-info probe in the base-branch resolver called its git seam directly while a sibling function in the same file already modelled the injectable form; it now follows that sibling rather than inventing a second convention. Every parameter defaults to today's real implementation, so no existing caller changes behavior. This is a testability seam, not a redesign. Two guards are removed as genuinely dead, each excluded by a check a few lines above it. A NaN test on a value captured by a digits-only pattern cannot fire, because parseInt of digits is never NaN. An emptiness test on a capture group that matched one-or-more non-space characters cannot fire either. Each site keeps a one-line note naming the guard that excludes it, so neither gets restored by a future reader. A third guard was proposed for deletion on the same grounds and is NOT removed, because the claim was wrong. The local-branch fallback returns null when git prints output that is non-empty but names neither branch — the emptiness check above it only catches the empty string, so a single newline reaches the fallback with both flags false. Deleting it would have changed which branch the resolver reports. It stays, and it gets a test. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c7da62b682 |
Merge pull request #3094 from open-gsd/test/3057-wave2-liveness
chore(#3057): remove tests that report coverage they do not have — Wave 2 |
||
|
|
5039d49924 |
ci(#3057): shard the scoped Windows lane, the last unsharded one
The scoped Windows lane reached exactly 15m05s and was cancelled on four consecutive shas of PR #3094. A job that exceeds timeout-minutes reports as CANCELLED rather than FAILURE, which is why it first read as infrastructure noise; the giveaway is that the duration equals the cap. The Required tests rollup fans that job in, so it red-blocked merge while every other lane — including all three sharded full-Windows shards — was green. The trigger was a change to the shared test helper, which scopes the install-heavy suites into the selected list. The lane normally runs about eight minutes; with that list it does not fit. It was the only unsharded lane left in this job, so it was the only one without headroom to absorb a large scoped list. Issue #869 hit this exact cliff on the sibling lane and named the durable answer in its own follow-up: a timeout bump moves the cliff, sharding removes it. #2952 then sharded the full lane. This finishes that work. The runner already supports it — the shard partition is applied after scope selection, so it composes with a selected file list rather than only with a suite, and the partition is cost-weighted from the measured timings table. The job name template already renders a shard suffix when one is present, so the three entries name themselves. No individual matrix job is a required status check; the rollup is, and it is name-independent, so renaming these jobs does not touch branch protection. timeout-minutes stays at 15. Each shard now does roughly a third of the work, so the cap goes from binding to backstop without being raised. The lane-shape tests were generalized rather than relaxed: the complete-shard-set invariant now runs per sharded scope instead of only over the full lane, and "only the full lane is sharded" became "targeted is the only unsharded lane". A new assertion pins the shard through to the runner — without it the three shards would each run the entire selected list, triple the cost and no speedup, and every check would stay green. No LANE_COSTS entry is added for the new shards. The only recorded cost for that lane is the pre-sharding run that hit the cap, and inventing a post-sharding number would be exactly the kind of unmeasured claim the rest of that table avoids. The estimate and the reason are written down instead, to be replaced by a real measurement. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2f5b6a9b48 |
fix(#3001): indent continuation lines in serializeChangelog (#3101)
* fix(#3001): indent continuation lines in serializeChangelog serializeChangelog interpolated bullet bodies verbatim into a single - ${body} (#${pr}) line. Any embedded newline became a column-0 line; parseChangelog's continuation-fold (/^\s+/) didn't pick it up, so flushBullet terminated the bullet early — dropping the continuation text and the (#NNNN) PR trailer (recorded as pr: null). The round-trip property serialize(IR) → parse(text) === IR was false for any body containing \n. Fix: indent continuation lines (body.replace(/\n/g, '\n ')) so the parser folds them correctly. Round-trip test asserts both paragraphs' content AND the PR number survive. * chore(#3001): backfill changeset PR number 3101 --------- Co-authored-by: sim <sim@local> |
||
|
|
f49b9b8f70 |
fix(#2998): point bug template at runtime-home version, not npm list -g (#3100)
* fix(#2998): point bug template at runtime-home version, not npm list -g The bug-report template instructed reporters to run npm list -g for their GSD version, but /gsd-update installs into the runtime home (~/.claude/gsd-core/) without touching the global npm package — so a correctly-updated machine could report a stale or nonexistent version. gsd-tools also rejects --version, so 'npx ... --version' was wrong too. Changed both the version-field description and the diagnostics section to point at cat ~/.claude/gsd-core/gsd-file-manifest.json (the version field the installer writes). * chore(#2998): backfill changeset PR number 3100 --------- Co-authored-by: sim <sim@local> |
||
|
|
7ef9945adb |
test(#3090): stop paying for the guard on every teardown
The Windows node-24 scoped-test job hit its 15-minute ceiling twice on this
branch and GitHub reported both as cancelled, which is how a job timeout
surfaces. The same job finishes in about two minutes twenty on next, across
four consecutive runs. cleanup() is the only hot-path change here.
It was doing up to seven filesystem calls per invocation: three probes of the
temp root, two more for each conventional temp dir, then an existence check and
a realpath of the target. On Windows fs.realpathSync.native opens a file handle
and Defender charges for each one, and this runs in the teardown of effectively
every test.
The root candidates are now memoized on the live os.tmpdir() value. The key
matters: two files in the suite override TMPDIR mid-run and restore it, so a
plain module-level hoist would go stale for them, while re-reading os.tmpdir()
costs an env lookup. Measured over 20,000 calls: 546ms unmemoized, 10ms
memoized.
The symlink-escape check is removed rather than optimized, because it was
guarding something that cannot happen. Verified directly on node v26.5.1:
fs.rmSync(link, {recursive: true, force: true})
link exists false
victim exists true
file exists true
rmSync unlinks a top-level symlink and leaves its target alone, and a symlink
nested inside a tree being recursively removed is also unlinked rather than
followed. The check cost two filesystem calls per teardown on the slowest
platform in the matrix and bought nothing. Its test asserted the victim
survived, which was true with or without the guard.
What closed the original defect is untouched: a target outside the known temp
roots is still refused, before the chdir and before the rmSync, with the roots
named in the message.
Refs #3057
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
aa7697fe97 |
fix(#2997): include phase_id_convention in resolved config (#3098)
* fix(#2997): include phase_id_convention in resolved config _baseConfig in config-loader.cts is an explicit allowlist of keys copied from parsed into the resolved config. phase_id_convention was in VALID_CONFIG_KEYS (manifest line 83) and survived the unknown-key filter, but was never copied into _baseConfig — silently dropped on a clean read. The milestone-prefix validation check could only be activated via the ROADMAP frontmatter fallback, not the documented project-config surface. Added phase_id_convention: get('phase_id_convention') ?? null to _baseConfig. 3 tests: survives resolution, null round-trips, absent resolves to null. * chore(#2997): backfill changeset PR number 3098 --------- Co-authored-by: sim <sim@local> |
||
|
|
1046a721f9 | Merge remote-tracking branch 'origin/next' into test/3057-wave2-liveness | ||
|
|
5628edddda |
fix(#2991): route /gsd command output through Pi's display shape (#3097)
* fix(#2991): route /gsd command output through Pi's display shape The registerCommand('gsd') handler returned a bare string, which Pi's ExtensionAPI does not display. Changed all return paths to Pi's structured { content: [{ type: 'text', text }] } shape, matching the gsd_invoke tool's proven output contract. Updated 2 reachability tests that asserted the old bare-string return shape. * chore(#2991): backfill changeset PR number 3097 --------- Co-authored-by: sim <sim@local> |
||
|
|
2e81516f1f |
test(#3090): accept the temp roots the suite actually uses, and say which ones
A fourth failure of the same guard, found by review before it reached CI: the config-schema property suite builds fixtures through a getWritableTmp() helper that returns the first writable of /private/tmp, /tmp, os.tmpdir(). On macOS that is /private/tmp while os.tmpdir() is /var/folders/.../T, so five cleanup() calls in that file were refused outright. The three earlier breakages were all spellings of one root. This one is not: the suite legitimately uses more than one temp root, so the premise was wrong rather than the encoding. tmpRootCandidates() now probes the conventional system temp dirs alongside os.tmpdir(), each included only if it exists on the host, so the accepted set stays a bounded explicit list instead of growing a patch per platform. Two corrections that follow from the same review: A root that is itself a filesystem root already ends in a separator, and appending another built `//`, which only the literal `/` satisfies — TMPDIR=/ would have refused every descendant. The separator is only appended when it is not already there. The refusal messages named os.tmpdir(), which stopped being the boundary. They now name the roots actually compared against. Every failure of this guard so far was diagnosed from that message in a CI log, so it should show what was checked rather than a stale approximation of it. The symlink-escape check keeps its refusal but drops its claim. fs.rmSync does not follow a top-level symlink — it unlinks the link and leaves the target alone — so that check was never closing a live escape, and the test asserting the victim survived would have passed with the guard removed. Both now say what is true: a defense-in-depth boundary against a future change to the deletion mechanism, with only the refusal itself load-bearing. The QA path helpers were evaluated for reuse rather than keeping a third copy of path containment. They resolve against one project directory and have no multi-root or Windows short-name handling, so they are not a drop-in; noted rather than forced. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2a77e50daf |
fix(#2989): anchor code-review diff-base grep to phase-mention convention (#3096)
* fix(#2989): anchor code-review diff-base grep to phase-mention convention The diff-base fallback in code-review.md used git log --grep with a bare phase number (unanchored substring), matching version strings, dates, issue refs, and other phases' numbers. tail -1 took the oldest match — routinely a commit from months or years before the phase existed. The fail-closed branch was dead code because a bare digit almost always matches something. Changed --grep to '[Pp]hase N\b' with --extended-regexp, anchoring to the phase-mention convention. When no commit genuinely references the phase, the derivation yields empty and the fail-closed warning fires (now reachable). All three consumers (Tier 3 file scope, fallow pre-pass, agent context) use the same corrected value. * chore(#2989): backfill changeset PR number 3096 --------- Co-authored-by: sim <sim@local> |
||
|
|
cc0a4685f1 |
test(#3090): close the symlink escape, and stop the test from mirroring the guard
An isolated review found the safety precondition was not safe. Test 4 uses the repo's own tests/ directory as a path that must never be deleted, and asserted beforehand that it sits outside the temp root — but it computed that root as path.resolve(os.tmpdir()) alone, while cleanup() accepts a target under any of several root spellings. For a checkout under the realpath'd temp root the precondition reads "outside, safe to proceed" while the guard reads "inside, delete it", and rmSync runs on tests/ before the assertion fails. The commit that added it claimed it would fail loudly instead of destructively; it would have done the opposite. The fix is not a second root in the test. tmpRootCandidates() is exported and the precondition calls it, so there is one source of truth and nothing left to drift. A mirror was the defect, not its contents. The same review bounded what the refusal actually guarantees: the check is a string prefix test, so a symlink living under tmpdir but pointing outside it passes while rmSync follows the link and deletes the real directory. When the target exists its real path is now checked too, against the same predicate — factored into one function so the two comparisons cannot diverge the way the test's copy did. A realpath failure refuses rather than proceeds; a safety check that cannot verify must not report safe, which is the whole subject of this branch. Missing targets are skipped, since rmSync with force no-ops on them and realpathSync would only throw ENOENT. `const isTmpPath = true` is gone. It survived the previous commit as a way to keep the catch's `&& isTmpPath` reading as a real condition, but a constant dressed as a test states nothing; the guard clauses above throw, so the catch comment now says the invariant in words instead. The new coverage does not depend on the platform the bug lives on. The root list is asserted directly — non-empty, absolute, deduped, and containing a freshly created temp dir — and the symlink refusal runs everywhere, skipping only where symlink creation is unavailable. The previous realpath test was coverage-identical to the control on Linux, so the only lanes the matrix runs could not have verified the fix it was written for. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b375d76a10 |
test(#3090): accept every canonical spelling of the temp root, not one platform's
Windows CI refused legitimate temp directories for the same reason macOS did, one commit earlier: cleanup() refused to remove a path outside os.tmpdir(): C:\Users\runneradmin\AppData\Local\Temp\bug-3491-7zisup GitHub's windows runners report os.tmpdir() in the 8.3 SHORT form (C:\Users\RUNNER~1\AppData\Local\Temp) while callers hold the expanded LONG form. fs.realpathSync() does not reliably expand 8.3 names; only fs.realpathSync.native() does. Casing can differ independently (C:\ vs c:\). Two platform-specific breakages from the same check is a sign the check was written against one spelling rather than the concept, so this stops patching symptoms. tmpRootCandidates() collects every variant derivable from os.tmpdir() — resolved, realpath'd, and native-realpath'd — each probe isolated in its own try/catch so an unavailable variant contributes nothing instead of crashing teardown, then deduped. A target is accepted under any of them, and the comparison folds case on win32 only, where casing genuinely varies. The error message still prints the original-case path. On this machine three variants collapse to two: /var/folders/.../T and /private/var/folders/.../T. Both spellings of a real temp dir are accepted and removed. The Linux matrix passed every one of these broken states — 30544 and then 30545, both lanes green — because /tmp has neither symlink indirection nor short names. The platform CI shards are the only thing that has caught any of it, which is worth stating plainly given what this branch is about. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2843e25bf3 |
fix(#2988): local changeset/docs lint falls back to next, not main (#3095)
* fix(#2988): local changeset/docs lint falls back to next, not main Both scripts/changeset/lint.cjs and scripts/lint-docs-required.cjs resolved their diff base as GITHUB_BASE_REF || 'main'. GITHUB_BASE_REF is set only in GitHub Actions; locally it falls back to 'main' (the release branch), which lags far behind 'next' (the integration branch every PR targets). The oversized diff range swept in every changeset fragment merged since the last release, so the lint passed on the first fragment it saw regardless of whether the current PR authored it — structurally vacuous. Changed the fallback to 'next' (DEFAULT_BASE constant, exported from both scripts for parity). CI behavior unchanged (GITHUB_BASE_REF is set there). Added a parity test asserting both lints resolve the same base. * chore(#2988): backfill changeset PR number 3095 --------- Co-authored-by: sim <sim@local> |
||
|
|
164076b6ac |
test(#3090): compare both canonical forms of the temp root, not just the unresolved one
The guard added in the previous commit refused legitimate temp directories on macOS. os.tmpdir() returns a path under /var/folders/..., /var is a symlink to /private/var, and path.resolve() does not resolve symlinks. So a caller that passed the realpath'd form — via fs.realpathSync(), or via process.cwd() after chdir-ing into a temp dir, which returns the resolved path — produced /private/var/folders/... and failed a check written against /var/folders/... Measured on this machine before the fix: mkdtemp path /var/folders/.../probe-XXX allowed fs.realpathSync of the same dir /private/var/folders/.../probe-XXX REFUSED process.cwd() after chdir to it /private/var/folders/.../probe-XXX REFUSED The accepted-roots set is now built from both the resolved and realpath'd forms of os.tmpdir(), deduped — on Linux they are identical, so the set collapses to one entry and nothing changes there. realpathSync is wrapped because it throws if the root is momentarily missing, and a safety check must not become a new crash. The target itself is deliberately NOT realpath'd: cleanup() is called on already-deleted directories, where realpathSync raises ENOENT. The roots are computed per call rather than hoisted to module scope, because two test files override TMPDIR and a hoisted value would go stale for them. Worth naming: the remote matrix runs linux-node22 and linux-node24 only, and it passed 30544/30544 on the broken commit. os.tmpdir() is /tmp on Linux with no symlink indirection, so that matrix could not have caught this at any sample size. The regression test added here branches on whether realpath differs from the original path, so it exercises the real case on macOS and stays meaningful rather than vacuous on Linux. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
955655407c |
fix(#2979): document ExitError plain-text carve-out in json-errors.md (#3093)
* fix(#2979): document ExitError plain-text carve-out in json-errors.md The JSON-errors doc claimed every error emits a structured JSON envelope, but usage errors (ExitError) intentionally emit plain text with their own exit code (src/cli-exit.cts:36-39 catches ExitError before the envelope branch). Anyone following the doc's 'always parse stderr as JSON' guidance against a usage error got a parse failure. Amended the Wire format + Overview + Writing tests sections to scope the structured envelope to non-ExitError failures, stated the carve-out with a pointer to cli-exit.cts, and scoped the JSON-parse instruction to the envelope branch. Added a characterization test pinning both paths together (ExitError -> plain text + own code; non-ExitError -> JSON envelope) so the code cannot drift toward the doc's prior overstated claim. No runtime change — the test passes before and after the doc edit. Re-scoped per maintainer triage: the smart-entry --json part is already satisfied (shipped payload exposes the command token); only the doc correction + characterization test remain. * chore(#2979): backfill changeset PR number 3093 --------- Co-authored-by: sim <sim@local> |
||
|
|
fba501836b |
test(#3090): let the destructive call ask the safety question the function already answers
cleanup() computed `isTmpPath` — the exact predicate for "is this path safe to delete" — and consulted it only inside the catch block, to classify a transient Windows error. The rmSync above it ran unconditionally against whatever path it was handed, with `recursive: true, force: true`. The guard existed and the destructive call never asked it. The chdir at the top of the function makes the failure mode worse rather than better: a wrong target first moves the process out of the tree, then deletes it. So the predicate moves above both, and an out-of-tmpdir path is refused before either can run. The refusal throws and names the path; returning quietly would reproduce the fail-open shape this wave exists to remove. The catch keeps its `&& isTmpPath` term. It is now always true, but it states the condition the swallow depends on rather than inheriting it from a check twenty lines up, and it stays correct if the guard is ever relaxed. All 300+ call sites resolve under os.tmpdir() today, including the two files that override TMPDIR — both create their override root through the real os.tmpdir() first — so nothing legitimate is refused. The regression test targets tests/ itself: a real directory that must never be deleted, so nothing is created and nothing needs tearing down. It asserts the throw names the path, that cwd is unchanged (the chdir hazard), and that a known file inside still exists — proving the directory was not emptied rather than merely still present. Whether tests/ is outside os.tmpdir() is environment-dependent: os.tmpdir() is /tmp on Linux, so a checkout under /tmp would put it inside, and there a correct guard would delete this directory rather than refuse it. The test asserts that precondition before calling cleanup, so that environment fails loudly instead of destructively. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6128f73003 |
test(#3090): normalize the whole reason line, not just the category token
The allow-test-rule gate keys on identity, and the identity it records is everything after the colon on the annotation line — not the category token. Ten annotations carried the canonical category plus a trailing justification on the same line, so the recorded identity was a prose blob, and where the prose wrapped it was a sentence fragment: `source-text-is-the-product — the workflow .md content IS`. Seven of those ten are ones this branch already rewrote. That pass renamed the token and left the prose, which is the same error this wave exists to correct, one level down: the label was fixed without checking what the machine reads. Justifications move to the following comment line, which the scanner ignores because it lacks the token. No annotation gains or loses an issue reference, so no exemption changes compliance status; the allowlist goes 161 to 159 as two files' duplicate identities collapse. git-base-branch.test.cjs carried the token twice — once as the real annotation, once echoed in docblock prose that the line scanner parsed as a second exemption with a truncated identity. The echo is reworded to drop the literal token. intel.test.cjs:1360 was cut off mid-clause with an issue ref appended after the break; its sentence is restored and the ref kept on the annotation line so it stays compliant. Every remaining non-canonical identity is an ESLint RuleTester fixture inside a `code:` template literal, which the line scanner cannot tell apart from an annotation. Those two stay grandfathered. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2979f2a994 |
fix(#2978): add structural validation to roadmap validate (#3092)
* test(#2978): roadmap validate must perform structural validation Failing-first: roadmap validate returns {"warnings":[]} (exit 0) for every input — empty file, garbage, missing file, truncated frontmatter — because it performs no structural validation and its one opt-in check (W021 milestone- prefix) is off by default. Six cases: empty, garbage, missing, truncated frontmatter, well-formed (no false positive), BOM-prefixed (not corruption). * fix(#2978): add structural validation to roadmap validate roadmap validate returned {"warnings":[]} (exit 0) for every input — empty file, garbage, missing file, truncated frontmatter — because it performed no structural validation and its one opt-in check (W021 milestone-prefix) is off by default. A verb named validate that cannot produce a negative result provides false assurance. Add four structural checks, each producing a coded warning {code, message}: - V001: file missing/unreadable (was silent success) - V002: empty/whitespace-only - V003: malformed frontmatter (unterminated --- fence; BOM-tolerant per #3057) - V004: no recognizable phase entries (no ### Phase N: heading) Keep the existing W021 milestone-prefix check as-is. Exit non-zero via ExitError(1) when warnings are non-empty, per the documented contract ('exits non-zero on any error or warning'). Well-formed roadmaps (incl. BOM-prefixed, CRLF) still validate cleanly with warnings: [] and exit 0. * test(#2978): update W021 tests for non-zero exit on warnings Two existing W021 tests asserted roadmap validate exits 0 even with warnings ('roadmap validate should exit 0 even with warnings') — that was the bug. #2978 made validate exit non-zero on any warning (per its documented contract). Updated both mismatch-case tests to expect success===false and parse the JSON output from the failure path (stdout is written before the ExitError throw). * chore(#2978): add changeset fragment * chore(#2978): backfill changeset PR number 3092 --------- Co-authored-by: sim <sim@local> |
||
|
|
a4560c3669 |
test(#3090): read the body Status, not the frontmatter key that shadows it
stateExtractField tries bold **Field:**, then a plain ^Field: line, first match wins. STATE.md's frontmatter carries a `status:` key that appears before the body's plain `Status:` line, so extracting "Status" from unstripped content returns the frontmatter value and never the body prose the test was written against. Scoping the lookup to stripFrontmatter() fixes it. The tempting fix was to assert the frontmatter enum instead, on the reasoning that a typed value beats matching prose. That would have been wrong and would have weakened the test: normalizeStateStatus maps both "Phase complete — ready for verification" and "Verifying Phase N" onto the same 'verifying' enum, so the enum cannot tell phase-complete from mid-verification, which is precisely the distinction this case exists to prove. Typed is not automatically stronger when the type conflates the cases under test. Case 4 carried the same collision and was passing only because normalizeStateStatus falls through to the raw text when no known pattern matches, so frontmatter and body happened to agree. Fixed alongside it rather than left for the next person to trip over. All fourteen conversions on this branch were audited against the same failure mode. The collision can only arise where the frontmatter key and the body field name are identical case-insensitively — Status is the only such field, since every other frontmatter key is snake_case against a Title Case body label. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7dd9e59f6b |
test(#3090): stop exempting violations under categories that do not fit
An allow-test-rule annotation citing a category that does not apply is worse than no annotation, because it reads as reviewed. Eight were confirmed by reading the assertions each one covered, and auditing the rest found five more plus one refutation — a converter test whose wording described the wrong mechanism while the covered assertion genuinely was deployed-text. The instructive one used the CANONICAL string for the same mistake: STATE.md command output labelled as a deployed artifact. A canonical string is not evidence the category fits, which is why normalising strings alone would have laundered the problem rather than fixed it. Every mapping the audit had inferred rather than code-verified was spot-checked before rewriting, and the ones that turned out not to fit were re-annotated rather than relabelled. Fourteen STATE.md assertions had a typed extractor available all along and now use it; their annotations came out because nothing needs exempting. Eight assertions genuinely need a production change first — CLI stdout and stderr with no structured mode — and are tagged pending-migration-to-typed-ir citing #3090, which is what that category is for. It had zero real uses before this, while one file carried a real citation to migration issue #2974 under a non-canonical tag. Six annotations covered assertions that do no text matching at all. An exemption for a violation that does not exist is noise that makes the real ones harder to audit; those are removed. atomic-write-coverage gains the annotation it always warranted — its own docstring describes a structural-regression-guard while the file carried none. Fifty-nine non-canonical strings across roughly thirty files are normalised, and the allow-test-rule allowlist is regenerated to match. 472 annotations became 463: every one now uses a canonical category, and the two remaining non-canonical strings are ESLint RuleTester fixtures, not annotations. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b0f1722662 |
fix(#2969): ratchet completed_plans up for gap-closure plans under deriveProgressKeys (#3091)
* test(#2969): completed_plans must ratchet up for gap-closure plans Failing-first regression: when deriveProgressKeys=true (cmdStatePlannedPhase's opt-in), applyStatePreservation restores completed_plans to its pre-growth curated value, so gap-closure plans that complete never increment it — STATE.md shows completed_plans < total_plans forever even though every PLAN has a SUMMARY. Three cases: the ratchet-up (derived 54 > curated 50), the ratchet-down protection (derived 47 < curated 50 keeps curated), and the body-only write protection (no deriveProgressKeys → wholesale restore). The existing #2440 test covers the case where derived < curated (ratchet holds); this adds the missing case where derived > curated (ratchet must release upward). * fix(#2969): ratchet completed_plans/plases up under deriveProgressKeys applyStatePreservation's deriveProgressKeys path (cmdStatePlannedPhase's opt-in) let total_plans/total_phases take the derived value but restored completed_plans/completed_phases to their pre-growth curated value — so gap-closure plans that completed after the plan count grew never incremented them, leaving STATE.md at completed_plans < total_plans forever (every PLAN had a SUMMARY). Extend the deriveProgressKeys exclusion to also let completed_plans and completed_phases take the derived value, but ratcheted UP only (never derive downward past curated) — preserving the #3242 curated-progress protection for cases unrelated to plan-count growth (e.g. a deleted SUMMARY). percent takes the derived value (the resync already recomputed it from disk counts). Scoped to deriveProgressKeys (plan-phase only); body-only writes (state.update/patch without the flag) keep the full #3242 wholesale restore. * fix(#2969): also take derived percent under deriveProgressKeys Isolated-review blocker: percent fell into the else branch and was overwritten with the stale curated value, contradicting the inline comment and leaving STATE.md incoherent (e.g. completed_plans:54/total_plans:54 at percent:93). Skip percent in the ratchet loop so the derived (resync- recomputed) value survives. * chore(#2969): add changeset fragment * chore(#2969): backfill changeset PR number 3091 --------- Co-authored-by: sim <sim@local> |
||
|
|
9723d2b7e0 |
test(#3090): assert which value, not which type
Nine assertions drove a real fault through a real seam and then checked only that the call did not throw, or that a result was a string, a boolean, an array. Each passed whether the code was right or wrong. #3050 is the canonical instance of the shape: its counter-test asserted effectiveRoot was a string and never which root, so a silent misroute passed it. All nine now assert the exact verdict, derived from the production branch each one reaches and traced back to source rather than taken from the survey. One was worse than a weak assertion. The test targeting resolveWorktreeLinkage's main_worktree path used createTempGitProject, which always seeds .planning/ — so the reason was always has_local_planning and the git-dir comparison the test appears to exercise was unreachable from its own fixture. It was not asserting loosely, it was pointed at the wrong path. The fixture now builds a git project without .planning (projectDoc had to be disabled too, since it defaults to git and would have re-seeded it), and the test reaches the branch it names. Another had no reason assertion anywhere in the file while its four siblings all pinned theirs — the odd one out rather than a convention. The last is mine. The parity guard shipped in #3077 checked typeof and doesNotThrow across four ExecGitFn seams, and that PR described it as failing "the moment any site re-grows its own shape". It could not: a site returning a different value of the same type passed it. All four benign-passthrough outputs are derivable exact values, so it now asserts them and the claim is true. Test names that promised more than their assertions established are corrected to match what they prove. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
53ea8e0664 |
fix(#3057): make a guard's failure distinguishable from its benign result — Wave 1 (#3088)
* fix(#3057): refuse the write when the duplicate scan cannot complete writeManifest documents itself as a fail-closed duplicate guard: if any existing manifest shares plan_id with a different, non-terminal job_id it must refuse, because dispatching again would duplicate the external job. It could not honour that. The scan reads every sibling manifest looking for the duplicate, and an unreadable or unparseable sibling was `continue`d past. If the corrupt file was the one holding the live duplicate, the scan found nothing and a duplicate external job dispatched. The asymmetry is what gives it away: a malformed TARGET refused with malformed_existing because clobbering is unacceptable, while a malformed SIBLING was skipped — yet siblings are the only thing the duplicate check reads. Adds a scan_incomplete verdict that refuses and names the offending file, so an operator can quarantine or repair it. Fail-closed alone would let one stale corrupt manifest wedge every dispatch for that planning dir permanently; naming the file is what makes refusing survivable. malformed_existing is untouched, so the target/sibling distinction stays visible. The docstring is updated — it previously stated a rule the function did not keep. memFs() gains an optional failReads map so these branches are reachable at all; they had zero coverage because the fake could not express a per-file read fault. The signature is additive and every existing caller is unchanged. The regression is proved by a pair, not a single test. A control writes a readable sibling holding a genuine non-terminal duplicate and asserts duplicate_plan_id, establishing the scenario is real; the regression then makes that same path unreadable and asserts scan_incomplete. A first draft of this test used a corrupt-JSON fixture containing no plan_id at all while its comment claimed otherwise — it duplicated the unparseable-sibling case and proved nothing, which is the defect class this phase exists to remove. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3057): make a guard's failure distinguishable from its benign result Wave 1 of the negative-space backfill: the branches where a guard that could not verify something reported the same value it reports when everything is fine. That indistinguishability is the defect; every fix here makes the two states tellable apart, and every test proves it with a pair — one for the failure, one for the benign case. A single test cannot establish that two states are distinguishable, which is the whole property being fixed. state.cts phaseInventoryProvider returned null for both a real disk-scan failure and a genuinely empty phases dir, so `state rebuild` could report success while phase-table reconciliation never ran. It now returns a discriminated result and the CLI surfaces phase_inventory_scan_failed plus a reason. The reason field turned out never to have been wired into the emitted JSON at all — it existed only as an internal variable — so a test could only assert on the operator-facing note. It is a real field now. state.cts treated an unreadable lock body the same as an empty one, applying the 1-second stealable floor. A lock we cannot read is not a lock we know is stale; an unreadable body is now held to the deadman ceiling like a live holder. verification.cts findStaleVerificationSummary returned null on any fs, scan or clock failure — meaning "not stale". It now returns a discriminated StaleCheckResult and the caller records that the check was indeterminate. git-base-branch resolveBaseBranch returned 'main' both when no candidate branch existed and when every git tier timed out. A diagnostics variant now reports whether the answer was verified, and the CLI writes an unverified-fallback note to stderr. The stdout contract five workflows parse is untouched. worktree-safety snapshotWorktreeInventory left exists:true when statSync threw, so a guard that could not check reported the worktree present; exists is now tri-state and a stat failure surfaces as an 'unverified' finding. planWorktreePrune reported 'no_worktrees' for a parse failure, which is not the same as an empty list — and it drives a prune. It now reports 'parse_failed'. Fixing the inventory change exposed a second fail-open in verify.cts: the validate-health consumer silently dropped findings whose kind it did not recognise, so the new kind would have vanished. That is closed too — worth noting that the survey enumerated producers of degraded verdicts, not consumers that discard them. worktree-base-ref and state-transition gain the distinguishing signal without changing what they do: headAbsenceVerified, and a phase-inventory scan meta. Whether those guards should ACT differently is a product question this change does not answer, and both are flagged rather than quietly settled. rescueSummaryArtifacts is left alone: rescuing on an uncertain cat-file is deliberate per #2556. It now has tests proving it, and a recorded negative finding — git cat-file -e returns 128 for both "absent from HEAD" and a fatal error, so "uncertain" and "certain-and-fine" are not separable at the git level. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3057): assert typed values, not rendered text Ten assertions in the rebuild CLI suite matched substrings of produced output — STATE.md body fields, a markdown table row, an audit-log heading, and JSON keys read as text. CONTRIBUTING prohibits that: if the code under test produces text, the test asserts on its structured surface instead. No production surface had to be built. Every one already existed and was already compiled into bin/lib: stateExtractField for body fields, parseMarkdownTable for the phase table, collectSection for the audit-log section, and result.data.log — already a typed RebuildLogEntry[]. The tests were matching rendered text sitting next to the structured data. One of those assertions was passing for the wrong reason. `stdout.includes ('rebuilt')` matched the JSON KEY name, not a value: the dry-run path emits `mutated` and the real path emits `rebuilt`, so it would have passed whether the value was true or false. It now asserts the value. external-job's refusal already had to name the offending file — that naming is why the fail-closed variant is survivable rather than a permanent wedge — but the tests proved it by substring of a prose message. The failure result now carries offendingPath as its own field and the tests assert it by value. The human message is unchanged; operators read it. Array membership is left alone. `phaseIds.includes('99')` and `result.updated.includes('Completed Phases')` are membership checks on real arrays, not text matching, and converting them would weaken nothing and clarify nothing. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3057): execute acquireStateLock instead of grepping its source The non-EEXIST lock test asserted on the TEXT of the built .cjs and never called acquireStateLock. It carried an allow-test-rule: architectural-invariant exemption to permit that. A source grep proves a literal is present in a file, not that the behaviour works — it is weaker than a liveness test, which at least runs the code, and it was the only coverage the fatal-errno path had. Replaced with tests that inject the errno through fs and assert what actually happens: a fatal EACCES propagates out of acquireStateLock with zero backoff sleeps, while EAGAIN/EINTR/EINVAL/EIO/ENOENT/ESTALE/EPERM/EBUSY retry once and succeed. The exemption is removed and its allowlist entry with it. One old assertion is deliberately not carried over: it checked the retryable errnos were expressed as a Set rather than an inline literal. That is a shape check with no runtime signature; the behavioural tests fail if the code reverts to the old inline check, which is the regression it was really guarding. The #3057 lock-body tests move into that same file rather than a new one, which is what lint-test-file-count asks for and puts every acquireStateLock test in one place. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3057): surface an indeterminate staleness check to its callers An isolated review caught an inconsistency inside this wave. Two of the three "add the distinguishing signal" fixes wire through to something a user sees: git base-branch writes an unverified-fallback diagnostic to stderr, and an unverifiable worktree surfaces as a W020 finding. The third set staleCheckIndeterminate on readVerificationStatus's result and nothing read it. A signal nobody consumes leaves the fail-open exactly as silent as before: the staleness check could fail and the operator saw precisely what they would see if the answer were genuinely "not stale". That is the defect this issue exists to remove, so it is not defensible as scaffolding when its two siblings in the same change already wire through. All five callers now surface it, each through the channel it already had rather than a mechanism imposed uniformly: phase complete adds it to its existing warnings array and, on the blocked path, as an additive note on the error text; init and roadmap carry it as a field on output they already emit; the UAT report carries it without ever gating passed/blockers; workstream inventory takes an injectable writeDiagnostic mirroring the git base-branch idiom, because its return shape had nowhere to hang a per-phase field without rippling the builder's types. The routing decision is unchanged everywhere. What changes is only that a caller and an operator can now tell a failed check from a completed one. That diagnostic carries structured meta rather than being asserted by regex — the default still writes only the human message to stderr, but tests assert phaseDir and reason by value. Two earlier assertions in this branch were converted the same way; this was the last raw-text assertion left. Also records a scope correction: the completePhaseCore guards now compare stateReplaceField's result to the body instead of testing truthiness, so a field whose substitution produced identical text no longer reports as updated. That is a real behaviour fix, not the signal-only change this file was described as carrying, and its tests cover both the changed and unchanged cases. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3057): bound two heavy subprocesses for a loaded bench, not an idle one The remote matrix surfaced three failures unrelated to this branch's changes. All were bad tests, and a re-run would have hidden every one of them. The reviewer-flags parse block bounded bash -> node -> a full gsd-tools cold start at 5 seconds. On a bench running thirty thousand tests in parallel that is not a hang, it is a busy machine. Raised to 30s, matching the convention sibling suites already use for script invocations, with a comment saying what the budget covers so nobody tightens it back. Two further copies of the same 5-second spawn in the same file had the identical defect and are raised too — they were not in the failure report, but they will be next time. The fragment-propagation test bounded npm run regen:derived — a full build plus eight generators, the heaviest subprocess in the suite — at five minutes, and node22 was killed near the end. The captured output proves it: every generator had written its files and gen:install-tree had emitted all fifteen runtimes before the kill. Raised to fifteen minutes. That failure read as `null !== 0`, which says nothing. status null means killed, not a non-zero exit, and the two want different responses: one is a timeout to size correctly, the other is a real build break. The assertion now distinguishes them and names the signal. Neither test's assertions were weakened and no retry was added. A retry here would suppress exactly the signal the timeout exists to produce. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3057): capture fd 1 through the mock tracker, not a raw reassignment The phase suite reported zero test results on both lanes while running for five and a half minutes and exiting 1. No assertion text, no stderr, four events for the whole file: enqueue, start, dequeue, complete. That shape is not a failing assertion — it is the runner being unable to read the child at all, because it parses its event stream from the child's stdout. The cause was the capture helper reassigning fs.writeSync directly. Proven rather than assumed: a standalone probe patched fs.writeSync and called process.stdout.write, and the interception fired only when fd 1 resolved to a FILE, not when it was a pipe. The remote runner captures the event stream to a file, so a helper that was invisible against a pipe swallowed the reporter's own output on the bench. That is also why the two sibling suites wired the same way in this change pass cleanly — they use the mock tracker, the seam io.test.cjs established for this exact function. The helper now uses t.mock.method with an explicit restore after each call, so teardown belongs to node:test rather than a second hand-rolled implementation, and the interception cannot outlive the one synchronous call it wraps even if that call throws. Ten call sites thread the test context through; three test callbacks gained the parameter they lacked. The three B3 tests are untouched — same assertions, same fault injection. Only how the context reaches the helper changed. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3057): capture phase-complete output from a subprocess, not fd 1 Two attempts to make in-process fd-1 interception safe both failed on the bench. The suite reported zero test results on either lane while exiting 1 — four events for the whole file — because the runner parses its event stream from the child's stdout, and process.stdout.write routes through fs.writeSync whenever fd 1 resolves to a file, which is how the runner captures. Patching that seam anywhere in a file can therefore destroy the file's own reporting, and tightening the window only moved the runtime from 326s to 125s without recovering a single event. So the interception is gone rather than tuned. The helper now spawns gsd-tools as a real subprocess and reads stdout the way the OS already gives it to us, which is what the rest of the suite does. It asserts the command succeeded before parsing, so a genuine failure can no longer present as a JSON parse error. The two fault-injecting tests could not survive that move as written: a subprocess cannot see a mock installed in the parent. Instead of reinstating the interception they now produce the fault on disk — the summary artifact is created as a dangling symlink, so the staleness check's real statSync throws inside the child. That is a more honest fixture than a mock in any case, since it is a condition a user's tree can actually be in. Skipped on Windows, matching the existing symlink precedent in the write-guard suite. Three further call sites turned out to depend on parent-process writeFileSync mocks the subprocess could not see. Those call the CJS function directly, which is what they always wanted — they never needed stdout at all. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3057): one name for one signal, one encoding for one distinction Standards review found four things this branch introduced, all of them inconsistencies with itself rather than with the repo. One upstream bit reached its consumers under three names — verification_stale_check_indeterminate in two modules, the same value with "stale" dropped in a third, and stderr only in the fourth. Standardised on the long name wherever it is a field. The workstream inventory keeps its stderr channel, since its return shape has nowhere to hang a per-phase field without rippling the builder's types, but it now says the same word for the same thing. worktree-safety encoded one three-way distinction two ways in a single file: a named union for a finding's kind, and boolean|null for an inventory entry's existence. The second is now a named union too. Two assertions matched human prose because the blocked and non-blocked completion paths carried no typed field for the signal. Both now assert typed values. The first round of this fix added the field but left the regex beside it, which is the banned pattern sitting next to its own replacement; the second removed it and added an assertion on the reason enum so nothing was lost. The remaining two were reasoned away before being fixed, and both reasons were bad. "No typed surface exists" is the condition CONTRIBUTING says to fix by adding one — it took three lines. "The file already does this dozens of times" is not licence to add instance number thirty-one; a convention that violates a documented rule is debt, not precedent. Vocabulary differing across DIFFERENT modules is left alone: CONTEXT.md rejects a single shared result envelope, so per-module shapes are precedented, and a baseline smell does not outrank a documented standard. A census of every line this branch adds to a test file now finds no regex or substring assertion on produced prose: 87 strictEqual, 25 ok (all non-empty or shape guards), 12 equal, 3 throws (all typed err.code predicates), 3 deepStrictEqual, 2 notStrictEqual. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3057): backfill changeset pr number to 3088 --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
589a9b29b0 |
fix(#2962): enable nullglob in for-glob shell blocks for zsh portability (#3087)
* fix(#2962): enable nullglob in for-glob shell blocks for zsh portability Workflow shell blocks are fenced bash but execute in the user's login shell (zsh on macOS). zsh's nomatch default aborts the WHOLE block on an unmatched glob in a for-list (not just skipping the command), silently bypassing every statement after it — including the verify-phase decision-coverage gate, whose optional *-CONTEXT.md lookup used the unsafe for-list form so the DECISION_RESULT= assignment on the next line never ran under zsh. Fix: prepend a portable nullglob shim to every bash block containing a for-glob loop: shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null Each command no-ops (stderr suppressed) in the shell that doesn't recognize it; the matching shell enables nullglob so an unmatched glob expands to nothing and the loop body is skipped cleanly. Verified locally: both zsh and bash now reach end-of-block (rc=0) on a no-match glob; bash matched-case behavior unchanged. 14 blocks across 7 files: verify-phase.md (4, incl. the decision-coverage gate), review.md, execute-phase.md, resume-project.md, complete-milestone.md, audit-milestone.md, gsd-integration-checker.md, gsd-plan-checker.md (3). Closes the zsh bypass of the #2770 fix. * chore(#2962): add changeset fragment * chore(#2962): backfill changeset PR number 3087 --------- Co-authored-by: sim <sim@local> |