fix(#3691): let every reviewer lane take a prompt cap, and make the documented global resolve (#3832)

* test(#3691): failing-first coverage for the reviewer prompt budget

No prompt cap can reach any CLI reviewer lane, by any configuration. Two
independent defects compound: all nine `transport: spawn` lanes declare
`promptBudgetKey: null`, so `budgetFor` returns on its first line; and the
documented global `review.max_prompt_tokens` is advertised in the schema
manifest but declared nowhere, so the resolver never materializes it and
`budgetFor`'s fallback is dead code.

Adds to tests/reviewer-config-federation.test.cjs, which already owns the
per-reviewer budget config-set/config-get idiom:

- a CLI lane inherits the global cap (RED: reports null)
- an http lane with the -1 sentinel inherits the global cap (RED: reports null)
- the resolved review surface carries max_prompt_tokens at all (RED: absent)
- per-lane overrides the global on a CLI lane
- the sentinel boundary: -1 inherits, 0 means do-not-trim and must NOT read as
  unset, 1 is the smallest real budget — the regression budgetFor's own comment
  warns about
- anti-tightening pins that must stay green: an empty config leaves every lane
  null, the three existing budgeted lanes are unchanged, and config-set still
  rejects a per-reviewer key naming something that is not a declared lane
- a fast-check property over the resolution contract itself, with -1, 0 and
  non-finite inputs generated explicitly rather than left to chance

Every row was reproduced by hand against the real CLI before being written, so
the RED/GREEN split is observed rather than predicted.

Refs #3691

* fix(#3691): let every reviewer lane take a prompt cap, and make the global resolve

No prompt cap could reach any CLI reviewer lane, by any configuration. Two
independent defects compounded.

The nine spawn-transport lanes — claude, coderabbit, antigravity, cursor,
gemini, codex, kimi-code, opencode, qwen — declared `promptBudgetKey: null`, so
`budgetFor` returned on its first line and `review-lane plan` reported
`promptBudget: null` no matter what was configured. Each now declares
`review.max_prompt_tokens_per_reviewer.<slug>` with the same `-1`-is-unset
sentinel the three local-server lanes already use.

Separately, the central `review.max_prompt_tokens` was listed in the schema
manifest's validKeys and documented as a supported setting, but declared
nowhere — the resolved surface is built from capability declarations plus the
defaults manifest, and neither carried it. `configGet` returned undefined and
`budgetFor`'s documented fallback was dead code. It is now declared with a
`null` default, exactly as docs/CONFIGURATION.md already specified, so the
default behavior is unchanged: nothing configured means nothing trims.

Two things the diagnosis had not predicted, found and fixed while implementing:

- `REVIEWER_LANES` in src/review-lane-descriptor.cts is a second, hardcoded
  registration site that `mergeReviewerLanes` prefers over the capability
  registry on a slug collision. Editing only the capability files left every
  CLI lane still null. Both sites now agree.
- The generated `gsd-core/bin/lib/capability-registry.cjs` was stale and masked
  the capability edits; regenerated with `npm run gen:capability-registry`
  rather than hand-edited.

docs/CONFIGURATION.md said "Only lanes that declare a budget key accept one —
today ollama, lm_studio and llama_cpp". That is false as of this change and is
corrected rather than left to rot.

The trim-versus-refuse question the issue raises is deliberately not taken up
here: the refusal path already exists for the case that matters — a reviewer
whose minimum set exceeds its budget is skipped rather than sent a misleading
prompt — and trimming above that floor is the documented, shipped design of the
feature. Changing it would alter behavior for the three lanes that already
work, which is not what the issue asks for.

Fixes #3691

* fix(#3691): document the new global and narrow an invariant this change obsoleted

The full suite surfaced two consequences of giving every CLI lane a budget key.

`review.max_prompt_tokens` entered CONFIG_DEFAULTS without a matching entry in
the planning-config reference, which config-field-docs guards. Documented,
including the sentinel semantics a reader needs: a per-lane value overrides the
global, `-1` means unset and inherits it, and `0` means "do not trim that lane"
and is not unset.

The #2797 federation guard asserted that "a lane with no model flag and no host
owns no config keys". That held only because budget keys existed solely on the
three local-server lanes, all of which have hosts. A lane can now legitimately
own a config key for a third reason, so qwen tripped it.

The assertion is narrowed rather than weakened: such a lane must still own no
model key and no host key, and may own at most its own
`review.max_prompt_tokens_per_reviewer.<slug>` — never another lane's. That is
strictly more specific in the dimensions that still matter. Proven to still
bite: hypothetically giving qwen a `review.models.qwen` key fails it with
`model/host: review.models.qwen`. The name and comment cite #3691 for why the
premise changed, so a reader sees a deliberate narrowing, not erosion.

Checked the sibling assertions in that describe block; the other three do not
rest on the obsolete premise and are untouched.

Refs #3691

* fix(#3685): port the write-flag content-change contract to its three sibling sites

#3685 fixed `phase complete`'s `roadmap_updated` / `state_updated`, which
reported `fs.existsSync(path)` rather than whether the transaction wrote
anything. Three sibling sites carried the identical defect and are ported here.

- `cmdPhaseRemove` reported `roadmap_updated: true`, hardcoded.
  `updateRoadmapAfterPhaseRemoval` now returns whether the content changed and
  the flag reports it. #2640/#2974 already fixed `state_updated` at this same
  call site and left this one behind, so the correct shape was adjacent.
- `cmdMilestoneComplete` reported `state_updated: fs.existsSync(statePath)` —
  byte-identical to #3685's bug in a different command.
- `cmdMilestoneComplete` reported `milestones_updated: true`, hardcoded, never
  consulting the MILESTONES.md write.

`gsd-core/workflows/remove-phase.md:100` extracts `roadmap_updated` for display
and never branches on it, so the flip from always-true to content-based changes
no workflow behavior. Verified by reading the step, not assumed.

One trap found while implementing: the obvious in-memory
`finalContent !== originalStateContent` comparison — copying `cmdPhaseComplete`'s
shipped shape verbatim — gives a FALSE POSITIVE for milestone completion.
`platformWriteSync` normalizes Markdown at write time, and the milestone-closure
transform regenerates `## Current Position` fresh on every call, so its
pre-normalize output always differs from the already-normalized file on disk
even when the persisted bytes are identical. The comparison is therefore made
against the post-write on-disk content. `cmdPhaseComplete`'s own comparisons are
left untouched — their repeat-no-op tests pass, so they are not exposed to this
artifact.

`milestones_updated` has no reachable no-op: the MILESTONES.md write
unconditionally appends an entry every call. Only the true direction is pinned,
documented inline rather than faked with a passing test.

Refs #3685

* fix(#3685): compare write-flag content through the writer's own normalizer

An independent reviewer disproved a claim made while porting #3685's contract
to its sibling sites: that `cmdPhaseComplete`'s comparisons were not exposed to
the Markdown-normalization artifact already diagnosed in `cmdMilestoneComplete`.

`platformWriteSync` normalizes on write — CRLF stripped, blank-line runs
collapsed, a blank line inserted after a heading, a single trailing newline
enforced. Every flag that compares the PRE-normalization in-memory string
against the on-disk pre-image can therefore report a change when the persisted
bytes are identical. `cmdMilestoneComplete` had been worked around by re-reading
the file after the write; the other sites compared raw strings.

All of them now go through one exported seam,
`contentChangedAfterNormalize(filePath, before, after)`, which normalizes both
sides exactly as the writer does. That removes the extra disk read the milestone
workaround needed, and makes the sites agree by construction rather than by
four independent implementations of one rule — the divergence the repo names as
an anti-pattern.

Reachability, stated precisely rather than uniformly: the seam is load-bearing
at `cmdPhaseComplete`'s `roadmapUpdated`, `requirementsUpdated` and
`stateUpdated`, where section-rewrite logic genuinely regenerates content into a
different-but-normalization-equivalent shape. At
`updateRoadmapAfterPhaseRemoval` it is defense-in-depth: the no-match branch
never reassigns `content`, so the raw comparison was already correct there. The
first analysis claimed the reverse; this is the corrected finding.

Also fixes an unsound test premise the remote suite caught. The byte-identity
precondition in `roadmap_updated is false when ROADMAP.md comes out
byte-identical` asserted against a hand-authored, un-normalized fixture — so the
very first write reformatted it and the file could not come back identical. The
fixture is now written already-normalized, so the assertion compares a
normalized pre-image against a normalized post-image and still fails if the flag
regresses to a hardcoded `true`. Not platform-specific; it reproduces on macOS
too, and the earlier local check simply never exercised it.

The sibling true-direction and milestone tests were checked for the same premise
and do not share it — they assert `notEqual`, or compare two post-write states
produced through the same normalizing seam.

Refs #3685

* chore(changeset): backfill PR number for #3691 fragment

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-24 19:39:51 -04:00
committed by GitHub
parent c933184b97
commit aaf47c5fc2
23 changed files with 792 additions and 54 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3832
---
**Every reviewer lane can now be given a prompt-token cap, and the documented global `review.max_prompt_tokens` finally works** — the nine CLI reviewer lanes declared no budget key, so no cap could reach them by any configuration, and the central global was advertised in the config schema but declared nowhere, so setting it changed nothing. Each CLI lane now accepts `review.max_prompt_tokens_per_reviewer.<slug>` on the same terms as the local-server lanes, and the global resolves as the documented fallback. Defaults are unchanged: with nothing configured, no lane trims. (#3691)

View File

@@ -133,7 +133,7 @@
"reviewsSection": "Antigravity",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.antigravity",
"modelConfigKey": "review.models.agy",
"handler": "antigravity"
},
@@ -142,6 +142,11 @@
"type": "string",
"default": "",
"description": "Model passed to the Antigravity reviewer lane. The key suffix is the lane binary/flag alias `agy`, not the slug `antigravity` — preserved verbatim so existing .planning/config.json files keep working."
},
"review.max_prompt_tokens_per_reviewer.antigravity": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Antigravity reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\". Keyed on the reviewer slug `antigravity`, not the `agy` binary alias used by review.models.agy."
}
}
}

View File

@@ -148,7 +148,7 @@
"reviewsSection": "Claude",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.claude",
"modelConfigKey": "review.models.claude",
"handler": null
},
@@ -157,6 +157,11 @@
"type": "string",
"default": "",
"description": "Model passed to the Claude reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.claude": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Claude reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
}

