Repository navigation
Conversation
trek-e
left a comment
There was a problem hiding this comment.
Review of head 2281bd7127ab — changes requested
Summary. The fix is on the failing path: config-new-project now refuses unknown keys, out-of-enum values and unsupported flags through validators extracted from config-set, and --dry-run writes nothing (all confirmed by running the head CLI). What blocks approval: the repo's two mandatory test rules are not met for the new validator (no property test, no accept-side boundary rows), validation is skipped entirely when a config already exists, and "the same rules as config-set" is not true for a few inputs. Major 2, Minor 8, Nit 9.
Classification and gates. #5268 carries bug + confirmed-bug (RULESET.CONTRIB.CLASSIFY.fix=requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)). Fixes #5268, conventional title, Fix template present, not draft, no competing PR. Changeset .changeset/calm-heron-checks.md: type Fixed, pr: 5284, bold lead; docs/CLI-TOOLS.md documents the verb. No shipped workflow, agent or command changed (no compact-content baseline or Emitted-Drift-Ack-Growth requirement); no references/ or workflows/ file added.
Acceptance criteria, #5268 Agent Brief (verbatim)
- "Unknown key in choices: non-zero exit,
Unknown config keyerror, no config file created." — Met on the create path; not met whenconfig.jsonalready exists (Minor 1). - "
modeoutsideinteractive/yolo(and any other valueconfig-setrejects for that key): non-zero exit with the same message, no file created." — Met for the 18 table rows; Partial for the rest (Minor 2) and on the existing-config path. - "Unsupported flags are refused, or
--dry-runprints the config and writes nothing." — Met (both). - "The full set of keys the new-project workflow template emits … is still accepted and produces the same config as today." — Met (tests fill both shipped templates).
- "Existing-config idempotency (
created: false) and defaults from the user defaults file are unchanged." — Met for idempotency; see Minor 7 for the reordered defaults-file write.
Out of scope per the Brief: "config-setbehavior; validation of user-defaults file contents; changing the template keys."
Reproduction (built head CLI in a throwaway git init repo, no test runner)
| Command | Result |
|---|---|
config-new-project '{"mode":"nonsense"}' --dry-run (no config) |
Error: Invalid mode 'nonsense'. Valid values: interactive, yolo |
same, with .planning/config.json present (dry-run and real) |
exit 0, {"created": false, "reason": "already_exists"} |
config-set mode nonsense |
Error: Invalid mode 'nonsense'… |
config-set review.max_prompt_tokens_per_reviewer '{"a":1}' |
accepted (updated: true) |
config-new-project '{"review":{"max_prompt_tokens_per_reviewer":{"a":1}}}' --dry-run |
Unknown config key: "review.max_prompt_tokens_per_reviewer.a" |
config-new-project '{"ship":{"pr_body_sections":null}}' --dry-run |
refused (config-set accepts null and clears) |
config-new-project '{"mode":null}' --dry-run |
accepted |
config-new-project -1 |
Unknown flag for config-new-project: -1 |
Findings
Major 1 — no fast-check property for the new validator [verified] validateNewProjectChoices is a recursive walk over arbitrary nested JSON with dotted-key, empty-section and dynamic-family logic, and tests/config.test.cjs adds table rows only (RULESET.TESTS.property-based-testing, a MUST for parsers and contracts of this kind). The code-review Standards pass cites the rule as violated; the adversarial pass rated the same gap Minor, the delegated pass Major; both ratings are kept. Suggested property: for any nested object built from valid leaf paths, acceptance equals the fold of config-set verdicts on the leaves, and one injected unknown leaf is always refused with nothing written.
Major 2 — boundary rows assert only the refused side [verified] The parity rows (context_window 0, security_asvs_level 9, drift_threshold 0, hooks.context_warning_threshold 0) never include the accepted-at-limit and refused-just-outside pair (RULESET.TESTS.boundary-coverage: limit-1, limit, limit+1). E.g. hooks.context_warning_threshold 0/1/99/100/101 and security_asvs_level 1-3 against 4; the flag and positional counts (0, 1, 2 positionals, flag before and after) also lack the n-1/n/n+1 shape.
Minor 1 — invalid choices are not refused when config.json already exists [verified: table above] cmdConfigNewProject returns already_exists before parsing or validating, so a bad key or bad mode exits 0, and --dry-run gives no preview. The Brief's idempotency line and its "non-zero exit" lines both hold on different paths; the ordering is not settled in the Brief. Fix: validate before the existence check, or document and test the short-circuit.
Minor 2 — "same key and value rules as config-set" is overstated [verified: table above; adversarial sweep 1,875 key×value cases found 14 divergences, 11 of them the CLI-string coercion] Real rule divergences: an object value for review.max_prompt_tokens_per_reviewer is accepted by config-set and refused here (the walker treats it as a section); ship.pr_body_sections: null is accepted by config-set and refused here; mode: null is accepted here and written, where config-set <key> null clears. The PR text and docs/CLI-TOOLS.md say the rules are the same. Fix: align, or enumerate the intended exceptions in the docs, and pin the null behaviour with a test.
Minor 3 — the documented string-for-boolean break is not pinned [verified] {"workflow":{"research":"true"}} is refused here while the same text through config-set is accepted (JSON-typed values, no coercion). The PR lists it under Breaking changes, but no row locks it.
Minor 4 — the issue's coupled command-aliases entry is not addressed [verified diff: no file under that path changed; judgment on applicability] Issue #5268, "Coupled (must not miss)": "command-aliases entry". The diff touches only .changeset, docs/CLI-TOOLS.md, gsd-tools.cjs, src/config.cts, tests/config.test.cjs. If no alias applies (the alias tables list the state, verify, init, phase, phases, validate, roadmap and eval families, with none for config-*), say so in the PR.
Minor 5 — the template test is coupled to workflow prose [verified/judgment] tests/config.test.cjs extracts the payload with /config-new-project '(\{.*\})' from gsd-core/workflows/*.md and fills a hand-maintained TEMPLATE_PLACEHOLDERS list; a new placeholder spelling fails with a parse error, and the order-dependent true|false replacement can mis-fill. Assert after filling that no [/| placeholder text remains.
Minor 6 — --raw and the dry-run text output are untested [verified] --raw is in the closed flag set (gsd-core/bin/gsd-tools.cjs) but no test passes it, and would create (dry run) is not asserted. One --raw --dry-run row covers both.
Minor 7 — scope beyond the brief [judgment, Standards and Spec axes] RULESET.PR-SCOPE.one-concern-per-pr=split unrelated changes into separate PRs. Beyond the Brief the PR adds non-object-choices refusal, empty-key and dotted-key refusal (with a DYNAMIC_KEY_PATTERNS carve-out), silent deletion of empty sections ({"bogus":{}} is dropped, not refused), a second-positional refusal, and moves the depth→granularity write to the user defaults file until after validation with a new options.persistDefaultsMigration parameter. The Brief says "produces the same config as today" and "defaults from the user defaults file are unchanged". Whether this is one concern is the maintainer's call; the finding stays.
Minor 8 — validateNewProjectChoices mutates its input [judgment] It rewrites normalized values and deletes empty sections in place while named validate*; return a normalized copy or rename it.
Nit 1 [judgment] validateConfigValue (about 300 lines after the move) documents "Exits non-zero through error()" and also returns the normalized value — two contracts in one function.
Nit 2 [verified] The dry-run masking test only covers empty defaults; with {"brave_search":"SECRETVAL"} the preview prints ****TVAL (as config-set does) and nothing pins masking or the absence of the plaintext.
Nit 3 [verified] -1 is reported as "Unknown flag" rather than "expected a JSON object"; any --prefixed token is treated as a flag.
Nit 4 [judgment] {"workflow":"x"} (a string for a section) reports Unknown config key "workflow", which misdescribes the problem.
Nit 5 [judgment] The flag/positional parsing in routeConfigNewProject duplicates routeConfigSet's ("mirroring routeConfigSet"), and the secret-mask loop in the dry-run preview is a third copy.
Nit 6 [judgment] Names: val/parsedValue/value for one concept; pendingDefaultsMigration holds serialized JSON, not a migration; persistDefaultsMigration is the negation of dryRun.
Nit 7 [judgment] The '--raw' entry in CONFIG_NEW_PROJECT_KNOWN_FLAGS is documented as a guard against a splice moving; it is dead today.
Nit 8 [judgment] buildNewProjectConfig's docstring says it "does NOT write any files" while it now ends with a deferred platformWriteSync; return the pending write as data and let the caller persist it.
Nit 9 [verified] docs/CLI-TOOLS.md does not mention that the dry-run preview masks secrets, and the PR template ticks Linux only although the matrix covers Windows and macOS.
Considered, not findings: no exploitable issue (see the security row); the already-supplied changeset has no (#5268) trailing backlink, which is a convention and not required; model_overrides.<agent> and agent_skills.<type> object values are not walked (same as config-set; a __proto__ key inside one is written as an inert own key and the old code accepted the same input).
Passes run
| Pass | Result |
|---|---|
/diagnose |
run; CLI probes of the built head above |
/root-cause-analysis |
run; extracting shared validators addresses the cause (rules lived inline in cmdConfigSet), but the existence check still precedes validation (Minor 1) |
/qa-test-architect |
run; Majors 1 and 2, Minors 3, 5, 6 |
/skills-from-the-artificer (bugfix-review) |
run; block below |
/code-review |
run as two parallel sub-agents: Standards (Majors 1 and 2 as rule violations, Minor 7, Nits 5-8) and Spec (Minors 1, 4, 7, Nit 4) |
/security-review |
run: identification sub-agent found no issue above 80% confidence (prototype-pollution, masking, symlinked defaults file, argv all checked by running the CLI) |
| Isolated adversarial pass | run by a fresh agent with no prior context; found Minors 1, 2 and Nits 2, 3 independently |
Not run (Memtrace is unavailable): the Deterministic Review Pass (detect_changes, find_cross_module_issues, find_code_review_issues, find_yaml_rule_matches, review_github_pr), preflight_check, risk tiering (find_central_symbols, find_bridge_symbols), the recorded-decision check (recall_decision, governing_rules) and the co-change gate. None is reported clean. I did not execute any test (local node --test is not permitted); the Stryker rows are SKIPPED on this PR, so the 80% mutation threshold was not measured.
Laws that apply
Subject: PR #5284 makes config-new-project validate its choices with the key and value rules extracted from config-set, refuse unsupported flags and honor --dry-run.
- hyrums-law — fired: string-typed booleans, dotted keys, extra flags and extra positionals that were accepted now exit non-zero, under a
Fixedchangeset; andmode: null/ship.pr_body_sections: nullbehave differently fromconfig-set(src/config.cts). Action: Minors 2 and 3. - postels-law — fired: the edge is deliberately strict (the right direction), but the strictness differs between the two verbs for null, objects and coercion, so callers cannot rely on one contract. Action: Minor 2.
- zawinskis-law — fired: the change absorbs input-normalization concerns (silent empty-section deletion, defaults-file migration reordering) that the issue did not ask for. Action: Minor 7.
- goodharts-law — fired: a table of refused-side rows passes whatever the walker does on accepted limits, so green does not show parity. Action: Majors 1 and 2.
Considered and cleared: kerckhoffs-principle (no guard or secret mechanism changed; masking behavior unchanged), kernighans-law (the long function is a pure move).
CI. The matrix executed on this head: 32 SUCCESS, 5 SKIPPED (auto-merge, Stryker matrix, Stryker mutation score, test (inert CI), Publish emitted-baseline artifact), none failing or pending; the branch is BEHIND next by one commit (a rebase is a CI condition, not a finding). I am not approving: Majors 1 and 2 and the Minors need a push.
CodeGraph Evidence
Memtrace unavailable: monthly quota exhausted and the server disconnected, so get_impact, get_symbol_context, recall_decision and find_code_review_issues were not run, and the recorded-decision check was not evaluated.
codegraph_impact buildNewProjectConfig: 5 symbols (buildNewProjectConfig,cmdConfigNewProject,ensureConfigFile,cmdConfigEnsureSection,cmdConfigSetModelProfile); the signature change is an optional trailing argument and the three other callers see the later defaults-file write.codegraph_callers cmdConfigNewProject: dispatched fromrouteConfigNewProjectingsd-core/bin/gsd-tools.cjs; branch-new symbols (validateNewProjectChoices,validateConfigValue,assertKnownConfigKey) are absent from the main-checkout index, so the head CLI was executed instead.codegraph_node(file mode):cmdConfigSetregion on base, confirming the value rules lived inline there;codegraph_exploreon the alias tables, for Minor 4.- Finding: the blast radius is the config verbs only; the risk is contract drift between
config-new-projectandconfig-set, not a wide break.
Verdict
Changes requested. Blocker 0, Major 2, Minor 8, Nit 9.
… and return checked choices as a copy Review of open-gsd#5284 (Minor 8, Nits 1, 5, 6, 8): validateNewProjectChoices rewrote and deleted keys in the caller's object while named validate*, and buildNewProjectConfig took a persistDefaultsMigration option and held a serialized pending write while its docstring said it wrote nothing. planNewProjectConfig now builds the config and returns the user defaults rewrite (depth -> granularity) as data, writing nothing; cmdConfigNewProject persists it only on a real write. buildNewProjectConfig keeps its one other caller's contract (ensureConfigFile): plan, persist, return the config. validateNewProjectChoices returns the checked choices as a new object and leaves its input as given. validateConfigValue's docstring states its one contract: it returns the value to persist, or refuses. The route comment cited routeConfigSet as the model for its closed flag surface; routeConfigSet parses no flags. It now cites todo complete (open-gsd#4096). No behaviour change: tests/config.test.cjs 237/237. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…sting-config short-circuit Review of open-gsd#5284 (Minor 1): cmdConfigNewProject returned already_exists before it parsed or checked its choices, so an unknown key or an invalid mode exited 0 whenever .planning/config.json existed, and --dry-run gave no preview of the refusal. The Brief's "non-zero exit" and idempotency lines each held, on different paths. The choices are now parsed and checked first; the existence check follows. Valid choices on an existing config still report already_exists and leave it untouched (the existing dry-run row), so idempotency is unchanged for every input config-set would accept. Tests: an invalid mode and an unknown key, each with and without --dry-run, against a project that already has a config: refused, file byte-identical. Against the previous build all 4 fail (exit 0, already_exists). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…set <key> null clears it Review of open-gsd#5284 (Minor 2, the null cases): config-set treats a bare null as "clear" (open-gsd#2046) and skips the key's typed rules; config-new-project skipped the rules too but wrote a literal null, a value config-set can never store. So `mode: null` was accepted and written as null, and `ship.pr_body_sections: null` reached the builder's array check and was refused where config-set accepts and clears it. A null choice still has its key checked, then is left out of the choices, so the key takes the value an empty payload gives it. The test of a builder-stage refusal used that null as its vehicle; it now uses an invalid user-defaults value (defaults contents are not validated as choices), which is still refused before the defaults file is migrated. Tests: mode, ship.pr_body_sections and workflow.research given null are accepted, each config-set-accepted as a positive control, and produce the config a {} payload produces; a null under an unknown key is still refused. Against the previous build the three null rows fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
… name a section given a non-object Review of open-gsd#5284 (Minor 7, the empty-section part; Nit 4): an empty object was dropped silently whatever its name, so `{"bogus":{}}` succeeded where config-set refuses `bogus '{}'` as an unknown key; and a real section given a non-object (`{"workflow":"x"}`) was reported as `Unknown config key "workflow"`, which misdescribes it. An empty object has no leaves to carry its name through the key gate, so its name is checked directly: refused with config-set's own error unless it is a real section (a schema key lives under it, or it is a dynamic family's top level such as agent_skills), where it sets nothing as before. A real section given anything but an object is refused with an error that says it takes an object of settings. Tests: three empty objects under unknown names, at the top level, nested, and under a real section, each refused with config-set's identical error (config-set's refusal asserted as a positive control); real sections given {} produce the {} payload's config; a section given a string, boolean, number, null or array is refused as a section. Against the previous build 8 of the 9 fail; the real-section row pins behaviour that does not move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
Found while sweeping for other values config-new-project writes in a form config-set cannot (the class of open-gsd#5284's Minor 2): config-set stores project_code as the text typed and never number-coerces it (open-gsd#1581), but `{"project_code":7}` was written as the number 7, which every reader casts `as string`. validateConfigValue now refuses a non-string project_code. Through config-set the value is always the typed text, so the rule never fires there; it holds a JSON choice to the type config-set stores. Tests: 7 is refused and nothing written; "007" is written as "007". Fails against the previous build; the existing open-gsd#1581 config-set rows still pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…ew masking, and the template fill Review of open-gsd#5284 (Minors 3, 5, 6; Nit 2; Nit 9 and Minor 2's documentation): - Choices are JSON-typed and never coerced. A string where workflow.research or git.create_tag takes a boolean, or where context_window or workflow.security_asvs_level takes a number, is refused, while config-set accepts the same text (asserted as a positive control). - `--raw --dry-run` prints `would create (dry run)` and writes nothing. - A secret choice in the dry-run preview is masked to exactly what `config-set --dry-run` prints for it, and its plaintext appears nowhere. - The template test now walks the filled payload's strings for a leftover placeholder (`"[…]"` or a bare `a|b|c` alternation), so a new placeholder spelling fails by name instead of as a parse or enum error. Planted control: with the `"[selected]"` mapping removed, both template rows fail with that message. docs/CLI-TOOLS.md now states the two intended differences from config-set that follow from JSON input (no coercion; a setting with per-entry settings under it takes an object of those entries, each checked, where config-set stores any object whole) and that the preview masks secrets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…t, and the positional count Review of open-gsd#5284 (Major 2): the parity rows asserted only the refused side of each limit (context_window 0, security_asvs_level 9, drift_threshold 0, hooks.context_warning_threshold 0), so a walker that refused too much, or a rule whose accepted edge moved, would still pass. Each numeric rule is now exercised at limit-1 / limit / limit+1 on every end it has: hooks.context_warning_threshold -1, 0, 1, 99, 100, 101 (0 is in range but refused, open-gsd#4285); workflow.security_asvs_level 0-4; context_window and workflow.drift_threshold 0, 1, 2. Every row first asserts config-set's verdict for the same value, so the expected column is checked against the oracle; an accepted value must be written as given and a refused one must give config-set's identical error and write nothing. The positional count gains its zero case (no choices, and --dry-run alone, build the defaults-only config) beside the existing one- and two-positional rows and the flag-before/after pair. Mutant control: moving the ASVS upper bound in the built lib from 3 to 4 turns the `=4` row red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…at config-set accepts, leaf by leaf Review of open-gsd#5284 (Major 1, RULESET.TESTS.property-based-testing): validateNewProjectChoices is a recursive walk over arbitrary nested JSON, and its tests were table rows only. A property section at the end of tests/config.test.cjs (one file per module, lint-test-file-count) runs both verbs in-process (error() throws ExitError, ADR-3889) over generated payloads of one to five leaves drawn from fourteen keys, each with values its rule accepts, values it refuses, and null: (a) a payload is accepted exactly when config-set accepts every leaf; a refusal carries the error config-set gives for one of the leaves; an accepted leaf is written as config-set --dry-run reports it would write it (normalized values included); (b) one unknown leaf inserted anywhere refuses the payload, writes no .planning directory, and is the leaf named when the rest are valid. Each in-process call runs inside suppressFdAsync on fd 1 and fd 2, the helper that exists for this window (open-gsd#4448: io.output()'s fd-1 write races node:test's child-to-parent IPC), so the properties are async. The text handed to config-set is the text it parses back to the same JSON value; strings config-set would coerce are left out, since that difference is intended and pinned by name in the rows above. Seed 42 (shared setup): 200 runs of (a), 75 accepted and 125 refused, and the test asserts both verdicts occur so a one-sided generator cannot pass it vacuously. Negative control: against the build before the null fix in this series, (a) fails with ship.pr_body_sections: null, refused by the builder with an error config-set never gives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…checking before already_exists The fragment lands in the CHANGELOG verbatim, so it now names the user-visible changes from the open-gsd#5284 review round: a null choice leaves the setting at its default instead of writing null; values are JSON-typed and not coerced; and the choices are checked even when the config already exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
… config is written Found by the pre-push cross-AI review of the open-gsd#5284 round: cmdConfigNewProject persisted the user defaults rewrite (depth -> granularity) before creating .planning/ and writing config.json, so a creation that failed at either step left the defaults file migrated and no config. The reviewed head and the base did the same, from inside the builder; this is not a regression, but the round had already moved the write to the caller, so it now follows the config write it was planned for. Tests: a fault-injected config write (mockPartialWriteThenThrow on the config path, in-process so the fault lands on that write and nothing before it) is refused with "Failed to write config.json" and leaves the defaults file byte-identical; a successful creation still migrates it. Against the previous ordering the first row fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…follows the dash Review of open-gsd#5284 (Nit 3): `config-new-project -1` was refused as "Unknown flag for config-new-project: -1", because any token starting with `-` was read as a flag. `-1` is a JSON value; the error it deserves is the one any other non-object payload gets. A flag is now a dash (or two) followed by a letter. `-1` and a bare `-` are the choices argument and are held to the choices rules ("expected a JSON object", "Invalid JSON"); `-x` and `--force` are still refused as unknown flags. (todo complete, whose closed flag set this follows, scans only the tokens after its positional, so it never had to decide this.) Tests: `-1`, `-` and `-x`. Against the previous dispatcher the first two fail with "Unknown flag"; the third pins the unchanged refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…aviour as verified The pre-push cross-AI review of the open-gsd#5284 round, over two passes, refuted these sentences as stronger than the behaviour: - "a null choice leaves its key at the default, as config-set <key> null clears it": the two end in different effective values when a user default or a detected API key is involved. A null choice is now described as not chosen (it takes the value it would have had without the choice), and config-set's null as its existing-config counterpart, not an equal. - "a refused choice fails even when config.json already exists": true, but the set is wider than config-set's refusals (malformed JSON and other non-object payloads too), and narrower than "anything this command refuses": a problem in the user defaults file is not reported when the config already exists, because the defaults are read only to build one. - "an empty object sets nothing": where the key is itself a setting, {} is its value, held to that setting's rule as config-set holds it (written for model_overrides.<agent> and agent_tools, refused for mode). Where the key is not a setting, an empty object sets nothing under a named section and is refused as an unknown key otherwise, including below the first level of a dynamic key family, as config-set refuses it. The changeset's null sentence is reworded to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…r> keys Found by the fifth pass of the pre-push cross-AI review of the open-gsd#5284 round: `agent_tools` is both a valid setting and the top level of the `agent_tools.<selector>` dynamic key family, but the walker's "does a key live under this one" test looked only at static and capability keys. So `agent_tools` was taken as a whole value, and `{"agent_tools":{"$bad":[]}}` was written although config-set refuses `agent_tools.$bad` as an unknown key. hasChildConfigKeys now also answers yes for a dynamic family's top level, so an `agent_tools` object is walked and each selector is checked as its own key. `agent_tools` is the only key this moves: it is the one dynamic top level that is also a valid key with no static children. An empty `agent_tools: {}` now sets nothing, which the install-time reader treats exactly as an absent key (both merge the global grants unchanged). isConfigSection, which duplicated the dynamic-top-level test, now defers to hasChildConfigKeys. docs/CLI-TOOLS.md names agent_tools beside review.max_prompt_tokens_per_reviewer as a setting checked entry by entry. Tests: `{"agent_tools":{"$bad":[]}}` refused with config-set's identical error (config-set's refusal asserted first); valid selectors, `*` included, written as given; `agent_tools: {}` gives the {} payload's config. Against the previous build both rows fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…ols, and label the budget example Sixth pass of the pre-push cross-AI review of the open-gsd#5284 round: "a section given anything but an object is refused" is false for agent_tools, a key that is both a setting and a section; `"agent_tools":"x"` is held to the setting's own rule (it has none), as config-set holds it. And the `{"a":1}` refusal example applies to the reviewer budget, not to agent_tools, whose selector `a` is valid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
|
Thanks for the review. Every finding is addressed in 14 commits on top of the reviewed head Majors
Minors
Nits
Found in the sweep
Verification
|
trek-e
left a comment
There was a problem hiding this comment.
Review of head 9a68a06fb071 - substance clean, awaiting rebase
Summary. Every finding from the 2026-10-10 review is resolved or declined with a reason I verified on disk. Root cause (value rules inline in cmdConfigSet, flags dropped by the router, existence check before validation) is fixed at the source via the shared assertKnownConfigKey / validateConfigValue.
Prior findings. Major 1 (property): two fast-check properties, both outcomes asserted. Major 2 (boundaries): limit-1/limit/limit+1 rows present. Minors 1, 2, 3, 5, 6, 8 and Nits 1-4, 6, 8, 9: fixed and pinned. Minor 4: src/command-aliases.cts:754 already carries config-new-project; decline accepted. Nit 7: matches the todo complete convention (gsd-tools.cjs:3200); accepted.
Open for the maintainer. Minor 7 (scope: dotted-key, non-object, empty-section, second-positional and defaults-rewrite changes ride along); the author justifies each against the ACs and leaves the one-concern call to you. Nit 5 (third masking copy) left as is.
Nits. (1) Masking loop duplicated in the dry-run preview. (2) New test block is not exercised by the Windows legs (disclosed).
State. BEHIND next by 42 commits; merge-tree is clean. git fetch origin && git rebase origin/next, resolve nothing expected, push. No failing checks at this head; Stryker rows SKIPPED.
Memtrace Evidence
The Memtrace MCP server was unavailable for this review (it failed to connect: memtrace not found in PATH), so no graph-backed check ran. None of the four checks below is a clean result.
get_impact: not run. Blast radius comes from reading the diff and grepping callers.get_symbol_context: not run. Callers and callees of the changed symbols were checked by grep.recall_decision: not run. The recorded-decision gate did not execute, so this does not show that no decision governs the change. ADRs andCONTEXT.mdpredicates were checked by grep instead.find_code_review_issues: not run. The deterministic review pass did not run. A manual diff review stood in for it, so the findings above are judgment, not graph facts.
… and return checked choices as a copy Review of open-gsd#5284 (Minor 8, Nits 1, 5, 6, 8): validateNewProjectChoices rewrote and deleted keys in the caller's object while named validate*, and buildNewProjectConfig took a persistDefaultsMigration option and held a serialized pending write while its docstring said it wrote nothing. planNewProjectConfig now builds the config and returns the user defaults rewrite (depth -> granularity) as data, writing nothing; cmdConfigNewProject persists it only on a real write. buildNewProjectConfig keeps its one other caller's contract (ensureConfigFile): plan, persist, return the config. validateNewProjectChoices returns the checked choices as a new object and leaves its input as given. validateConfigValue's docstring states its one contract: it returns the value to persist, or refuses. The route comment cited routeConfigSet as the model for its closed flag surface; routeConfigSet parses no flags. It now cites todo complete (open-gsd#4096). No behaviour change: tests/config.test.cjs 237/237. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
9a68a06 to
104b4ac
Compare
…sting-config short-circuit Review of open-gsd#5284 (Minor 1): cmdConfigNewProject returned already_exists before it parsed or checked its choices, so an unknown key or an invalid mode exited 0 whenever .planning/config.json existed, and --dry-run gave no preview of the refusal. The Brief's "non-zero exit" and idempotency lines each held, on different paths. The choices are now parsed and checked first; the existence check follows. Valid choices on an existing config still report already_exists and leave it untouched (the existing dry-run row), so idempotency is unchanged for every input config-set would accept. Tests: an invalid mode and an unknown key, each with and without --dry-run, against a project that already has a config: refused, file byte-identical. Against the previous build all 4 fail (exit 0, already_exists). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…set <key> null clears it Review of open-gsd#5284 (Minor 2, the null cases): config-set treats a bare null as "clear" (open-gsd#2046) and skips the key's typed rules; config-new-project skipped the rules too but wrote a literal null, a value config-set can never store. So `mode: null` was accepted and written as null, and `ship.pr_body_sections: null` reached the builder's array check and was refused where config-set accepts and clears it. A null choice still has its key checked, then is left out of the choices, so the key takes the value an empty payload gives it. The test of a builder-stage refusal used that null as its vehicle; it now uses an invalid user-defaults value (defaults contents are not validated as choices), which is still refused before the defaults file is migrated. Tests: mode, ship.pr_body_sections and workflow.research given null are accepted, each config-set-accepted as a positive control, and produce the config a {} payload produces; a null under an unknown key is still refused. Against the previous build the three null rows fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
… name a section given a non-object Review of open-gsd#5284 (Minor 7, the empty-section part; Nit 4): an empty object was dropped silently whatever its name, so `{"bogus":{}}` succeeded where config-set refuses `bogus '{}'` as an unknown key; and a real section given a non-object (`{"workflow":"x"}`) was reported as `Unknown config key "workflow"`, which misdescribes it. An empty object has no leaves to carry its name through the key gate, so its name is checked directly: refused with config-set's own error unless it is a real section (a schema key lives under it, or it is a dynamic family's top level such as agent_skills), where it sets nothing as before. A real section given anything but an object is refused with an error that says it takes an object of settings. Tests: three empty objects under unknown names, at the top level, nested, and under a real section, each refused with config-set's identical error (config-set's refusal asserted as a positive control); real sections given {} produce the {} payload's config; a section given a string, boolean, number, null or array is refused as a section. Against the previous build 8 of the 9 fail; the real-section row pins behaviour that does not move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
Found while sweeping for other values config-new-project writes in a form config-set cannot (the class of open-gsd#5284's Minor 2): config-set stores project_code as the text typed and never number-coerces it (open-gsd#1581), but `{"project_code":7}` was written as the number 7, which every reader casts `as string`. validateConfigValue now refuses a non-string project_code. Through config-set the value is always the typed text, so the rule never fires there; it holds a JSON choice to the type config-set stores. Tests: 7 is refused and nothing written; "007" is written as "007". Fails against the previous build; the existing open-gsd#1581 config-set rows still pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…ew masking, and the template fill Review of open-gsd#5284 (Minors 3, 5, 6; Nit 2; Nit 9 and Minor 2's documentation): - Choices are JSON-typed and never coerced. A string where workflow.research or git.create_tag takes a boolean, or where context_window or workflow.security_asvs_level takes a number, is refused, while config-set accepts the same text (asserted as a positive control). - `--raw --dry-run` prints `would create (dry run)` and writes nothing. - A secret choice in the dry-run preview is masked to exactly what `config-set --dry-run` prints for it, and its plaintext appears nowhere. - The template test now walks the filled payload's strings for a leftover placeholder (`"[…]"` or a bare `a|b|c` alternation), so a new placeholder spelling fails by name instead of as a parse or enum error. Planted control: with the `"[selected]"` mapping removed, both template rows fail with that message. docs/CLI-TOOLS.md now states the two intended differences from config-set that follow from JSON input (no coercion; a setting with per-entry settings under it takes an object of those entries, each checked, where config-set stores any object whole) and that the preview masks secrets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…at config-set accepts, leaf by leaf Review of open-gsd#5284 (Major 1, RULESET.TESTS.property-based-testing): validateNewProjectChoices is a recursive walk over arbitrary nested JSON, and its tests were table rows only. A property section at the end of tests/config.test.cjs (one file per module, lint-test-file-count) runs both verbs in-process (error() throws ExitError, ADR-3889) over generated payloads of one to five leaves drawn from fourteen keys, each with values its rule accepts, values it refuses, and null: (a) a payload is accepted exactly when config-set accepts every leaf; a refusal carries the error config-set gives for one of the leaves; an accepted leaf is written as config-set --dry-run reports it would write it (normalized values included); (b) one unknown leaf inserted anywhere refuses the payload, writes no .planning directory, and is the leaf named when the rest are valid. Each in-process call runs inside suppressFdAsync on fd 1 and fd 2, the helper that exists for this window (open-gsd#4448: io.output()'s fd-1 write races node:test's child-to-parent IPC), so the properties are async. The text handed to config-set is the text it parses back to the same JSON value; strings config-set would coerce are left out, since that difference is intended and pinned by name in the rows above. Seed 42 (shared setup): 200 runs of (a), 75 accepted and 125 refused, and the test asserts both verdicts occur so a one-sided generator cannot pass it vacuously. Negative control: against the build before the null fix in this series, (a) fails with ship.pr_body_sections: null, refused by the builder with an error config-set never gives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…checking before already_exists The fragment lands in the CHANGELOG verbatim, so it now names the user-visible changes from the open-gsd#5284 review round: a null choice leaves the setting at its default instead of writing null; values are JSON-typed and not coerced; and the choices are checked even when the config already exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
… config is written Found by the pre-push cross-AI review of the open-gsd#5284 round: cmdConfigNewProject persisted the user defaults rewrite (depth -> granularity) before creating .planning/ and writing config.json, so a creation that failed at either step left the defaults file migrated and no config. The reviewed head and the base did the same, from inside the builder; this is not a regression, but the round had already moved the write to the caller, so it now follows the config write it was planned for. Tests: a fault-injected config write (mockPartialWriteThenThrow on the config path, in-process so the fault lands on that write and nothing before it) is refused with "Failed to write config.json" and leaves the defaults file byte-identical; a successful creation still migrates it. Against the previous ordering the first row fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…follows the dash Review of open-gsd#5284 (Nit 3): `config-new-project -1` was refused as "Unknown flag for config-new-project: -1", because any token starting with `-` was read as a flag. `-1` is a JSON value; the error it deserves is the one any other non-object payload gets. A flag is now a dash (or two) followed by a letter. `-1` and a bare `-` are the choices argument and are held to the choices rules ("expected a JSON object", "Invalid JSON"); `-x` and `--force` are still refused as unknown flags. (todo complete, whose closed flag set this follows, scans only the tokens after its positional, so it never had to decide this.) Tests: `-1`, `-` and `-x`. Against the previous dispatcher the first two fail with "Unknown flag"; the third pins the unchanged refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…aviour as verified The pre-push cross-AI review of the open-gsd#5284 round, over two passes, refuted these sentences as stronger than the behaviour: - "a null choice leaves its key at the default, as config-set <key> null clears it": the two end in different effective values when a user default or a detected API key is involved. A null choice is now described as not chosen (it takes the value it would have had without the choice), and config-set's null as its existing-config counterpart, not an equal. - "a refused choice fails even when config.json already exists": true, but the set is wider than config-set's refusals (malformed JSON and other non-object payloads too), and narrower than "anything this command refuses": a problem in the user defaults file is not reported when the config already exists, because the defaults are read only to build one. - "an empty object sets nothing": where the key is itself a setting, {} is its value, held to that setting's rule as config-set holds it (written for model_overrides.<agent> and agent_tools, refused for mode). Where the key is not a setting, an empty object sets nothing under a named section and is refused as an unknown key otherwise, including below the first level of a dynamic key family, as config-set refuses it. The changeset's null sentence is reworded to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…r> keys Found by the fifth pass of the pre-push cross-AI review of the open-gsd#5284 round: `agent_tools` is both a valid setting and the top level of the `agent_tools.<selector>` dynamic key family, but the walker's "does a key live under this one" test looked only at static and capability keys. So `agent_tools` was taken as a whole value, and `{"agent_tools":{"$bad":[]}}` was written although config-set refuses `agent_tools.$bad` as an unknown key. hasChildConfigKeys now also answers yes for a dynamic family's top level, so an `agent_tools` object is walked and each selector is checked as its own key. `agent_tools` is the only key this moves: it is the one dynamic top level that is also a valid key with no static children. An empty `agent_tools: {}` now sets nothing, which the install-time reader treats exactly as an absent key (both merge the global grants unchanged). isConfigSection, which duplicated the dynamic-top-level test, now defers to hasChildConfigKeys. docs/CLI-TOOLS.md names agent_tools beside review.max_prompt_tokens_per_reviewer as a setting checked entry by entry. Tests: `{"agent_tools":{"$bad":[]}}` refused with config-set's identical error (config-set's refusal asserted first); valid selectors, `*` included, written as given; `agent_tools: {}` gives the {} payload's config. Against the previous build both rows fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…ols, and label the budget example Sixth pass of the pre-push cross-AI review of the open-gsd#5284 round: "a section given anything but an object is refused" is false for agent_tools, a key that is both a setting and a section; `"agent_tools":"x"` is held to the setting's own rule (it has none), as config-set holds it. And the `{"a":1}` refusal example applies to the reviewer budget, not to agent_tools, whose selector `a` is valid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
|
Thanks. Rebased onto
Found before pushingA review of this round, before the push, refuted a claim the docs made: that
The same review turned up two
I can file the first as its own issue if you want it tracked. A correction to my round-2 replyIts Verification listed Verification
|
trek-e
left a comment
There was a problem hiding this comment.
Review (re-review) — fix(#5268): config-new-project refuses the choices config-set refuses, and honors --dry-run
Head reviewed: f1f36fb0630ce010432bb674a63ead50f73b5d5f (fresh gh pr view this turn: mergeable MERGEABLE, mergeStateStatus BEHIND, reviewDecision CHANGES_REQUESTED, not draft). Merge base 433a43445; origin/next is now f7efbf110.
Summary
Re-review of trek-e's CHANGES_REQUESTED reviews (head 2281bd712, then 9a68a06fb). The second review already found every first-round finding resolved or declined with a verified reason and left Minor 7 (scope) to the maintainer. Since 9a68a06fb the PR was rebased onto 433a43445 and gained three commits: b00f1cdea (mask preview via maskIfSecret, closes the second review's Nit 1), 104b4ac99 (test + docs for a third config-set difference), f1f36fb06 (empty commit to re-run CI). No new defect in the delta. Remaining items are the carried maintainer-call scope finding and two nits.
Classification & gate compliance
- Track: linked #5268 is
confirmed-bug(per trek-e's earlier verified read) -> Bug-Fix track. PR label isreview: changes requestedonly (state label, not a classification label). - Body:
Fixes #5268, Fix template, not draft. Titlefix(#5268): ...conventional. - Changeset
.changeset/calm-heron-checks.md: typeFixed,pr: 5284, bold lead, body ends at the period. Fixed needs no docs/ update;docs/CLI-TOOLS.mdis updated anyway (and in the delta). - No
references/orworkflows/file added, so no INVENTORY drift. No shipped agent/workflow/command markdown changed. - Files changed (5):
.changeset/calm-heron-checks.md,docs/CLI-TOOLS.md,gsd-core/bin/gsd-tools.cjs,src/config.cts,tests/config.test.cjs. - Branch
fix/...name not re-verified; head ref not shown in the fetched JSON (not a finding).
Must-Have Acceptance Checklist (#5268 Agent Brief, as quoted in the earlier review)
- Unknown key: non-zero exit,
Unknown config key, no config created. PASS (create path and, since3b8478e8a, existing-config path). modeoutside interactive/yolo and any valueconfig-setrejects: same message, no file. PASS (18 parity rows plus 3 forbidden-segmentnullrows, the latter new in104b4ac99).- Unsupported flags refused or
--dry-runprints and writes nothing. PASS (both;-1/-now reported as choices). - Workflow template keys still accepted, same config as today. PASS (tests fill both shipped templates and assert leftover-placeholder absence).
- Existing-config idempotency and user-defaults unchanged. PASS for idempotency with valid choices; defaults-file rewrite now deferred until after the config write (see Minor 7).
Root-cause / functional verdict
Root cause (one sentence): cmdConfigNewProject spread caller choices into the config without the key/value rules that existed only inline in cmdConfigSet, and routeConfigNewProject dropped every flag silently. The fix extracts assertKnownConfigKey / validateConfigValue and runs them over the dotted leaves before any write, and closes the flag set. One owner for the rules: cmdConfigSet calls the same functions. Root-cause fix, not symptom handling.
Delta check:
b00f1cdeasrc/config.cts:714:maskIfSecret(key, value)replacesisSecretKeythenmaskSecret.maskIfSecretis exported bysrc/secrets.cts(INVENTORY.md:721 lists it;src/init.cts:39already imports it). Behavior-preserving; three masking tests named in the commit cover it.104b4ac99: three rows (agent_skills.__proto__|prototype|constructor=null) assert theconfig-setpositive control against a seeded default config first, then identical error text and nothing written (tests/config.test.cjs~813-834). Specific refusal verdict, persisted-artifact check viaassertNothingWritten. Docs text added atdocs/CLI-TOOLS.md~627-635 documents the third difference; consistent with the PR body.f1f36fb06: empty commit, no content.
Blast radius
CodeGraph (earlier review, not re-run for the delta since it touches one call site): buildNewProjectConfig has 5 impacted symbols (cmdConfigNewProject, ensureConfigFile, cmdConfigEnsureSection, cmdConfigSetModelProfile); the other callers see the deferred defaults-file write only. The delta changes one masking call and adds tests/docs. Risk is contract drift between the two config verbs, now documented.
Co-change completeness
src/config.cts + gsd-core/bin/gsd-tools.cjs + test + docs + changeset all present. src/command-aliases.cts already carries config-new-project (earlier Minor 4 declined, accepted by trek-e). Generated .cjs outputs are built in CI; no drift check red.
Memtrace Evidence
get_impact: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT).get_symbol_context: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT).recall_decision: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). The recorded-decision gate did not execute; this is not a clean result.find_code_review_issues: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). The deterministic review pass did not run.
Substituted evidence:git showof the three delta commits;git grep maskIfSecret refs/prrev/5284(export documented indocs/INVENTORY.md:721, second consumersrc/init.cts:2390); the prior reviews' CodeGraph impact ofbuildNewProjectConfig(5 symbols). No local test was run (node --testblocked; CI is the executor).
Test quality
Unchanged from the second review's verdict: two fast-check properties with both-outcome assertions, limit-1/limit/limit+1 rows, config-set positive control before each refusal, persisted-artifact assertions. Delta rows meet the same bar. No source-grep, no wall-clock assertions in the delta. The fail-first claim for the new rows (null skip moved above segment guard turns exactly three rows red) is the author's reversion control; I did not re-execute it.
CI (head f1f36fb06)
Latest-per-name collapse: 39 success, 5 skipped, 0 failing, 0 pending. action_required runs for this head: none. Skipped rows include the Stryker matrix and mutation score (not executed, so no mutation result; as the author states, this is not a full-matrix result). The prior Windows shard hang on 104b4ac99 was re-run green at this head; the author attributes it to an unrelated runner hang and I am only reporting that the re-run is green, not judging the cause. Branch is BEHIND next (merge-base 433a43445, next at f7efbf110); a rebase is a CI condition, not a finding: git fetch origin && git rebase origin/next.
Findings
Minor 7 (scope beyond the Brief)— withdrawn: the maintainer accepted the extra refusals as in scope.- Nit A (deterministic fact):
f1f36fb06is an empty commit titledci: re-run after an unrelated Windows runner hang, with no issue scope; it adds history noise. Fix: drop it at the next rebase (git rebase origin/nextwith it dropped, or re-run CI from the Actions UI instead). - Nit B (carried, disclosed): the new
describeblock intests/config.test.cjsis not exercised by the Windows conformance legs (they runtests/config.platform.test.cjs); disclosed in the PR body. Judgment whether a platform-leg wiring is wanted.
Verdict
Changes requested under zero tolerance (nit-level only): all prior Majors and Minors are resolved and verified; the delta is clean; CI is green at f1f36fb06 (39 success, 5 skipped, 0 action_required). Substance is otherwise clean; before merge: drop the empty commit, rebase on origin/next, (Minor 7 scope question was ruled in scope by the maintainer).
… refuses, and honors --dry-run config-new-project spread its choices JSON into the new config unchecked, so an unknown key or an out-of-enum value (a `mode` placeholder left unfilled by the new-project workflow) was written with `created: true` while config-set refused the same input. Its route also forwarded only the first argument, so `--dry-run` was dropped and the file written. Choices are now walked to the dotted leaf paths config-set addresses and each leaf goes through the same key gate and value rules, then the setter's prototype-pollution refusal, before the first write. The value rules were inline in cmdConfigSet; they move, unchanged, into validateConfigValue, and the key check into assertKnownConfigKey, so the two verbs share one definition and one error text. Four inputs on the same unvalidated path are closed with it. Choices that are not a JSON object are refused (an array was spread in as index keys). An empty key is refused. A key with a dot that spells a deeper path is refused, since the merge would write it as one literal key no reader resolves; a dot inside an id is kept, recognised by one dynamic key family admitting both the path cut at the dot and the whole path (phase_commit_docs is keyed by the literal phase id, "2.1" included). A section object with no settings in it, as given or once its own empty sections are dropped, is not written. A refusal writes nothing. buildNewProjectConfig used to rewrite the user defaults file (depth -> granularity) before its own final check, so a payload refused there still migrated the file; that write now waits until the config has validated. The route's flag surface is closed, following routeTodo (open-gsd#4096): `--dry-run` prints the config that would be created and writes nothing, the defaults rewrite included; any other flag and a second positional argument are refused. Tests: a table of inputs config-set refuses, each asserted to be refused by config-new-project with the identical error and no .planning directory created; the flag, dry-run and shape cases; and both shipped workflow templates, filled in, asserted to produce the config they chose. Against the previous build 40 of the 44 fail; the four that pass are the template, existing-config and decimal-phase-id cases, which pin behavior that must not move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RN5q5WUD6eGFA4SrhJmVtg
… and return checked choices as a copy Review of open-gsd#5284 (Minor 8, Nits 1, 5, 6, 8): validateNewProjectChoices rewrote and deleted keys in the caller's object while named validate*, and buildNewProjectConfig took a persistDefaultsMigration option and held a serialized pending write while its docstring said it wrote nothing. planNewProjectConfig now builds the config and returns the user defaults rewrite (depth -> granularity) as data, writing nothing; cmdConfigNewProject persists it only on a real write. buildNewProjectConfig keeps its one other caller's contract (ensureConfigFile): plan, persist, return the config. validateNewProjectChoices returns the checked choices as a new object and leaves its input as given. validateConfigValue's docstring states its one contract: it returns the value to persist, or refuses. The route comment cited routeConfigSet as the model for its closed flag surface; routeConfigSet parses no flags. It now cites todo complete (open-gsd#4096). No behaviour change: tests/config.test.cjs 237/237. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…sting-config short-circuit Review of open-gsd#5284 (Minor 1): cmdConfigNewProject returned already_exists before it parsed or checked its choices, so an unknown key or an invalid mode exited 0 whenever .planning/config.json existed, and --dry-run gave no preview of the refusal. The Brief's "non-zero exit" and idempotency lines each held, on different paths. The choices are now parsed and checked first; the existence check follows. Valid choices on an existing config still report already_exists and leave it untouched (the existing dry-run row), so idempotency is unchanged for every input config-set would accept. Tests: an invalid mode and an unknown key, each with and without --dry-run, against a project that already has a config: refused, file byte-identical. Against the previous build all 4 fail (exit 0, already_exists). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…set <key> null clears it Review of open-gsd#5284 (Minor 2, the null cases): config-set treats a bare null as "clear" (open-gsd#2046) and skips the key's typed rules; config-new-project skipped the rules too but wrote a literal null, a value config-set can never store. So `mode: null` was accepted and written as null, and `ship.pr_body_sections: null` reached the builder's array check and was refused where config-set accepts and clears it. A null choice still has its key checked, then is left out of the choices, so the key takes the value an empty payload gives it. The test of a builder-stage refusal used that null as its vehicle; it now uses an invalid user-defaults value (defaults contents are not validated as choices), which is still refused before the defaults file is migrated. Tests: mode, ship.pr_body_sections and workflow.research given null are accepted, each config-set-accepted as a positive control, and produce the config a {} payload produces; a null under an unknown key is still refused. Against the previous build the three null rows fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
… name a section given a non-object Review of open-gsd#5284 (Minor 7, the empty-section part; Nit 4): an empty object was dropped silently whatever its name, so `{"bogus":{}}` succeeded where config-set refuses `bogus '{}'` as an unknown key; and a real section given a non-object (`{"workflow":"x"}`) was reported as `Unknown config key "workflow"`, which misdescribes it. An empty object has no leaves to carry its name through the key gate, so its name is checked directly: refused with config-set's own error unless it is a real section (a schema key lives under it, or it is a dynamic family's top level such as agent_skills), where it sets nothing as before. A real section given anything but an object is refused with an error that says it takes an object of settings. Tests: three empty objects under unknown names, at the top level, nested, and under a real section, each refused with config-set's identical error (config-set's refusal asserted as a positive control); real sections given {} produce the {} payload's config; a section given a string, boolean, number, null or array is refused as a section. Against the previous build 8 of the 9 fail; the real-section row pins behaviour that does not move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
Found while sweeping for other values config-new-project writes in a form config-set cannot (the class of open-gsd#5284's Minor 2): config-set stores project_code as the text typed and never number-coerces it (open-gsd#1581), but `{"project_code":7}` was written as the number 7, which every reader casts `as string`. validateConfigValue now refuses a non-string project_code. Through config-set the value is always the typed text, so the rule never fires there; it holds a JSON choice to the type config-set stores. Tests: 7 is refused and nothing written; "007" is written as "007". Fails against the previous build; the existing open-gsd#1581 config-set rows still pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…ew masking, and the template fill Review of open-gsd#5284 (Minors 3, 5, 6; Nit 2; Nit 9 and Minor 2's documentation): - Choices are JSON-typed and never coerced. A string where workflow.research or git.create_tag takes a boolean, or where context_window or workflow.security_asvs_level takes a number, is refused, while config-set accepts the same text (asserted as a positive control). - `--raw --dry-run` prints `would create (dry run)` and writes nothing. - A secret choice in the dry-run preview is masked to exactly what `config-set --dry-run` prints for it, and its plaintext appears nowhere. - The template test now walks the filled payload's strings for a leftover placeholder (`"[…]"` or a bare `a|b|c` alternation), so a new placeholder spelling fails by name instead of as a parse or enum error. Planted control: with the `"[selected]"` mapping removed, both template rows fail with that message. docs/CLI-TOOLS.md now states the two intended differences from config-set that follow from JSON input (no coercion; a setting with per-entry settings under it takes an object of those entries, each checked, where config-set stores any object whole) and that the preview masks secrets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…t, and the positional count Review of open-gsd#5284 (Major 2): the parity rows asserted only the refused side of each limit (context_window 0, security_asvs_level 9, drift_threshold 0, hooks.context_warning_threshold 0), so a walker that refused too much, or a rule whose accepted edge moved, would still pass. Each numeric rule is now exercised at limit-1 / limit / limit+1 on every end it has: hooks.context_warning_threshold -1, 0, 1, 99, 100, 101 (0 is in range but refused, open-gsd#4285); workflow.security_asvs_level 0-4; context_window and workflow.drift_threshold 0, 1, 2. Every row first asserts config-set's verdict for the same value, so the expected column is checked against the oracle; an accepted value must be written as given and a refused one must give config-set's identical error and write nothing. The positional count gains its zero case (no choices, and --dry-run alone, build the defaults-only config) beside the existing one- and two-positional rows and the flag-before/after pair. Mutant control: moving the ASVS upper bound in the built lib from 3 to 4 turns the `=4` row red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…at config-set accepts, leaf by leaf Review of open-gsd#5284 (Major 1, RULESET.TESTS.property-based-testing): validateNewProjectChoices is a recursive walk over arbitrary nested JSON, and its tests were table rows only. A property section at the end of tests/config.test.cjs (one file per module, lint-test-file-count) runs both verbs in-process (error() throws ExitError, ADR-3889) over generated payloads of one to five leaves drawn from fourteen keys, each with values its rule accepts, values it refuses, and null: (a) a payload is accepted exactly when config-set accepts every leaf; a refusal carries the error config-set gives for one of the leaves; an accepted leaf is written as config-set --dry-run reports it would write it (normalized values included); (b) one unknown leaf inserted anywhere refuses the payload, writes no .planning directory, and is the leaf named when the rest are valid. Each in-process call runs inside suppressFdAsync on fd 1 and fd 2, the helper that exists for this window (open-gsd#4448: io.output()'s fd-1 write races node:test's child-to-parent IPC), so the properties are async. The text handed to config-set is the text it parses back to the same JSON value; strings config-set would coerce are left out, since that difference is intended and pinned by name in the rows above. Seed 42 (shared setup): 200 runs of (a), 75 accepted and 125 refused, and the test asserts both verdicts occur so a one-sided generator cannot pass it vacuously. Negative control: against the build before the null fix in this series, (a) fails with ship.pr_body_sections: null, refused by the builder with an error config-set never gives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…checking before already_exists The fragment lands in the CHANGELOG verbatim, so it now names the user-visible changes from the open-gsd#5284 review round: a null choice leaves the setting at its default instead of writing null; values are JSON-typed and not coerced; and the choices are checked even when the config already exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
… config is written Found by the pre-push cross-AI review of the open-gsd#5284 round: cmdConfigNewProject persisted the user defaults rewrite (depth -> granularity) before creating .planning/ and writing config.json, so a creation that failed at either step left the defaults file migrated and no config. The reviewed head and the base did the same, from inside the builder; this is not a regression, but the round had already moved the write to the caller, so it now follows the config write it was planned for. Tests: a fault-injected config write (mockPartialWriteThenThrow on the config path, in-process so the fault lands on that write and nothing before it) is refused with "Failed to write config.json" and leaves the defaults file byte-identical; a successful creation still migrates it. Against the previous ordering the first row fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…follows the dash Review of open-gsd#5284 (Nit 3): `config-new-project -1` was refused as "Unknown flag for config-new-project: -1", because any token starting with `-` was read as a flag. `-1` is a JSON value; the error it deserves is the one any other non-object payload gets. A flag is now a dash (or two) followed by a letter. `-1` and a bare `-` are the choices argument and are held to the choices rules ("expected a JSON object", "Invalid JSON"); `-x` and `--force` are still refused as unknown flags. (todo complete, whose closed flag set this follows, scans only the tokens after its positional, so it never had to decide this.) Tests: `-1`, `-` and `-x`. Against the previous dispatcher the first two fail with "Unknown flag"; the third pins the unchanged refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…aviour as verified The pre-push cross-AI review of the open-gsd#5284 round, over two passes, refuted these sentences as stronger than the behaviour: - "a null choice leaves its key at the default, as config-set <key> null clears it": the two end in different effective values when a user default or a detected API key is involved. A null choice is now described as not chosen (it takes the value it would have had without the choice), and config-set's null as its existing-config counterpart, not an equal. - "a refused choice fails even when config.json already exists": true, but the set is wider than config-set's refusals (malformed JSON and other non-object payloads too), and narrower than "anything this command refuses": a problem in the user defaults file is not reported when the config already exists, because the defaults are read only to build one. - "an empty object sets nothing": where the key is itself a setting, {} is its value, held to that setting's rule as config-set holds it (written for model_overrides.<agent> and agent_tools, refused for mode). Where the key is not a setting, an empty object sets nothing under a named section and is refused as an unknown key otherwise, including below the first level of a dynamic key family, as config-set refuses it. The changeset's null sentence is reworded to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…r> keys Found by the fifth pass of the pre-push cross-AI review of the open-gsd#5284 round: `agent_tools` is both a valid setting and the top level of the `agent_tools.<selector>` dynamic key family, but the walker's "does a key live under this one" test looked only at static and capability keys. So `agent_tools` was taken as a whole value, and `{"agent_tools":{"$bad":[]}}` was written although config-set refuses `agent_tools.$bad` as an unknown key. hasChildConfigKeys now also answers yes for a dynamic family's top level, so an `agent_tools` object is walked and each selector is checked as its own key. `agent_tools` is the only key this moves: it is the one dynamic top level that is also a valid key with no static children. An empty `agent_tools: {}` now sets nothing, which the install-time reader treats exactly as an absent key (both merge the global grants unchanged). isConfigSection, which duplicated the dynamic-top-level test, now defers to hasChildConfigKeys. docs/CLI-TOOLS.md names agent_tools beside review.max_prompt_tokens_per_reviewer as a setting checked entry by entry. Tests: `{"agent_tools":{"$bad":[]}}` refused with config-set's identical error (config-set's refusal asserted first); valid selectors, `*` included, written as given; `agent_tools: {}` gives the {} payload's config. Against the previous build both rows fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
…ols, and label the budget example Sixth pass of the pre-push cross-AI review of the open-gsd#5284 round: "a section given anything but an object is refused" is false for agent_tools, a key that is both a setting and a section; `"agent_tools":"x"` is held to the setting's own rule (it has none), as config-set holds it. And the `{"a":1}` refusal example applies to the reviewer budget, not to agent_tools, whose selector `a` is valid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ev5QX35akRTC21izckDSc
The 2026-10-14 review's nit 1: the dry-run preview's masking loop duplicated a helper that secrets.cjs already exports. maskIfSecret(key, value) is exactly the loop's body, isSecretKey then maskSecret, so the preview now calls it. Output is unchanged. The other masking sites in config.cts mask one key's value (config-set also masks its previous value), not a whole object, and stay as they are. Covered by the existing preview-masking pin: with the call replaced by the unmasked value, three tests in tests/config.test.cjs fail, among them "--dry-run masks a secret choice exactly as config-set --dry-run masks it, and never prints it". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Sdm6Hwt6Y5PdQ6wAhnPHA
…nd document config-set's no-op
A pre-push review of this round found a third difference from config-set
that the docs did not list. config-new-project refuses a null choice
under __proto__, prototype or constructor ({"agent_skills":{"constructor":null}},
{"model_overrides":{"constructor":null}}) with the setter's
prototype-pollution error. config-set's unset path reaches that guard
only through a section that exists: with none, _unsetNestedValue finds
no parent and returns before its leaf guard, so config-set exits 0 with
nothing removed. Its known-key check still runs first. The default
config (`config-new-project '{}'`) has agent_skills but not
model_overrides, agent_tools or features, so the difference can hold
after creation.
config-set is out of scope for open-gsd#5268 and is left as it is. Three rows
pin the new-project side for agent_skills against config-set run on a
default config (the oracle asserted first), and docs/CLI-TOOLS.md states
the difference. Neither the parity property's generator nor the existing
unset-guard test reached this case: with the null `continue` moved above
the segment guard, these three rows fail and the other 287 tests in
tests/config.test.cjs pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Sdm6Hwt6Y5PdQ6wAhnPHA
f1f36fb to
f329c43
Compare
|
Thanks. Rebased onto
This round has no fix commits, so there is no reversion control to report. Nit BMost of the block's tests reach the defaults store and the API-key files through BodyThree corrections. The negative-controls line cited the first commit as Verification
|
trek-e
left a comment
There was a problem hiding this comment.
Review (re-review 3) - fix(#5268): config-new-project refuses the choices config-set refuses, and honors --dry-run
Head reviewed: f329c43c882b7872f5ae377977e55ad8427fb88e (fresh gh pr view this turn: mergeable MERGEABLE, mergeStateStatus BEHIND, reviewDecision CHANGES_REQUESTED). Merge base 755bf26f0; origin/next is 0dd184555, 4 commits ahead. git merge-tree of head with origin/next is clean.
Summary
Third review. Prior on-record reviews are trek-e's at 2281bd712, 9a68a06fb and f1f36fb06. The last one left two nits: Nit A (empty commit f1f36fb06) and Nit B (new test block not on the Windows legs). Since f1f36fb06 the author rebased onto 755bf26f0 and dropped the empty commit. Nit A is resolved. Nit B is declined with a reason. The author states git range-diff marks all 18 remaining commits =. I confirmed the PR's own diff against 755bf26f0 touches exactly the five PR files and matches the reviewed content (no hunk in src/config.cts, gsd-core/bin/gsd-tools.cjs, docs/CLI-TOOLS.md or the changeset differs from what the prior review read). The three other PR comments are all from the author; none is an unanswered question.
Classification & gate compliance
- Linked #5268 labels:
bug,confirmed-bug-> Bug-Fix track. PR carries only the state labelreview: changes requested. Fixes #5268present (GitHubclosingIssuesReferencesresolves it). Titlefix(#5268): ...conventional. Not a draft. Fix template used. Branchfix/5268-config-new-project-validate-choicesmatches<prefix>/NNN-slug.- Changeset
.changeset/calm-heron-checks.md: typeFixed,pr: 5284, bold lead, ends at the period.Fixedneeds no docs update;docs/CLI-TOOLS.mddocuments the verb anyway. - No
references/orworkflows/file added: no INVENTORY drift. No agent/workflow/command markdown changed. - One concern: the dotted-key, non-object, empty-section and second-positional refusals were ruled in scope by the maintainer in the previous review.
- ADR gate: no ADR governs this verb that I could find;
recall_decisionunavailable (see Memtrace Evidence).
Must-Have Acceptance Checklist (#5268 Agent Brief, verbatim)
- "Unknown key in choices: non-zero exit,
Unknown config keyerror, no config file created." PASS.validateNewProjectChoicesrunsassertKnownConfigKeyper leaf beforeplanNewProjectConfig/any write. - "
modeoutsideinteractive/yolo(and any other valueconfig-setrejects for that key): non-zero exit with the same message, no file created." PASS.validateConfigValueis the extractedcmdConfigSetbody; both verbs call it, so the error text is the same code path. 18 parity rows + 3 forbidden-segment rows. - "Unsupported flags are refused, or
--dry-runprints the config and writes nothing." PASS (both): closed flag set inrouteConfigNewProject;dryRunreturns beforemkdirSync/platformWriteSync. - "The full set of keys the new-project workflow template emits ... is still accepted and produces the same config as today." PASS per the tests that fill both shipped templates.
- "Existing-config idempotency (
created: false) and defaults from the user defaults file are unchanged." PASS for valid choices. The defaults-filedepth->granularityrewrite now runs after the config write (persistDefaultsRewrite), which keepsbuildNewProjectConfigbehaviour forensureConfigFile.
Out of scope per Brief:config-setbehavior, validation of user-defaults contents, template keys. None changed.
Root-cause / functional verdict
Root cause, one sentence: cmdConfigNewProject spread caller choices into the config with none of the key/value rules that existed only inline in cmdConfigSet, and routeConfigNewProject forwarded only args[1], dropping every flag. The fix is at the source: the key gate (assertKnownConfigKey) and the value block (validateConfigValue) are extracted and called by both verbs, so there is one owner for the rules and no parallel validator. Not special-casing.
Independent read of the diff:
- Prototype-pollution:
Object.keysonJSON.parseoutput includes an own__proto__key. Every path that would assignchecked[key]for such a key either errors inassertKnownConfigKey(unknown key) or in the segment guard before assignment.checkedis only assigned after the guard. No pollution path found. - Order of checks matches
config-set(key, value, then setter's segment guard). --dry-runwith existingconfig.jsonreportsalready_existswith no preview; documented indocs/CLI-TOOLS.md.- Subprocesses: none added.
Blast radius
buildNewProjectConfig keeps its signature and contract (plan, persist, return); ensureConfigFile, cmdConfigEnsureSection, cmdConfigSetModelProfile callers see only the deferred defaults write. cmdConfigSet now calls the two extracted functions with identical bodies. cmdConfigNewProject gains an optional fourth parameter (additive). Generated gsd-core/bin/lib/config.cjs is gitignored (.gitignore:329), built in CI. No exported-surface removal.
Co-change completeness
Present: src/config.cts, gsd-core/bin/gsd-tools.cjs, tests, docs/CLI-TOOLS.md, changeset. config-new-project already has its entry in src/command-aliases.cts (accepted by trek-e in review 2). The 4 commits on next since the merge base (.changeset/brave-search-no-redirect.md, .github/workflows/test.yml, src/commands.cts and CI diagnostics) touch none of this PR's files.
Memtrace Evidence
get_impact: UNAVAILABLE - Memtrace MCP failed to connect (ENOENT).get_symbol_context: UNAVAILABLE - Memtrace MCP failed to connect (ENOENT).recall_decision: UNAVAILABLE - Memtrace MCP failed to connect (ENOENT). The recorded-decision gate did not execute; this is not a clean result.find_code_review_issues: UNAVAILABLE - Memtrace MCP failed to connect (ENOENT). The deterministic review pass did not run; the findings below are manual-diff judgment unless marked as fact.
Substituted evidence:gh pr diff-equivalentgit diff 755bf26f0 refs/prrev/5284for all five files;git merge-treeagainstorigin/next; compare APIf1f36fb06...f329c43c8(rebase only, 119 files fromnext, PR files identical per range-diff);git ls-tree/check-ignorefor the generated file; PR-comment and review history. No CodeGraph call was needed beyond the diff because the changed symbols and callers are all insrc/config.cts. No local test run (node --testblocked; CI is the executor).
Test quality
Unchanged from review 2: two fast-check properties with both-outcome assertions, limit-1/limit/limit+1 rows for the four numeric rules and the positional count, config-set positive control before each refusal, assertion on the persisted artifact (assertNothingWritten, defaults file byte-identical). No wall-clock assertions. The readFileSync hits in the diff read config.json, the defaults file and workflows/*.md templates (not a source .cjs/.js/.ts), and .split('.') is on key paths: no local/no-source-grep exposure, no CRLF-split exposure. Fail-first for the new rows rests on the author's reversion controls, which I did not re-execute.
CI (head f329c43c8)
Latest-per-name collapse of 50 registrations (37 names): 32 success, 5 skipped, 0 failing, 0 pending. Skipped: auto-merge, Publish emitted-baseline artifact, Stryker (${{ matrix.name }}), Stryker mutation score (changed files only), test (inert CI). Stryker did not execute, so this is not a mutation-matrix result. action_required runs for this head: none. macOS conformance, Windows conformance shards 1-3, ubuntu test shards 1-3, Required tests, Coverage gate, lint-tests, docs-lint, changeset-lint, Base branch health all success.
Findings
- Nit B (carried, judgment):
tests/config.test.cjs(the newdescribeblock, ~800 lines) is not run by the Windows conformance legs, which runtests/config.platform.test.cjs. The author's reason (no OS branch in the validation,HOME/USERPROFILEboth sandboxed) is plausible but unverified, and the author's own note says the platform-tier generator "reads the file's text and does not follow the helper". Fix: move the platform-sensitive subset (defaults-file and API-key-file cases that go throughos.homedir()) intotests/config.platform.test.cjs, or confirm the decline. - Nit C (deterministic fact):
docs/CLI-TOOLS.md:627is a 120-column line inside a hard-wrapped bullet whose neighbours wrap near 100 (... refused, where \config-set` stores any object given to the setting whole. `review.max_prompt_tokens_per_reviewer` also`). Fix: rewrap that bullet. - State, not a finding: branch is BEHIND
nextby 4 commits and merge-tree is clean.git fetch origin && git rebase origin/next, push.
Verdict
Changes requested under zero tolerance (nit-level only). Substance is clean: all Majors and Minors from reviews 1 and 2 are resolved, Nit A is resolved, CI is green at f329c43c8 with Stryker skipped, no action_required runs. Remaining: Nit B (maintainer's call), Nit C, and a rebase onto origin/next.
Fix PR
Linked Issue
Fixes #5268
What was broken
config-new-projectwrote almost any choices it was given into the new project's.planning/config.jsonand reportedcreated: true(the one value it checked wasship.pr_body_sections, in the builder). An unknown key, or a valueconfig-setrefuses for the same key (for example amodeplaceholder the new-project workflow left unfilled), landed in the config with no error.--dry-runwas accepted, ignored, and the file was written anyway.What this fix does
Choices are now checked with
config-set's own key and value rules before anything is written, and a choiceconfig-setrefuses fails with the same error text; the three intended differences are listed below. The check runs before the existing-config check, so a refused choice fails even whenconfig.jsonalready exists.--dry-runprints the config that would be created and writes nothing; any other flag, and a second positional argument, are refused.After the fix, the issue's reproduction:
Root cause
Two gaps in one verb:
buildNewProjectConfigspreads the choices verbatim (...choices) andcmdConfigNewProjectnever checked a key or a value. The value rules existed only inline incmdConfigSet(L920-L1179), so no other verb could apply them.routeConfigNewProjectforwarded onlyargs[1](L839-L851), so every flag was dropped without a word.The change
src/config.ctsvalidateConfigValue(cwd, kp, parsedValue, val)iscmdConfigSet's per-key value block, moved into a function with its body unchanged except for one added rule: aproject_codethat is not a string is refused (config-setalways stores the typed text, bug: config-set silently coerces values — context_window Infinity persisted as null while CLI reports Infinity (output ≠ disk) #1581, so the rule never fires there).cmdConfigSetcalls it.assertKnownConfigKeyis the same extraction for the key check and its error text.validateNewProjectChoiceswalks the nested choices to the dotted leaf pathsconfig-setaddresses ({"workflow":{"research":true}}is checked asworkflow.research) and runs each leaf through those two functions, then through the same prototype-pollution refusalconfig-set's setter applies. It returns the checked choices as a new object and leaves its input as given. An object is read as a section whenever settings live under its key: a static section, the top level of a dynamic key family, or a setting that is also a parent (review.max_prompt_tokens_per_reviewer,agent_tools), whose entries are then checked one by one asconfig-setchecks each entry's own key.cmdConfigNewProjectparses and checks the choices first, then the existence check, then builds. WithdryRunit prints{ dry_run, would_create, path, config }and returns; secret keys are masked in that preview asconfig-setandconfig-getmask them.planNewProjectConfigbuilds the config and returns the user defaults file'sdepthtogranularityrewrite (L356-L363) as data, writing nothing.cmdConfigNewProjectpersists it only afterconfig.jsonis written, so a--dry-run, a refused payload or a failed write leaves the defaults file as it was.buildNewProjectConfigkeeps its other caller's contract (ensureConfigFile): plan, persist, return.gsd-core/bin/gsd-tools.cjs:routeConfigNewProjectgets a closed flag set,--dry-runand--raw, followingtodo complete(#4096). A flag is a dash or two followed by a letter; any other argument,-1included, is the choices JSON, which is the one positional argument wherever the flag sits.docs/CLI-TOOLS.md: the verb was not documented; it now is, with--dry-runand the three intended differences fromconfig-setbelow.Same defect class, folded in
Several inputs took the same unvalidated write path and are closed by the same walk:
'["mode"]','"yolo"')"0","1", …{"":{"mode":"yolo"}})""{"workflow.research":false}){"bogus":{}}, at any depth)config-set's own unknown-key error; under a real section ({"workflow":{}}) it sets nothing{"workflow":"x"})"0":"x")agent_tools), where the setting's own rule applies as onconfig-setnullchoice ({"mode":null})null(andship.pr_body_sections: nullrefused by the builder, whereconfig-setaccepts and clears it)__proto__,prototypeorconstructorit is refused (third difference below)The dotted-key rule is not a blanket ban, because one key family carries a dot inside an id:
phase_commit_docsis a map keyed by the literal phase id, and{"phase_commit_docs":{"2.1":true}}is the shape its resolver reads. So a dot is refused as structure unless one dynamic key family admits both the path cut at the dot and the whole path, which is what a dot inside a single id looks like to the schema."2.1"underphase_commit_docsis kept and written as given;"workflow.research"at the top level,"fallow.scope"undercode_quality,"max_prompt_tokens_per_reviewer.codex"underreviewand"blocks.workflow"underclaude_md_assemblyare refused. All five are pinned by test.Three intended differences from
config-setThe first two follow from the input being JSON rather than typed text. All three are documented in
docs/CLI-TOOLS.md:config-set workflow.research trueparses the texttrueto a boolean; the choice"research":"true"is a string and is refused. Pinned by test for two booleans and two numbers.review.max_prompt_tokens_per_reviewer,{"codex":5000}is accepted and{"a":1}is refused, whereconfig-set, given the whole object, stores it unchecked.agent_toolsis walked the same way, so an invalid selector is refused asconfig-set agent_tools.<selector>refuses it.nullchoice under__proto__,prototypeorconstructoris refused. For a key that is otherwise known ({"model_overrides":{"constructor":null}}), the prototype-pollution guard refuses it.config-set model_overrides.constructor nullreaches that guard only when the config has amodel_overridessection; without one its unset finds nothing to remove and exits 0. The default config hasagent_skillsand lacksmodel_overrides,agent_toolsandfeatures, so the difference can hold after creation.config-setis left as it is. Pinned by test foragent_skills, againstconfig-setrun on a default config.Census: the flag set
The route now enumerates its flags, so every flag that can reach it has to be in the set or be refused on purpose. The dispatcher consumes each global before routing; measured on this branch, each with
config-new-project '{}':--raw(either position)--cwd <p>,--cwd=<p><p>--pick <field>--pick created; an absent field exits 1 after creating, as on every verb)--project-dir <p>--ws <name>,--ws=<name>--json-errors,--exit-contract=<v>--default <v>--help,-h,--h,--usage--version,-vUnknown flag: …, unchanged--dry-run--force)Unknown flag for config-new-project: <flag>-1,-expected a JSON object,Invalid JSON)--json-errors(--json-errors --json-errors)Unknown flag for config-new-project: --json-errors, astodo completerefuses it--raw(--raw --raw)--rawis in the set--rawstays in the set for the reasontodo completegives, a guard against that splice ever moving, and because a repeated--rawreaches the route today.Out of scope, stated
config-setdoes not validate.model_profile,granularity,parallelizationandcommit_docsare in the workflow template, andconfig-setaccepts any value for them (config-set model_profile bogussucceeds), so an unfilled placeholder in one of those four still gets through both verbs. The issue asks for parity and leavesconfig-setbehavior out of scope. Because the rules are now one function, adding a rule for any of them fixes both verbs. The same holds foragent_tools, which has no value rule on either verb.--dry-run.config-ensure-section --dry-runandconfig-set-model-profile <p> --dry-runboth write. Same class, different verbs; not touched here.ship.pr_body_sectionscheck reads them. They are read only to build a new config, so a problem in them is not reported when the config already exists. The merge itself is also unchanged: a choice in a section outside the six deep-merged ones (review, say) still replaces that whole section from the defaults file, which predates this PR. Because the builder checksship.pr_body_sectionsafter the merge, anullchoice for it leaves an invalid value from the defaults file to be refused at creation, whereconfig-set ship.pr_body_sections nullon an existing config clears the key.config-setand a dot inside a phase id.config-set phase_commit_docs.2.1 truewrites{"phase_commit_docs":{"2":{"1":true}}}, a shape the resolver does not read;config-new-projectwrites the"2.1"key it does read. That isconfig-set's own path splitting, untouched here.platformWriteSynchelper can leave a partialconfig.json, and a retry then reportsalready_exists. The helper is byte-identical on the base; this PR does not touch it.Testing
How I verified the fix
A
describeblock intests/config.test.cjswith 95 tests, all but one through the CLI (the fault-injected config write runs in-process), and a property section at the end of the same file:config-setrefuses: unknown and misspelled keys,mode,context,context_window,workflow.*enums, booleans and ranges,git.create_tag,hooks.context_warning_threshold,plan_review.source_grounding_authority,statusline.state_format, andagent_skills.__proto__. Each test first runsconfig-setas a positive control, then assertsconfig-new-projectfails with the identical error string and that.planning/was not created.null, 3 rows.agent_skills.__proto__,.prototypeand.constructorset tonull:config-seton a default config refuses each (the positive control), andconfig-new-projectfails with the identical error and creates nothing.hooks.context_warning_threshold−1/0/1/99/100/101,workflow.security_asvs_level0–4,context_windowandworkflow.drift_threshold0/1/2), each row assertingconfig-set's verdict before its own; and the positional count at zero, one and two.config-setaccepts every leaf, a refusal carriesconfig-set's error for one of them, and each accepted leaf appears in the dry-run preview exactly asconfig-set --dry-runreports it; (b) one unknown leaf inserted anywhere refuses the payload and writes nothing. At the shared seed, 200 runs of (a) give 75 accepted and 125 refused; the test asserts both verdicts occur.nullchoices give the config a{}payload gives;agent_toolsselectors are checked; JSON-typed strings are refused whereconfig-setcoerces;--dry-run(before and after the choices) prints what a real run then writes, and with--rawprints the text formwould create (dry run), writing nothing either way; it masks secrets exactly asconfig-set --dry-rundoes, and refuses invalid choices; the user defaults file is migrated only by a creation that wrote its config (fault-injected write, dry run and refused payloads leave it byte-identical); an existing config does not hide a refused choice.workflows/new-project.mdandworkflows/new-project/steps/auto-mode-config.md, checked for a leftover placeholder, filled, and must be accepted with every chosen leaf written as chosen and every other key left at its default.Negative controls. The 44 tests of the PR's first commit (
e7179dff5), run against a build of the base: 40 fail, and the four that pass (the two template cases, the existing-config dry run and the decimal phase id) pin behavior that must not move. In the review round, I ran each new row against the build before its fix, where it failed, and reverted each fix alone at the final tree, which turned its named rows red. In the 2026-10-14 round, moving thenullskip above the segment guard turned the three forbidden-segment rows red and left the file's other 287 tests passing, and the file's other 193 tests passed on that head and on its base (433a43445). After the 2026-10-18 rebase onto755bf26f0, which replays every commit unchanged, the file passes 290 of 290 at this head, and every earlier commit of the PR passes it at its own tree.Run locally on this branch:
npm run lint:ci, then the unit, integration, security, install, slow and qa suites (theDetect test scopeselector reportsfull_matrix: truefor this diff).Regression test added?
Platforms tested
Locally on Linux. In this PR's CI, the macOS conformance leg runs
tests/config.test.cjs; the Windows legs runtests/config.platform.test.cjsinstead, so the new block is not exercised on Windows.Runtimes tested
Checklist
Fixes #NNN— PR will be auto-closed if missingconfirmed-buglabelnpm test).changeset/fragment added if this is a user-facing fix (npm run changeset -- --type Fixed --pr <NNN> --body "...") — orno-changeloglabel appliedBreaking changes
Inputs that used to be written are now refused. That is the fix, but a caller relying on the old leniency will see an error where it saw
created: true:{"workflow":{"research":"true"}});ship.pr_body_sectionswas already checked;phase_commit_docsis kept), empty objects under names that are not sections, and sections given a non-object (except a key that is itself a setting, such asagent_tools);agent_toolsselector, and aproject_codethat is not a string;--force, and a second positional argument.A
nullchoice now leaves its setting unchosen instead of writingnull, without an error, except under__proto__,prototypeorconstructor, where it is refused. A refused choice now fails even whenconfig.jsonalready exists, where it used to reportalready_exists.--dry-runno longer writes. Two accepted inputs now produce a different config:review.default_reviewersis written normalized, asconfig-setwrites it (["Codex","codex","gemini"]becomes["codex","gemini"]), and a section whose choices are allnullor empty no longer replaces that section from the user defaults with{}. The success output and the existing-config result for valid choices (created: false) are unchanged, and both shipped workflow templates produce the same config, pinned by test.