View File

@@ -35,8 +35,15 @@
"reviewsSection": "CodeRabbit",
"evidenceClass": "diff-only",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.coderabbit",
"modelConfigKey": null,
"handler": null
},
"config": {
"review.max_prompt_tokens_per_reviewer.coderabbit": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the CodeRabbit reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
}

View File

@@ -142,7 +142,7 @@
"reviewsSection": "Codex",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.codex",
"modelConfigKey": "review.models.codex",
"handler": null
},
@@ -151,6 +151,11 @@
"type": "string",
"default": "",
"description": "Model passed to the Codex reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.codex": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Codex reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
}

View File

@@ -145,8 +145,15 @@
"reviewsSection": "Cursor",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.cursor",
"modelConfigKey": null,
"handler": null
},
"config": {
"review.max_prompt_tokens_per_reviewer.cursor": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Cursor reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
}

View File

@@ -36,7 +36,7 @@
"reviewsSection": "Gemini",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.gemini",
"modelConfigKey": "review.models.gemini",
"handler": null
},
@@ -45,6 +45,11 @@
"type": "string",
"default": "",
"description": "Model passed to the Gemini reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.gemini": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Gemini reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
}

View File

@@ -139,7 +139,7 @@
"reviewsSection": "Kimi Code",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.kimi-code",
"modelConfigKey": "review.models.kimi-code",
"handler": null
},
@@ -148,6 +148,11 @@
"type": "string",
"default": "",
"description": "Model passed to the Kimi Code reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.kimi-code": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Kimi Code reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
}

View File

@@ -162,7 +162,7 @@
"reviewsSection": "OpenCode",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.opencode",
"modelConfigKey": "review.models.opencode",
"handler": "opencode"
},
@@ -171,6 +171,11 @@
"type": "string",
"default": "",
"description": "Model passed to the OpenCode reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.opencode": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the OpenCode reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
}

View File

@@ -132,8 +132,15 @@
"reviewsSection": "Qwen",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.qwen",
"modelConfigKey": null,
"handler": null
},
"config": {
"review.max_prompt_tokens_per_reviewer.qwen": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Qwen Code reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
}

View File

@@ -1243,15 +1243,15 @@ Configure per-CLI model selection for `/gsd-review`. When set, overrides the CLI
| `review.models.llama_cpp` | string | (server default) | Model name passed to llama.cpp when `--llama-cpp` reviewer is invoked. If unset, the first model reported by `/v1/models` is used. |
| `review.default_reviewers` | string[] \| null | (all detected reviewers) | Default reviewer subset for no-flag `/gsd-review`. Example: `["gemini","codex"]`. May include configured `review.reviewer_instances` names. Explicit flags and `--all` override this setting. |
| `review.max_prompt_tokens` | number\|null | null | Default maximum estimated tokens for the assembled review prompt. When set, the prompt is deterministically trimmed before being sent to each reviewer. Per-reviewer overrides via `review.max_prompt_tokens_per_reviewer` take precedence. null = no trim (current behavior). |
| `review.max_prompt_tokens_per_reviewer` | object | {} | Per-reviewer token budget overrides. Keys are reviewer slugs. Only lanes that declare a budget key accept one — today `ollama`, `lm_studio` and `llama_cpp`, the local model servers this exists for. Values override `review.max_prompt_tokens` for that reviewer. A per-lane value of `0` disables trimming for that lane specifically. |
| `review.max_prompt_tokens_per_reviewer` | object | {} | Per-reviewer token budget overrides. Keys are reviewer slugs. Every declared reviewer lane accepts one (`gemini`, `claude`, `codex`, `coderabbit`, `opencode`, `qwen`, `cursor`, `antigravity`, `kimi-code`, `ollama`, `lm_studio`, `llama_cpp`). A lane's value of `-1` (the default) is unset and inherits `review.max_prompt_tokens`; `0` disables trimming for that lane specifically; any other number is that lane's own budget. |
| `review.parallel_lanes` | boolean | `false` | Dispatch independent reviewer lanes concurrently within a single `/gsd-review` pass. Default `false` keeps the sequential dispatch that protects against provider rate limits. Opt in only when your providers can accept concurrent requests. Convergence cycles stay sequential either way. |
| `review.ollama_host` | string | `http://localhost:11434` | Base URL of the Ollama server. Override when running Ollama on a non-default port or remote host: `gsd config-set review.ollama_host http://192.168.1.10:11434` |
| `review.lm_studio_host` | string | `http://localhost:1234` | Base URL of the LM Studio local server. Override when using a non-default port. |
| `review.llama_cpp_host` | string | `http://localhost:8080` | Base URL of the llama.cpp server (`llama-server`). Override when using a non-default port. |
### Prompt budgets for small-context reviewers
### Prompt budgets for reviewer lanes
Local model servers (Ollama, llama.cpp, LM Studio) typically accept far fewer tokens than cloud APIs. Setting `review.max_prompt_tokens_per_reviewer` (or the global `review.max_prompt_tokens` fallback) triggers deterministic prompt trimming before the prompt is sent to that reviewer: CONTEXT is dropped first, then RESEARCH, then REQUIREMENTS; PROJECT.md is head-shrunk to the first 40 lines; PLANs are tail-truncated proportionally — instructions and roadmap are always preserved. When a reviewer is trimmed, a disclosure note is injected at the top of the prompt and trim metadata (budget, omitted sections, truncation percentage) is recorded in the REVIEWS.md frontmatter under `trimmed_reviewers`. If even the minimum review set (instructions + roadmap + plan stubs) exceeds the budget, the reviewer is skipped with a warning rather than sending a truncated prompt that would produce misleading feedback.
Every declared reviewer lane can be capped, most usefully the local model servers (Ollama, llama.cpp, LM Studio), which typically accept far fewer tokens than cloud APIs — but any CLI lane can be given a budget too. Setting `review.max_prompt_tokens_per_reviewer` (or the global `review.max_prompt_tokens` fallback, which every lane whose own key is unset inherits) triggers deterministic prompt trimming before the prompt is sent to that reviewer: CONTEXT is dropped first, then RESEARCH, then REQUIREMENTS; PROJECT.md is head-shrunk to the first 40 lines; PLANs are tail-truncated proportionally — instructions and roadmap are always preserved. When a reviewer is trimmed, a disclosure note is injected at the top of the prompt and trim metadata (budget, omitted sections, truncation percentage) is recorded in the REVIEWS.md frontmatter under `trimmed_reviewers`. If even the minimum review set (instructions + roadmap + plan stubs) exceeds the budget, the reviewer is skipped with a warning rather than sending a truncated prompt that would produce misleading feedback.
### Example

View File

@@ -227,7 +227,7 @@ const capabilities = {
"reviewsSection": "Antigravity",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.antigravity",
"modelConfigKey": "review.models.agy",
"handler": "antigravity"
},
@@ -236,6 +236,11 @@ const capabilities = {
"type": "string",
"default": "",
"description": "Model passed to the Antigravity reviewer lane. The key suffix is the lane binary/flag alias `agy`, not the slug `antigravity` — preserved verbatim so existing .planning/config.json files keep working."
},
"review.max_prompt_tokens_per_reviewer.antigravity": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Antigravity reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\". Keyed on the reviewer slug `antigravity`, not the `agy` binary alias used by review.models.agy."
}
}
},
@@ -631,7 +636,7 @@ const capabilities = {
"reviewsSection": "Claude",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.claude",
"modelConfigKey": "review.models.claude",
"handler": null
},
@@ -640,6 +645,11 @@ const capabilities = {
"type": "string",
"default": "",
"description": "Model passed to the Claude reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.claude": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Claude reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
@@ -1038,9 +1048,16 @@ const capabilities = {
"reviewsSection": "CodeRabbit",
"evidenceClass": "diff-only",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.coderabbit",
"modelConfigKey": null,
"handler": null
},
"config": {
"review.max_prompt_tokens_per_reviewer.coderabbit": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the CodeRabbit reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
"codex": {
@@ -1187,7 +1204,7 @@ const capabilities = {
"reviewsSection": "Codex",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.codex",
"modelConfigKey": "review.models.codex",
"handler": null
},
@@ -1196,6 +1213,11 @@ const capabilities = {
"type": "string",
"default": "",
"description": "Model passed to the Codex reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.codex": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Codex reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
@@ -1445,9 +1467,16 @@ const capabilities = {
"reviewsSection": "Cursor",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.cursor",
"modelConfigKey": null,
"handler": null
},
"config": {
"review.max_prompt_tokens_per_reviewer.cursor": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Cursor reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
"drift": {
@@ -1690,7 +1719,7 @@ const capabilities = {
"reviewsSection": "Gemini",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.gemini",
"modelConfigKey": "review.models.gemini",
"handler": null
},
@@ -1699,6 +1728,11 @@ const capabilities = {
"type": "string",
"default": "",
"description": "Model passed to the Gemini reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.gemini": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Gemini reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
@@ -2278,7 +2312,7 @@ const capabilities = {
"reviewsSection": "Kimi Code",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.kimi-code",
"modelConfigKey": "review.models.kimi-code",
"handler": null
},
@@ -2287,6 +2321,11 @@ const capabilities = {
"type": "string",
"default": "",
"description": "Model passed to the Kimi Code reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.kimi-code": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Kimi Code reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
@@ -2905,7 +2944,7 @@ const capabilities = {
"reviewsSection": "OpenCode",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.opencode",
"modelConfigKey": "review.models.opencode",
"handler": "opencode"
},
@@ -2914,6 +2953,11 @@ const capabilities = {
"type": "string",
"default": "",
"description": "Model passed to the OpenCode reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.opencode": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the OpenCode reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
@@ -3251,9 +3295,16 @@ const capabilities = {
"reviewsSection": "Qwen",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.qwen",
"modelConfigKey": null,
"handler": null
},
"config": {
"review.max_prompt_tokens_per_reviewer.qwen": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Qwen Code reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
"refactor-trigger": {
@@ -4654,15 +4705,20 @@ const configKeys = {
"workflow.ai_integration_phase": "ai-integration",
"workflow.api_coverage_gate": "ai-integration",
"review.models.agy": "antigravity",
"review.max_prompt_tokens_per_reviewer.antigravity": "antigravity",
"workflow.assumption_delta": "assumption-delta",
"workflow.windows_enforce": "broken-windows",
"review.models.claude": "claude",
"review.max_prompt_tokens_per_reviewer.claude": "claude",
"claude_orchestration.enabled": "claude-orchestration",
"claude_orchestration.execution_backend": "claude-orchestration",
"claude_orchestration.min_agent_sdk_version": "claude-orchestration",
"workflow.code_review": "code-review",
"workflow.code_review_depth": "code-review",
"review.max_prompt_tokens_per_reviewer.coderabbit": "coderabbit",
"review.models.codex": "codex",
"review.max_prompt_tokens_per_reviewer.codex": "codex",
"review.max_prompt_tokens_per_reviewer.cursor": "cursor",
"workflow.drift_threshold": "drift",
"workflow.drift_action": "drift",
"workflow.schema_drift_gate": "drift",
@@ -4674,9 +4730,11 @@ const configKeys = {
"external_job.poll_timeout_ms": "external-job",
"workflow.post_planning_gaps": "gap-analysis",
"review.models.gemini": "gemini",
"review.max_prompt_tokens_per_reviewer.gemini": "gemini",
"graphify.enabled": "graphify",
"intel.enabled": "intel",
"review.models.kimi-code": "kimi-code",
"review.max_prompt_tokens_per_reviewer.kimi-code": "kimi-code",
"workflow.live_dom_uat": "live-dom-uat",
"review.models.llama_cpp": "llama-cpp",
"review.llama_cpp_host": "llama-cpp",
@@ -4699,8 +4757,10 @@ const configKeys = {
"review.ollama_host": "ollama",
"review.max_prompt_tokens_per_reviewer.ollama": "ollama",
"review.models.opencode": "opencode",
"review.max_prompt_tokens_per_reviewer.opencode": "opencode",
"workflow.pattern_mapper": "pattern-mapper",
"profile-pipeline.enabled": "profile-pipeline",
"review.max_prompt_tokens_per_reviewer.qwen": "qwen",
"refactor.trigger_enabled": "refactor-trigger",
"refactor.complexity_threshold": "refactor-trigger",
"refactor.complexity_jump_delta": "refactor-trigger",
@@ -4735,6 +4795,12 @@ const configSchema = {
"default": "",
"description": "Model passed to the Antigravity reviewer lane. The key suffix is the lane binary/flag alias `agy`, not the slug `antigravity` — preserved verbatim so existing .planning/config.json files keep working."
},
"review.max_prompt_tokens_per_reviewer.antigravity": {
"owner": "antigravity",
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Antigravity reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\". Keyed on the reviewer slug `antigravity`, not the `agy` binary alias used by review.models.agy."
},
"workflow.assumption_delta": {
"owner": "assumption-delta",
"type": "boolean",
@@ -4753,6 +4819,12 @@ const configSchema = {
"default": "",
"description": "Model passed to the Claude reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.claude": {
"owner": "claude",
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Claude reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
},
"claude_orchestration.enabled": {
"owner": "claude-orchestration",
"type": "boolean",
@@ -4793,12 +4865,30 @@ const configSchema = {
"deep"
]
},
"review.max_prompt_tokens_per_reviewer.coderabbit": {
"owner": "coderabbit",
"type": "number",
"default": -1,
"description": "Prompt-token budget for the CodeRabbit reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
},
"review.models.codex": {
"owner": "codex",
"type": "string",
"default": "",
"description": "Model passed to the Codex reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.codex": {
"owner": "codex",
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Codex reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
},
"review.max_prompt_tokens_per_reviewer.cursor": {
"owner": "cursor",
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Cursor reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
},
"workflow.drift_threshold": {
"owner": "drift",
"type": "number",
@@ -4872,6 +4962,12 @@ const configSchema = {
"default": "",
"description": "Model passed to the Gemini reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.gemini": {
"owner": "gemini",
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Gemini reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
},
"graphify.enabled": {
"owner": "graphify",
"type": "boolean",
@@ -4890,6 +4986,12 @@ const configSchema = {
"default": "",
"description": "Model passed to the Kimi Code reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.kimi-code": {
"owner": "kimi-code",
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Kimi Code reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
},
"workflow.live_dom_uat": {
"owner": "live-dom-uat",
"type": "boolean",
@@ -5027,6 +5129,12 @@ const configSchema = {
"default": "",
"description": "Model passed to the OpenCode reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.opencode": {
"owner": "opencode",
"type": "number",
"default": -1,
"description": "Prompt-token budget for the OpenCode reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
},
"workflow.pattern_mapper": {
"owner": "pattern-mapper",
"type": "boolean",
@@ -5039,6 +5147,12 @@ const configSchema = {
"default": false,
"description": "Enable the developer profiling pipeline commands (scan-sessions, extract-messages, profile-sample, write-profile, etc.)."
},
"review.max_prompt_tokens_per_reviewer.qwen": {
"owner": "qwen",
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Qwen Code reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
},
"refactor.trigger_enabled": {
"owner": "refactor-trigger",
"type": "boolean",
@@ -5262,7 +5376,7 @@ const runtimes = {
"reviewsSection": "Antigravity",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.antigravity",
"modelConfigKey": "review.models.agy",
"handler": "antigravity"
},
@@ -5271,6 +5385,11 @@ const runtimes = {
"type": "string",
"default": "",
"description": "Model passed to the Antigravity reviewer lane. The key suffix is the lane binary/flag alias `agy`, not the slug `antigravity` — preserved verbatim so existing .planning/config.json files keep working."
},
"review.max_prompt_tokens_per_reviewer.antigravity": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Antigravity reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\". Keyed on the reviewer slug `antigravity`, not the `agy` binary alias used by review.models.agy."
}
}
},
@@ -5537,7 +5656,7 @@ const runtimes = {
"reviewsSection": "Claude",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.claude",
"modelConfigKey": "review.models.claude",
"handler": null
},
@@ -5546,6 +5665,11 @@ const runtimes = {
"type": "string",
"default": "",
"description": "Model passed to the Claude reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.claude": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Claude reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
@@ -5902,7 +6026,7 @@ const runtimes = {
"reviewsSection": "Codex",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.codex",
"modelConfigKey": "review.models.codex",
"handler": null
},
@@ -5911,6 +6035,11 @@ const runtimes = {
"type": "string",
"default": "",
"description": "Model passed to the Codex reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.codex": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Codex reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
@@ -6160,9 +6289,16 @@ const runtimes = {
"reviewsSection": "Cursor",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.cursor",
"modelConfigKey": null,
"handler": null
},
"config": {
"review.max_prompt_tokens_per_reviewer.cursor": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Cursor reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
"hermes": {
@@ -6648,7 +6784,7 @@ const runtimes = {
"reviewsSection": "Kimi Code",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.kimi-code",
"modelConfigKey": "review.models.kimi-code",
"handler": null
},
@@ -6657,6 +6793,11 @@ const runtimes = {
"type": "string",
"default": "",
"description": "Model passed to the Kimi Code reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.kimi-code": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Kimi Code reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
@@ -6824,7 +6965,7 @@ const runtimes = {
"reviewsSection": "OpenCode",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.opencode",
"modelConfigKey": "review.models.opencode",
"handler": "opencode"
},
@@ -6833,6 +6974,11 @@ const runtimes = {
"type": "string",
"default": "",
"description": "Model passed to the OpenCode reviewer lane."
},
"review.max_prompt_tokens_per_reviewer.opencode": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the OpenCode reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
@@ -7039,9 +7185,16 @@ const runtimes = {
"reviewsSection": "Qwen",
"evidenceClass": "source-grounded",
"requiresBinaries": [],
"promptBudgetKey": null,
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.qwen",
"modelConfigKey": null,
"handler": null
},
"config": {
"review.max_prompt_tokens_per_reviewer.qwen": {
"type": "number",
"default": -1,
"description": "Prompt-token budget for the Qwen Code reviewer lane. Unset is -1, a sentinel: 0 is a legitimate value meaning \"do not trim this lane\", so it cannot double as \"not configured\"."
}
}
},
"trae": {

View File

@@ -99,6 +99,9 @@
"source_grounding": true,
"source_grounding_authority": "grep"
},
"review": {
"max_prompt_tokens": null
},
"capabilities": {
"strict_known_registries": null,
"auto_update": false

View File

@@ -244,6 +244,7 @@ Generated from `CONFIG_DEFAULTS` (configuration.cjs) and `VALID_CONFIG_KEYS` (co
| `resolve_model_ids` | boolean\|string | `false` | `false`, `true`, `"omit"` | Map model aliases to full Claude IDs; `"omit"` returns empty string |
| `context` | string\|null | `null` | `"dev"`, `"research"`, `"review"` | Execution context profile that adjusts agent behavior: `"dev"` for development tasks, `"research"` for investigation/exploration, `"review"` for code review workflows |
| `review.models.<cli>` | string\|null | `null` | Any model ID string | Per-CLI model override for /gsd:review (e.g., `review.models.gemini`). Falls back to CLI default when null. |
| `review.max_prompt_tokens` | number\|null | `null` | Any positive integer, or `null` | Central, cross-lane default cap (in estimated tokens) on the assembled review prompt; `null` means no trim. A per-lane `review.max_prompt_tokens_per_reviewer.<slug>` value overrides it for that lane: `-1` means unset (inherits this global default), `0` means "do not trim that lane" (not unset — it is an explicit, standing opt-out). _Alias:_ `max_prompt_tokens` is the flat-key form used in `CONFIG_DEFAULTS`; `review.max_prompt_tokens` is the canonical namespaced form. |
### Workflow Fields

View File

@@ -134,6 +134,7 @@ const CONFIG_DEFAULTS = {
security_block_on: _getNestedConfigDefault('workflow', 'security_block_on'),
post_planning_gaps: _getNestedConfigDefault('workflow', 'post_planning_gaps'),
smart_zone_tokens: _getNestedConfigDefault('workflow', 'smart_zone_tokens'),
max_prompt_tokens: _getNestedConfigDefault('review', 'max_prompt_tokens'),
};
/**
@@ -903,6 +904,13 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
claude_md_path: get('claude_md_path') || null,
claude_md_assembly: (parsed['claude_md_assembly']) || null,
phase_id_convention: get('phase_id_convention') ?? null,
// #3691: the documented central review key. Declared here (not federated —
// it is central, see config-schema.manifest.json validKeys) so the existing
// `review.*` per-lane keys the federated overlay below adds land as SIBLINGS
// on this same object rather than being clobbered by it.
review: {
max_prompt_tokens: get('max_prompt_tokens', { section: 'review', field: 'max_prompt_tokens' }) ?? defaults.max_prompt_tokens,
},
};
// ADR-857 phase 3b: federated config overlay

View File

@@ -14,7 +14,7 @@ import planningWorkspace = require('./planning-workspace.cjs');
import frontmatterMod = require('./frontmatter.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
import stateMod = require('./state.cjs');
import { platformWriteSync, platformReadSync, platformEnsureDir, execGit, retryRenameSync } from './shell-command-projection.cjs';
import { platformWriteSync, platformReadSync, platformEnsureDir, execGit, retryRenameSync, contentChangedAfterNormalize } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { realClock } from './clock.cjs';
import { transitionCore } from './state-transition.cjs';
@@ -925,6 +925,13 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
const accomplishmentsList = accomplishments.map((a) => `- ${a}`).join('\n');
const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
// #3685: mirror requirementsUpdated's diff-tracking contract — the result
// below used to report `milestones_updated: true` hardcoded, never
// consulting whether the MILESTONES.md write actually changed anything.
// Captured before the write branches below so the after-comparison reports
// a real content diff instead of an assumed one.
const milestonesBefore = fs.existsSync(milestonesPath) ? fs.readFileSync(milestonesPath, 'utf-8') : null;
if (fs.existsSync(milestonesPath)) {
const existing = fs.readFileSync(milestonesPath, 'utf-8');
if (!existing.trim()) {
@@ -952,6 +959,11 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
}
// #3685: real content diff, not the hardcoded `true` this used to report —
// see the `milestonesBefore` capture above.
const milestonesAfter = fs.existsSync(milestonesPath) ? fs.readFileSync(milestonesPath, 'utf-8') : null;
const milestonesUpdated = milestonesAfter !== milestonesBefore;
// #2142 BLOCKER 2 (review): opt-in quick-task archival. This call MUST sit
// immediately adjacent to the STATE.md write block directly below it, with
// NO unguarded IO in between (unlike the ROADMAP/REQUIREMENTS/audit/
@@ -992,6 +1004,13 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
// taken). `resync: true` mirrors `cmdPhaseComplete`'s posture (progress
// recomputed from disk; only the preserve-when-unchanged deltas apply) —
// milestone completion is the same kind of lifecycle transition.
// #3685: mirror requirementsUpdated's diff-tracking contract — this used to
// report `state_updated: fs.existsSync(statePath)`, true even on a no-op
// transaction. Declared here beside `stateUpdated`'s sibling flags and
// defaulted to `false` so the "STATE.md absent" case keeps today's answer
// (existsSync also returns false there) reached via a real content
// comparison instead.
let stateUpdated = false;
if (fs.existsSync(statePath)) {
withStateLock(statePath, () => {
const originalStateContent = platformReadSync(statePath) || '';
@@ -1073,6 +1092,24 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
},
);
platformWriteSync(statePath, finalContent);
// #3685 / #3691: compare NORMALIZED bytes, not the pre-normalize
// `finalContent` string, against the pre-normalize `originalStateContent`
// read above. `platformWriteSync` runs Markdown normalization (blank-line
// insertion around headings/fences/lists) before persisting — the
// transition core (`transitionCore`'s `## Current Position` section
// reset) regenerates that section fresh on every call, including on a
// genuine no-op re-run, and its raw un-normalized output differs from
// the already-normalized on-disk original even though the write
// converges to byte-identical content. Comparing pre-normalize strings
// (mirroring cmdPhaseComplete's shape verbatim) was verified live to
// report `true` on three consecutive byte-identical writes.
// `contentChangedAfterNormalize` runs BOTH sides through the exact same
// normalizer `platformWriteSync` used to persist (no extra disk I/O,
// and immune by construction to this ordering artifact) — this used to
// re-read the file to get the same answer; #3691 hoisted that seam so
// this site, `updateRoadmapAfterPhaseRemoval`, and `cmdPhaseComplete`'s
// roadmap/state/requirements flags all agree by construction.
stateUpdated = contentChangedAfterNormalize(statePath, originalStateContent, finalContent);
for (const field of divergedFields) {
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
}
@@ -1151,8 +1188,11 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
phases_archive_skip_reason: phasesArchiveSkipReason,
quick: !!quickArchiveResult && quickArchiveResult.archived > 0,
},
milestones_updated: true,
state_updated: fs.existsSync(statePath),
// #3685: mirror requirementsUpdated's diff-tracking contract — both flags
// now report a real before/after content diff instead of the previous
// hardcoded `true` (milestones_updated) / bare fs.existsSync (state_updated).
milestones_updated: milestonesUpdated,
state_updated: stateUpdated,
preservation_warnings: preservationWarnings,
};

View File

@@ -64,7 +64,7 @@ import planningWorkspace = require('./planning-workspace.cjs');
import frontmatterMod = require('./frontmatter.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
import stateMod = require('./state.cjs');
import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync } from './shell-command-projection.cjs';
import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync, contentChangedAfterNormalize } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { realClock } from './clock.cjs';
import { transitionCore } from './state-transition.cjs';
@@ -1705,15 +1705,23 @@ function findDataRowLine(sectionText: string, dataRowIndex: number): string | nu
return null;
}
// #3685: mirror requirementsUpdated's diff-tracking contract — the caller
// (cmdPhaseRemove) used to report `roadmap_updated: true` unconditionally,
// hardcoded regardless of whether this transform actually changed
// ROADMAP.md's content. Returning a real before/after comparison here lets
// the caller report accurately, the same fix #3685 applied to
// `cmdPhaseComplete` and #2640/#2974 already applied to this same function's
// sibling `stateUpdated` flag a few lines below in `cmdPhaseRemove`.
function updateRoadmapAfterPhaseRemoval(
roadmapPath: string,
targetPhase: string,
isDecimal: boolean,
removedInt: number,
cwd: string,
): void {
withPlanningLock(cwd, () => {
let content = fs.readFileSync(roadmapPath, 'utf-8');
): boolean {
return withPlanningLock(cwd, () => {
const originalContent = fs.readFileSync(roadmapPath, 'utf-8');
let content = originalContent;
const escaped = escapeRegex(targetPhase);
// #3572: ROADMAP headings and rows carry the normalized (zero-padded) form
// of a decimal id — `phase insert 1` writes `### Phase 01.1:` while the
@@ -1911,6 +1919,14 @@ function updateRoadmapAfterPhaseRemoval(
}
platformWriteSync(roadmapPath, content);
// #3685 / #3691: compare NORMALIZED bytes (what platformWriteSync actually
// persists), not the raw pre-normalize `content` string, against the raw
// pre-mutation `originalContent` read above — a raw `!==` here reports a
// false `true` whenever this transform's regenerated output takes a
// different-but-equivalent shape than the already-normalized on-disk
// original (same normalization-order artifact #3685 fixed at
// cmdMilestoneComplete; see contentChangedAfterNormalize's own doc).
return contentChangedAfterNormalize(roadmapPath, originalContent, content);
});
}
@@ -2038,7 +2054,7 @@ function cmdPhaseRemove(
error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
}
updateRoadmapAfterPhaseRemoval(
const roadmapUpdated = updateRoadmapAfterPhaseRemoval(
roadmapPath,
targetPhase,
isDecimal,
@@ -2130,7 +2146,11 @@ function cmdPhaseRemove(
renamed_directories: renamedDirs,
renamed_files: renamedFiles,
renamed_file_collisions: renamedFileCollisions,
roadmap_updated: true,
// #3685: mirror requirementsUpdated's diff-tracking contract — true only
// when updateRoadmapAfterPhaseRemoval's content diff detected a real
// change, not hardcoded regardless of whether ROADMAP.md's content
// actually changed.
roadmap_updated: roadmapUpdated,
state_updated: stateUpdated,
},
raw,
@@ -2686,7 +2706,12 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
before: originalRoadmapContent,
after: roadmapContent,
});
roadmapUpdated = roadmapContent !== originalRoadmapContent;
// #3685 / #3691: normalize both sides before comparing — see
// contentChangedAfterNormalize's doc (shell-command-projection.cts).
// A raw `!==` here false-positives whenever this phase-complete
// roadmap mutation regenerates a section in a different-but-
// equivalent raw shape than the already-normalized on-disk original.
roadmapUpdated = contentChangedAfterNormalize(roadmapPath, originalRoadmapContent, roadmapContent);
const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
if (fs.existsSync(reqPath)) {
@@ -3003,7 +3028,11 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// diff-tracking pattern used for the ROADMAP write above. A phase
// whose citations match nothing (ghost REQ-IDs only) must report
// `false`, not a bare "the file was present" `true`.
requirementsUpdated = reqContent !== originalReqContent;
// #3685 / #3691: normalize both sides before comparing — same
// false-positive shape as the sibling roadmapUpdated/stateUpdated
// flags in this same transaction; all three must agree by
// construction (see contentChangedAfterNormalize's doc).
requirementsUpdated = contentChangedAfterNormalize(reqPath, originalReqContent, reqContent);
}
}
@@ -3260,7 +3289,12 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
}
writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
stateUpdated = stateContent !== originalStateContent;
// #3685 / #3691: normalize both sides before comparing (same
// transitionCore-regenerated-section artifact cmdMilestoneComplete
// hit — see contentChangedAfterNormalize's doc). Reported "not
// exposed" by a previous agent; the reviewer disproved that by
// inspection and this branch closes it.
stateUpdated = contentChangedAfterNormalize(statePath, originalStateContent, stateContent);
}
anyPlanningWrite = writePlanningFileSet(writes) > 0;

View File

@@ -253,7 +253,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
reviewsSection: 'Gemini',
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: null,
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.gemini',
modelConfigKey: 'review.models.gemini',
handler: null,
},
@@ -285,7 +285,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
reviewsSection: 'Claude',
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: null,
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.claude',
modelConfigKey: 'review.models.claude',
handler: null,
},
@@ -313,7 +313,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
reviewsSection: 'Codex',
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: null,
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.codex',
modelConfigKey: 'review.models.codex',
handler: null,
},
@@ -338,7 +338,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
reviewsSection: 'CodeRabbit',
evidenceClass: 'diff-only',
requiresBinaries: [],
promptBudgetKey: null,
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.coderabbit',
// Accepts no model flag at all (review.md:367) — not merely "none configured".
modelConfigKey: null,
handler: null,
@@ -365,7 +365,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
// Phase 5b: the handler reconstructs from the JSON stream with JSON.parse, so `jq` — absent on
// stock Windows/Git-Bash (#2589) — is no longer a prerequisite for this lane.
requiresBinaries: [],
promptBudgetKey: null,
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.opencode',
modelConfigKey: 'review.models.opencode',
// Phase 5b (#2799): was `null`. The review is REBUILT from assistant `text` parts; a plain
// stdout copy would write the raw JSON envelope as the review (#1936). See LaneHandler.
@@ -388,7 +388,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
reviewsSection: 'Qwen',
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: null,
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.qwen',
modelConfigKey: null,
handler: null,
},
@@ -413,7 +413,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
reviewsSection: 'Cursor',
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: null,
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.cursor',
modelConfigKey: null,
handler: null,
},
@@ -440,7 +440,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
evidenceClass: 'source-grounded',
// Phase 5b: the handler reads the transcript with JSON.parse per line, not `jq`.
requiresBinaries: [],
promptBudgetKey: null,
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.antigravity',
// NOT `review.models.antigravity` — the shipped key is `review.models.agy` (review.md:291) and
// Phase 4 federated it under that name. This lane is why the key is declared, not derived.
modelConfigKey: 'review.models.agy',
@@ -570,7 +570,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
reviewsSection: 'Kimi Code',
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: null,
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.kimi-code',
modelConfigKey: 'review.models.kimi-code',
handler: null,
},

View File

@@ -1174,6 +1174,24 @@ export function normalizeContent(filePath: string, content: string, opts: { enco
return { content: normalized, encoding };
}
/**
* True iff persisting `after` via `platformWriteSync` would land different
* on-disk bytes than `before` already has (or would have, normalized the
* same way). `platformWriteSync` runs Markdown normalization (CRLF strip,
* blank-line-run collapse, single trailing newline) before writing, so a
* caller comparing raw pre-normalize strings (`after !== before`) can report
* `true` even when the persisted bytes are byte-identical — e.g. a
* transform that regenerates a section fresh on every call, including a
* genuine no-op re-run, in a different-but-equivalent raw shape than the
* already-normalized on-disk original (#3685 / #3691). Any "did this write
* change the file?" flag MUST go through this seam (or an equivalent
* post-write re-read of the actual on-disk bytes) instead of a raw `!==` —
* do not simplify this back to a direct string comparison.
*/
export function contentChangedAfterNormalize(filePath: string, before: string, after: string): boolean {
return normalizeContent(filePath, after).content !== normalizeContent(filePath, before).content;
}
// Rename errnos that are transient on Windows: a concurrent reader (or an AV
// scanner / indexer) holding the target open makes renameSync fail briefly.
// Same idiom as capability-ledger.cts / capability-consent.cts.

View File

@@ -515,6 +515,66 @@ describe('milestone complete command', () => {
assert.strictEqual(output.plans, 0);
assert.strictEqual(output.tasks, 0);
});
// #3685: state_updated/milestones_updated were reported via
// fs.existsSync(statePath) and a hardcoded `true` respectively — neither
// consulted whether the file's content actually changed in the
// transaction. Both now mirror requirements_updated's diff-tracking
// contract. Clock is pinned (GSD_TEST_MODE + GSD_NOW_MS) because
// syncStateFrontmatter stamps a millisecond-resolution `last_updated:`
// field on every STATE.md write — an unpinned second run would genuinely
// differ by that timestamp alone, masking the no-op this test needs to
// observe.
describe('write-flag content-change contract (#3685)', () => {
const PINNED_CLOCK_ENV = { GSD_TEST_MODE: '1', GSD_NOW_MS: '1750000000000' };
test('state_updated is true and STATE.md content actually changes on a genuine completion', () => {
writeRoadmap(tmpDir, `# Roadmap v1.0\n`);
writeState(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const stateBefore = fs.readFileSync(statePath, 'utf-8');
const result = runGsdTools(['milestone', 'complete', 'v1.0', '--name', 'Test'], tmpDir, PINNED_CLOCK_ENV);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const stateAfter = fs.readFileSync(statePath, 'utf-8');
assert.notEqual(stateAfter, stateBefore, 'precondition: STATE.md content must actually change');
assert.strictEqual(output.state_updated, true, 'state_updated must be true for a genuine rewrite');
assert.strictEqual(output.milestones_updated, true, 'milestones_updated must be true for a genuine rewrite');
});
test('state_updated is false when a second identical completion rewrites nothing (#3685)', () => {
writeRoadmap(tmpDir, `# Roadmap v1.0\n`);
writeState(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const run1 = runGsdTools(['milestone', 'complete', 'v1.0', '--name', 'Test', '--force'], tmpDir, PINNED_CLOCK_ENV);
assert.ok(run1.success, `first milestone complete failed: ${run1.error}`);
const stateAfter1 = fs.readFileSync(statePath, 'utf-8');
// Second call: STATE.md is already in its "milestone complete" closure
// shape, so re-running the same closure transform against it is a
// genuine no-op for STATE.md content, even though MILESTONES.md still
// gains a new (duplicate-looking) entry each call — the two flags are
// independent and must not be conflated.
const run2 = runGsdTools(['milestone', 'complete', 'v1.0', '--name', 'Test', '--force'], tmpDir, PINNED_CLOCK_ENV);
assert.ok(run2.success, `second milestone complete failed: ${run2.error}`);
const stateAfter2 = fs.readFileSync(statePath, 'utf-8');
assert.equal(stateAfter2, stateAfter1, 'STATE.md must be byte-identical across the no-op second run');
const output2 = JSON.parse(run2.output);
assert.strictEqual(
output2.state_updated, false,
'fs.existsSync() reported true here, masking the no-op (#3685)',
);
// milestones_updated has no reachable no-op path: the MILESTONES.md
// write unconditionally inserts a new entry block every call, so its
// content always differs from the pre-call file — pinning the
// true-direction here instead of fabricating a no-op case.
assert.strictEqual(output2.milestones_updated, true, 'milestones_updated stays true — MILESTONES.md always gains a new entry');
});
});
});
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -3244,6 +3244,65 @@ Plans:
const out = JSON.parse(result.output);
assert.strictEqual(out.state_updated, false, 'state_updated must be false when no STATE.md exists');
});
// #3685: roadmap_updated used to be reported as a hardcoded `true` —
// #2640/#2974 already fixed this call site's sibling `state_updated` flag
// to reflect a real content diff (via readModifyWriteStateMd's returned
// boolean); roadmap_updated is now fixed the same way, via
// updateRoadmapAfterPhaseRemoval's own before/after content comparison.
test('roadmap_updated is false when ROADMAP.md comes out byte-identical (#3685)', () => {
// Target phase number appears nowhere in ROADMAP.md (no heading, no
// dependency reference, no progress-table row, and higher than every
// existing phase number so no renumbering fires) and has no directory —
// updateRoadmapAfterPhaseRemoval's section-delete/renumber/row-delete
// passes are all no-matches, so `content` never diverges from
// `originalContent`.
//
// The fixture is written in ALREADY-NORMALIZED form (a blank line after
// the `###` heading) so the byte-identity assertion below compares a
// normalized pre-image against a normalized post-image. A hand-authored
// fixture that skips that blank line is NOT in the shape
// platformWriteSync's own normalizer produces, so writing it back
// through the same normalizing write path gains the blank line even
// though no phase data changed — that's the writer's own formatting
// pass reformatting an un-normalized input, not a real content change,
// and asserting byte-identity against such a fixture is unsound.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 1: Foundation\n\n**Goal:** Setup\n\n## Progress\n\n| Phase | Status |\n|-------|--------|\n| 1 | Done |\n`,
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const roadmapBefore = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const result = runGsdTools('phase remove 5', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmapAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.equal(roadmapAfter, roadmapBefore, 'ROADMAP.md must be byte-identical when the removed phase is absent from it');
const out = JSON.parse(result.output);
assert.strictEqual(
out.roadmap_updated, false,
'roadmap_updated was hardcoded true here, masking the no-op (#3685)',
);
});
test('roadmap_updated is true and ROADMAP.md content actually changes on a genuine removal (#3685)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n### Phase 2: Auth\n**Goal:** Authentication\n\n## Progress\n\n| Phase | Status |\n|-------|--------|\n| 1 | Done |\n| 2 | Planned |\n`,
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
const roadmapBefore = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const result = runGsdTools('phase remove 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmapAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.notEqual(roadmapAfter, roadmapBefore, 'precondition: ROADMAP.md content must actually change');
const out = JSON.parse(result.output);
assert.strictEqual(out.roadmap_updated, true, 'roadmap_updated must be true for a genuine removal');
});
});
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -21,12 +21,15 @@ const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const fc = require('fast-check');
const { createTempProject, createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
const configSchema = require('../gsd-core/bin/lib/config-schema.cjs');
const capValidator = require('../gsd-core/bin/lib/capability-validator.cjs');
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const gen = require('../scripts/gen-capability-registry.cjs');
const configLoader = require('../gsd-core/bin/lib/config-loader.cjs');
const { REVIEWER_LANES } = require('../gsd-core/bin/lib/review-lane-descriptor.cjs');
/** Keys that moved to a lane capability, with the lane that must own each. */
const FEDERATED = {
@@ -98,12 +101,26 @@ describe('reviewer config federation — provenance actually moved (#2797)', ()
assert.equal(configSchema.isCentralConfigKey('review.max_prompt_tokens_per_reviewer'), true);
});
test('a lane with no model flag and no host owns no config keys', () => {
test('a lane with no model flag and no host owns no MODEL or HOST key (#3691 narrows #2797)', () => {
// Absent-safe (ADR-2782 D4): qwen, cursor and coderabbit take neither a model
// argument nor a host, so they declare nothing. That is not a coverage hole.
const owners = Object.values(registry.configSchema || {}).map((e) => e && e.owner);
for (const laneId of ['qwen', 'cursor', 'coderabbit']) {
assert.equal(owners.includes(laneId), false, `${laneId} must declare no config keys`);
// argument nor a host. Under #2797 that meant "declares nothing" — the only
// way a lane owned a config key was via a model flag or a host. #3691 gave
// every CLI lane a `review.max_prompt_tokens_per_reviewer.<slug>` key, a
// third legitimate reason to own a key, so qwen/cursor/coderabbit now
// legitimately own their own budget key. The part of the #2797 invariant
// that still holds — a lane must never own a MODEL or HOST key, or another
// lane's budget key, it has no use for — is what this asserts directly.
for (const [key, entry] of Object.entries(registry.configSchema || {})) {
const owner = entry && entry.owner;
if (!['qwen', 'cursor', 'coderabbit'].includes(owner)) continue;
assert.ok(
!key.startsWith('review.models.') && !key.endsWith('_host'),
`${owner} must not own a model or host key, but owns "${key}"`,
);
assert.equal(
key, `review.max_prompt_tokens_per_reviewer.${owner}`,
`${owner} must own no key other than its own budget key, but owns "${key}"`,
);
}
});
});
@@ -362,3 +379,226 @@ describe('exclusivity gate sees dynamic patterns (#2797)', () => {
);
});
});
// ────────────────────────────────────────────────────────────────────────
// #3691 — no prompt cap reaches any CLI reviewer lane.
//
// Two independent defects, per .gsd/bug/fix-3691-reviewer-prompt-budget/10-diagnosis.md:
// (1) every `transport: spawn` lane (claude, coderabbit, antigravity, cursor, gemini,
// codex, kimi-code, opencode, qwen) declares `promptBudgetKey: null`, so
// `budgetFor` (gsd-core/bin/gsd-tools.cjs) returns null for them unconditionally;
// (2) `review.max_prompt_tokens` is documented and in validKeys but
// `config-defaults.manifest.json` has no `review` section, so the resolved
// config surface never materializes the key at all — `budgetFor`'s global
// fallback is dead code.
//
// `budgetFor` is an unexported closure inside `routeReviewLane`
// (gsd-core/bin/gsd-tools.cjs:1373-1380, confirmed via `module.exports` at
// gsd-tools.cjs:4410 — it is not there), so there is no in-process seam to call
// directly; every row below drives the real `review-lane plan` CLI end-to-end,
// same idiom as the federation suite above.
// ────────────────────────────────────────────────────────────────────────
describe('reviewer prompt budget — #3691 (CLI lanes cannot receive a cap)', () => {
/** Write `.planning/config.json` for a temp project (overwrites any existing one). */
function writeReviewConfig(tmpDir, cfg) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify(cfg, null, 2));
}
/** Run `review-lane plan --selected <slugs>` and return the parsed plan array. */
function planLanes(tmpDir, slugs) {
const runDir = path.join(tmpDir, 'run');
const r = runGsdTools(
['review-lane', 'plan', '--selected', slugs.join(','), '--run-dir', runDir, '--repo-root', tmpDir],
tmpDir,
);
assert.equal(r.success, true, `review-lane plan failed: ${r.error || r.output}`);
return JSON.parse(r.output);
}
/** Run `review-lane plan` for exactly one slug and return its entry. */
function planLane(tmpDir, slug) {
const entry = planLanes(tmpDir, [slug]).find((e) => e.slug === slug);
assert.ok(entry, `no plan entry for slug "${slug}"`);
return entry;
}
test('row1 (regression): a CLI spawn lane with only the central global set still reports null today', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeReviewConfig(tmpDir, { review: { max_prompt_tokens: 50000 } });
assert.equal(
planLane(tmpDir, 'codex').promptBudget, 50000,
'codex must inherit the central global once it declares a promptBudgetKey',
);
});
test('row2 (regression): the -1 per-lane sentinel on an already-budgeted lane must inherit the global', (t) => {
// The exact repro from 10-diagnosis.md.
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeReviewConfig(tmpDir, {
review: { max_prompt_tokens: 50000, max_prompt_tokens_per_reviewer: { ollama: -1 } },
});
assert.equal(
planLane(tmpDir, 'ollama').promptBudget, 50000,
'ollama already declares a promptBudgetKey, so this fails purely on defect 2 (the dead global)',
);
});
test('row3 (regression): the resolved config surface must carry review.max_prompt_tokens', (t) => {
// Asserts on the resolver's own surface, not only on promptBudget — the two
// defects are independent, and fixing only the lane keys would leave THIS
// row red even after row1/row2 go green.
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeReviewConfig(tmpDir, {
review: { max_prompt_tokens: 50000, max_prompt_tokens_per_reviewer: { ollama: -1 } },
});
const resolved = configLoader.loadConfigResolved(tmpDir);
const reviewKeys = Object.keys(resolved.config.review || {});
assert.ok(
Object.prototype.hasOwnProperty.call(resolved.config.review || {}, 'max_prompt_tokens'),
`resolved review surface is missing max_prompt_tokens, got keys: ${JSON.stringify(reviewKeys)}`,
);
assert.equal(resolved.config.review.max_prompt_tokens, 50000);
});
test('row4 (happy path): a per-lane value overrides the global', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeReviewConfig(tmpDir, {
review: { max_prompt_tokens: 50000, max_prompt_tokens_per_reviewer: { codex: 12345 } },
});
assert.equal(planLane(tmpDir, 'codex').promptBudget, 12345);
});
test('row5 (boundary, limit-1): an explicit per-lane 0 means "do not trim", never the global', (t) => {
// The specific regression budgetFor's own comment warns about — 0 is a real
// value, not the unset sentinel, and must not be silently promoted to the
// global budget.
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeReviewConfig(tmpDir, {
review: { max_prompt_tokens: 50000, max_prompt_tokens_per_reviewer: { codex: 0 } },
});
assert.equal(planLane(tmpDir, 'codex').promptBudget, 0);
});
test('row6 (boundary, limit): the -1 sentinel inherits the global', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeReviewConfig(tmpDir, {
review: { max_prompt_tokens: 50000, max_prompt_tokens_per_reviewer: { codex: -1 } },
});
assert.equal(planLane(tmpDir, 'codex').promptBudget, 50000);
});
test('row7 (boundary, limit+1): a real one-token budget is not read as a sentinel', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeReviewConfig(tmpDir, {
review: { max_prompt_tokens: 50000, max_prompt_tokens_per_reviewer: { codex: 1 } },
});
assert.equal(planLane(tmpDir, 'codex').promptBudget, 1);
});
test('row8 (anti-tightening pin, green today and after): no config at all trims nothing, on every declared lane', (t) => {
// Guards against a "fix" that hard-codes a budget onto every lane: that
// would satisfy rows 1-7 while breaking every user who configured nothing.
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeReviewConfig(tmpDir, {});
const allSlugs = REVIEWER_LANES.map((l) => l.slug);
const plans = planLanes(tmpDir, allSlugs);
for (const slug of allSlugs) {
const entry = plans.find((e) => e.slug === slug);
assert.ok(entry, `no plan entry for slug "${slug}"`);
assert.equal(entry.promptBudget, null, `${slug}: the default resolved surface must not gain trimming`);
}
});
test('row9 (independence pin, green today and after): the three pre-existing http lanes are unaffected', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeReviewConfig(tmpDir, { review: { max_prompt_tokens_per_reviewer: { ollama: 777 } } });
const plans = planLanes(tmpDir, ['ollama', 'lm_studio', 'llama_cpp']);
assert.equal(plans.find((e) => e.slug === 'ollama').promptBudget, 777, 'ollama must keep resolving its own configured value');
assert.equal(plans.find((e) => e.slug === 'lm_studio').promptBudget, null, 'lm_studio must not shift with no config of its own');
assert.equal(plans.find((e) => e.slug === 'llama_cpp').promptBudget, null, 'llama_cpp must not shift with no config of its own');
});
test('row10 (negative space, green today and after): config-set still rejects an unknown per-lane slug', (t) => {
// #2841 made an unknown `review.max_prompt_tokens_per_reviewer.<x>` slug an
// error; extending the family to nine more lanes must not loosen that.
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const set = runGsdTools('config-set review.max_prompt_tokens_per_reviewer.not_a_lane 5', tmpDir);
assert.equal(set.success, false, 'an unknown lane slug must still be rejected, not silently accepted');
});
// ─── property: the budget-resolution contract ──────────────────────────
//
// `budgetFor`'s contract (gsd-core/bin/gsd-tools.cjs:1361-1380): the resolved
// budget is the per-lane value when it is a finite number other than -1;
// otherwise the global when finite; otherwise null.
//
// Driven through the REAL CLI (review-lane plan against a temp project), not
// a reimplementation of the formula — `budgetFor` is not exported (see the
// describe-block header), so this is the lowest reachable seam that still
// exercises production code rather than a copy of it.
//
// Generator note on non-finite numbers: JSON cannot encode a literal NaN or
// Infinity (`JSON.stringify(NaN) === 'null'`), so a real `.planning/config.json`
// can never carry a numeric NaN/Infinity in the first place — driving those
// exact values through this seam would not be testing anything reachable.
// The string forms below ('NaN', 'Infinity', 'not-a-number') ARE reachable
// (JSON strings survive the round trip) and exercise the identical
// `typeof v === 'number' && Number.isFinite(v)` guard: a string is rejected
// by `typeof` exactly as a real NaN would be rejected by `Number.isFinite`.
test('property: per-lane wins when finite and not -1, else the global when finite, else null', () => {
const perLaneArb = fc.oneof(
fc.integer({ min: -1000, max: 1000000 }),
fc.constantFrom(-1, 0),
fc.constantFrom('NaN', 'Infinity', 'not-a-number'),
fc.constant(undefined),
);
const globalArb = fc.oneof(
fc.integer({ min: 0, max: 1000000 }),
fc.constant(null),
fc.constantFrom('NaN', 'not-a-number'),
fc.constant(undefined),
);
fc.assert(
fc.property(perLaneArb, globalArb, (p, g) => {
const review = {};
if (g !== undefined) review.max_prompt_tokens = g;
if (p !== undefined) review.max_prompt_tokens_per_reviewer = { codex: p };
const tmpDir = createTempProject();
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ review }, null, 2));
const runDir = path.join(tmpDir, 'run');
const r = runGsdTools(
['review-lane', 'plan', '--selected', 'codex', '--run-dir', runDir, '--repo-root', tmpDir],
tmpDir,
);
if (!r.success) return false;
const entry = JSON.parse(r.output).find((e) => e.slug === 'codex');
if (!entry) return false;
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
const expected = isNum(p) && p !== -1 ? p : (isNum(g) ? g : null);
return entry.promptBudget === expected;
} finally {
cleanup(tmpDir);
}
}),
// Bounded low: each run spawns a real gsd-tools child process (plus its own
// nested `query resolve-execution` spawn), so this is deliberately far
// below the suite's usual 200-run property budget — see the file header
// note on why the CLI is nonetheless the right seam.
{ seed: 36910824, numRuns: 20 },
);
});
});

View File

@@ -30,6 +30,7 @@ const {
escapePosixDoubleQuoted,
escapeSingleQuotedShellLiteral,
retryRenameSync,
contentChangedAfterNormalize,
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs');
@@ -1291,6 +1292,71 @@ describe('normalizeContent', () => {
});
});
// ─── contentChangedAfterNormalize ─────────────────────────────────────────────
// #3685 / #3691: `platformWriteSync` runs Markdown normalization before
// persisting. `roadmap_updated`/`state_updated`/`requirements_updated`-style
// flags computed via a raw `after !== before` on the PRE-normalize strings
// false-positive whenever the two sides differ only in a way normalization
// erases (CRLF, blank-line-run collapse, trailing-newline count) — the exact
// artifact class the milestone.cts #3685 fix diagnosed. This seam is the
// single point every such flag must go through instead.
describe('contentChangedAfterNormalize (#3685 / #3691)', () => {
test('reports false when the only difference is a CRLF vs LF artifact', () => {
const before = 'line1\r\nline2\r\n';
const after = 'line1\nline2\n';
assert.strictEqual(
contentChangedAfterNormalize('STATE.md', before, after), false,
'CRLF-only difference must not report a content change',
);
});
test('reports false when the only difference is a collapsible blank-line run', () => {
const before = '# Title\n\nparagraph\n';
const after = '# Title\n\n\nparagraph\n'; // extra blank line — collapses under normalize
assert.strictEqual(
contentChangedAfterNormalize('ROADMAP.md', before, after), false,
'a blank-line-run artifact that normalizes away must not report a content change',
);
});
test('reports false when the only difference is trailing-newline count', () => {
const before = '# Title\n\nbody\n';
const after = '# Title\n\nbody\n\n\n';
assert.strictEqual(
contentChangedAfterNormalize('STATE.md', before, after), false,
'trailing-newline-count-only difference must not report a content change',
);
});
test('reports true for a genuine content change', () => {
const before = '# Title\n\nold body\n';
const after = '# Title\n\nnew body\n';
assert.strictEqual(
contentChangedAfterNormalize('ROADMAP.md', before, after), true,
'a real content change must still report true',
);
});
test('reports true for a genuine change even when disguised by normalize-equivalent formatting on both sides', () => {
const before = '# Title\r\n\r\n\r\nold body\r\n';
const after = '# Title\n\nnew body\n\n\n';
assert.strictEqual(
contentChangedAfterNormalize('ROADMAP.md', before, after), true,
'formatting noise on both sides must not mask a real semantic change',
);
});
test('non-.md files still normalize (CRLF strip + trailing newline) before comparing', () => {
const before = 'a: 1\r\nb: 2';
const after = 'a: 1\nb: 2\n';
assert.strictEqual(
contentChangedAfterNormalize('config.json', before, after), false,
'non-.md normalization (CRLF + trailing newline) must also suppress a false positive',
);
});
});
// ─── platformWriteSync ───────────────────────────────────────────────────────
describe('platformWriteSync', () => {