fix(#3726)!: require --confirm before milestone complete mutates (#3774)

* fix(#3726): require --confirm before milestone complete mutates

`milestone complete <version>` is a one-way door — ROADMAP.md and
REQUIREMENTS.md archived, every phase directory in the milestone MOVED,
STATE.md rewritten — and ran unconditionally on first invocation through
every invocation path, including `query milestone.complete <version>`,
whose `query` meta-prefix reads as a read-only namespace but performs no
filtering (#167's invocation-compatibility shim + #3243's dotted-form
normalization).

The gate lives on the destructive command itself, not on the `query`
prefix (the prefix is an intentional invocation mechanism, not a
permission boundary — restricting it would break dozens of shipped
workflow callers). Without --confirm and without --dry-run the command
now refuses via error() before reading anything beyond its arg checks,
so an unconfirmed invocation is a guaranteed no-op on disk. --dry-run
still previews with no confirmation needed and is now documented in the
usage block (it was only documented for the sibling archive-quick).
--force keeps its narrow meaning — bypassing the TRUNCATED-scope and
unstarted-phase guards — and does not double as the mutation opt-in.
--confirm follows the existing `phases clear --confirm` idiom in the
same module.

complete-milestone.md's two invocations pass --confirm (the workflow has
gathered explicit user intent by that step). Existing tests get
--confirm appended — pre-change behavior is exactly confirmed behavior —
and a #3726 regression block covers: refusal + full-tree byte-identity
on both invocation forms, --force not satisfying the gate, --dry-run
still passing without confirmation, and --confirm proceeding. The
refusal tests fail against pre-fix code (negative control run).

Fixes #3726

* docs(#3726): document the --confirm requirement in CLI-TOOLS and COMMANDS

Cross-AI review of the fix diff (codex, pre-create) caught three shipped
doc sites still instructing the now-refused bare invocation: the
CLI-TOOLS.md milestone-complete synopsis + flag table, and COMMANDS.md's
two guard-override instructions (`--force` alone now refuses without
--confirm). Localized CLI-TOOLS copies already lag the English synopsis
(no --force/--dry-run either) and follow the translation pipeline, not
this fix.

* chore(#3726): set changeset fragment pr to 3774

* test(#3726): confirm-gate CI repairs — QA scenario caller + growth ack

Two CI reds from the --confirm gate, both this branch's own misses:

- tests/qa/scenarios/milestone-rollover.json invoked `milestone complete
  1.0 --force` as a JSON arg-array fixture — a caller shape the test
  sweep (which grepped runGsdTools/runSdkQuery in tests/*.cjs) never
  enumerated. Adds --confirm; the scenario's boundary-crossing contract
  is otherwise untouched.
- complete-milestone.md's +420-byte --confirm note trips the
  emitted-attribution growth ratchet. Acknowledged as a #3726 append to
  the existing complete-milestone.md entry in
  3409-unreachable-guard-arms.json (two ack sources may never name the
  same path, per that fragment's own precedent).

Local: lint-emitted-drift-ack ok; loop-walk.qa 115/115 green sandboxed.

* docs(#3726): CLI-TOOLS.md guard-override sentences say --force --confirm

Review Major 1: the truncated-window and unstarted-phase guard paragraphs
still told the reader to "Pass `--force` to override", which now refuses
(--force alone does not satisfy the confirmation gate), while the flag
table 470 lines later said the opposite. Mirror the docs/COMMANDS.md pair
so the file no longer contradicts itself.

* docs(#3726): synopsis renders --confirm and --dry-run as alternatives

Review Nit 1: `milestone complete <version> --confirm [--dry-run]` read as
"a dry run still needs --confirm", the opposite of AC 3. Render the pair
as `(--confirm | --dry-run)` in the CLI-TOOLS.md synopsis and the usage
docblock, and let the flag rows carry the rule.

* test(#3726): pass --confirm in base-added milestone fixtures; re-file the growth ack

Rebase onto next (26 commits) surfaced three tests the gate now refuses:
the #3685 write-flag contract pair in tests/milestone.test.cjs and the
`milestone complete` boundary fixture in tests/state-contract.test.cjs
all invoke the command bare. Each now passes --confirm (a mutating run is
exactly what they assert on).

The +420 byte complete-milestone.md growth ack rode on
3409-unreachable-guard-arms.json, which #3078 swept from next as fully
spent — hence the modify/delete conflict. Re-filed under a fresh fragment
named for this issue, never resurrecting the swept one.

* test(#3726): pin the present-but-falsy arm of the confirmation gate

Review Minor 1: the boundary triple covered absent and present but not
present-but-falsy. The gate is an exact-token match, so --confirm=false
and --confirm=0 refuse today — pinned (canonical + query forms, whole
.planning/ tree byte-identical) so a future `=`-aware or prefix-matching
parser cannot silently turn --confirm=false into a confirmed run of an
irreversible command.

* test(#3726): drop --confirm from dry-run-only invocations

Review Nit 2: --confirm was mass-appended to 14 pre-existing --dry-run
invocations that never needed it, so each stopped standing as incidental
proof that a preview needs no confirmation. Reverted to the pre-PR form;
the dedicated AC-3 test carries the explicit assertion.

* docs(#3726): sync the localized CLI-TOOLS synopsis with the confirm gate

REQ-I18N-02 (docs/features/internationalized-documentation.md) requires
translations to stay synchronized with the English source. The four
localized CLI-TOOLS.md guides still advertised a bare
`milestone complete <version>`, which now exits 1. Render the English
synopsis verbatim — `(--confirm | --dry-run)` plus the `[--force]` and
`[--archive-quick]` flags the translations had also fallen behind on.

* test(#3726): drop --confirm from the remaining preview-only invocations

Round 2 reverted the --confirm appends on --dry-run-only invocations in
tests/milestone.test.cjs, but four more sat in two files the sweep missed:
tests/milestone-archive.test.cjs (three) and
tests/milestone-window-single-owner.test.cjs (one).

Each is a preview run whose whole purpose is to document that a preview
mutates nothing, so `--dry-run ... --confirm` contradicted the semantics
the test exists to pin. Dropping the token restores each as incidental
proof that a preview needs no confirmation; the dedicated AC-3 test keeps
the explicit assertion.

No assertion added, relaxed, or removed — the change is four tokens.

* chore(#3726): migrate the emitted-drift ack from a fragment to a commit trailer

#3954 (ADR-3942) moved emitted-drift acknowledgments out of
tests/emitted-drift-acks/ and into git commit trailers, and the fragment
directory no longer exists on next. The reason this PR's fragment carried
moves verbatim into the Emitted-Drift-Ack-Growth trailer on this commit;
the fragment file is removed rather than resurrected.

Emitted-Drift-Ack-Growth: complete-milestone.md — #3726: +420 bytes (40186 -> 40606). The archive_milestone step's two `milestone complete` invocations now pass the required --confirm flag (the command refuses to mutate without it — the archive is irreversible), with a note explaining the flag and pointing at --dry-run for previews. Deliberate runtime-loaded workflow text for the new gate, not converter drift.

* fix(#3726): name --confirm in the version-required refusal

The documented arg-discovery path (gsd-tools.cjs top-level usage: invoke
the command without args and the error lists what is required) stopped at
`version required for milestone complete (e.g., v1.0)` — one required
argument short. Discovering --confirm took a second round trip through the
gate. The refusal now reads `… — and --confirm to mutate`, pinned by a test
that also asserts the version-less invocation leaves .planning/ untouched.

* test(#3726): pin the milestone complete docs against a silent regression

The changeset is `type: Fixed`, which the docs-required lint exempts, so
nothing in CI would notice a later edit that reinstated the bare-`--force`
override prose or dropped `--confirm` from the synopsis. Four tests in
tests/milestone.test.cjs now pin: the synopsis line in docs/CLI-TOOLS.md
and its four localized mirrors; the `--confirm` flag row; both
guard-override instructions in docs/CLI-TOOLS.md and docs/COMMANDS.md,
by guard name (a substring match on each instruction's `--force
--confirm` text); and — as an identity ratchet over the
milestone-complete sections — every `--force` sentence or clause that
lacks `--confirm`, so a new bare instruction in its own sentence or
clause fails whatever its wording. Named residual: a bare instruction
spliced into the same clause as a compliant one coalesces with it and
passes the ratchet; the by-name pins are what keep the four known
instructions from losing the pairing that way. The file is registered
in scripts/docs-guard-registry.cjs so the pin runs on the PR that
changes those docs, not only after merge.

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
0xdhx
2026-08-29 16:00:45 -05:00
committed by GitHub
parent 370cfc6680
commit 472f585f7c
19 changed files with 400 additions and 87 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3774
---
**`milestone complete` now requires an explicit `--confirm` to mutate** — the command irreversibly archives ROADMAP.md/REQUIREMENTS.md, MOVES every phase directory in the milestone, and rewrites STATE.md, yet ran unconditionally on first invocation through every invocation path, including `query milestone.complete <version>`, whose `query` meta-prefix reads as a read-only namespace but performs no filtering. Without `--confirm` (and without `--dry-run`) the command now refuses before touching anything and names the flag that proceeds; `--dry-run` still previews the exact move list with no confirmation needed, and is now documented in the command's own usage block. `--force` keeps its narrow meaning (bypass the TRUNCATED-scope / unstarted-phase guards) and does not double as the mutation opt-in. The `/gsd-complete-milestone` workflow passes `--confirm` at its archive step. (#3726)

View File

@@ -393,8 +393,9 @@ elsewhere — phase scoping cannot be trusted, and the command now refuses
rather than falling back to an over-inclusive filter that would archive every
phase directory in the project. `unreadable` (no ROADMAP.md at all) and
`unscoped` (no section for this version) are pre-existing, legitimately
handled states and are not refused here. Pass `--force` to override, the same
affordance the unstarted-phase guard uses.
handled states and are not refused here. Pass `--force --confirm` to override, the same
affordance the unstarted-phase guard uses (`--confirm` is required for any mutating run —
#3726; `--force` alone does not imply it).
---
@@ -881,7 +882,7 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
```bash
# Archive milestone
node gsd-tools.cjs milestone complete <version> [--name <name>] [--no-archive-phases] [--force] [--dry-run] [--archive-quick]
node gsd-tools.cjs milestone complete <version> (--confirm | --dry-run) [--name <name>] [--no-archive-phases] [--force] [--archive-quick]
# Archive .planning/quick/* into milestones/<version>-quick/ WITHOUT the milestone complete close-out (#2142)
node gsd-tools.cjs milestone archive-quick <version> [--dry-run]
@@ -896,13 +897,14 @@ node gsd-tools.cjs requirements mark-complete <ids>
| Flag | Description |
|------|-------------|
| `<version>` | Milestone version label to archive (e.g. `v1.0`). |
| `--confirm` | **Required to mutate (#3726).** The archive is irreversible — ROADMAP.md/REQUIREMENTS.md archived, phase directories MOVED, STATE.md rewritten — so without this flag the command refuses and changes nothing (exit 1, with a message naming both `--confirm` and `--dry-run`). Not implied by `--force`, which only overrides the guards below. |
| `--name <name>` | Display name for the MILESTONES.md entry. Defaults to `<version>`. |
| `--no-archive-phases` | Leave phase directories in place instead of moving them into `.planning/milestones/<version>-phases/`. |
| `--archive-quick` | Opt-in (default OFF, #2142): also move every directory under `.planning/quick/` into `.planning/milestones/<version>-quick/`, (re)write that archive directory's `README.md` index, and clear STATE.md's `### Quick Tasks Completed` table rows. See "`milestone archive-quick`" below for the narrower standalone form and the full behavior. |
| `--force` | Override the unstarted-phase guard (see below). |
| `--dry-run` | Print the archive plan (roadmap, requirements, phases, and — when `--archive-quick` is also passed — quick-task dirs to move) without mutating anything. |
**Unstarted-phase guard.** Before archiving, the command scans the ROADMAP scoped for `<version>` and refuses if any `### Phase N:` heading in that slice has no matching phase directory on disk (`disk_status: no_directory`). Phase 0 (pre-milestone) and Phase 999 (backlog) sentinels are excluded. The guard runs whenever `--force` is absent, independent of `STATE.md`'s `milestone:` field — if that field is present but does not match `<version>`, a WARNING naming both values is emitted to stderr and the scan still runs (#2946). Pass `--force` to override.
**Unstarted-phase guard.** Before archiving, the command scans the ROADMAP scoped for `<version>` and refuses if any `### Phase N:` heading in that slice has no matching phase directory on disk (`disk_status: no_directory`). Phase 0 (pre-milestone) and Phase 999 (backlog) sentinels are excluded. The guard runs whenever `--force` is absent, independent of `STATE.md`'s `milestone:` field — if that field is present but does not match `<version>`, a WARNING naming both values is emitted to stderr and the scan still runs (#2946). Pass `--force --confirm` to override (`--confirm` is required for any mutating run — #3726; `--force` alone does not imply it).
**Sentinel directories are never archived.** The phase-directory move performed when `--no-archive-phases` is absent is now filtered through the same canonical sentinel predicate as `phases list` and `phases clear`: `999.*` (backlog) and `0-*` (pre-milestone) directories are left in place rather than moved into `.planning/milestones/<version>-phases/`. Previously this path was scoped only by the milestone window, with no sentinel filter, so a sentinel directory sitting inside the window could be archived along with the milestone's real phases.

View File

@@ -510,9 +510,9 @@ The marker never overwrites the artifact's own `status:` field for the eight fro
> **Note:** the `deferred-items.md` category is the per-phase SCOPE BOUNDARY log a phase agent writes when it finds a defect it should not fix. It is a different artifact from the `## Deferred Items` section `[A]` writes into `STATE.md`, which records what you acknowledged at close.
> **Truncated-window guard.** Archiving also refuses when the milestone's ROADMAP window is truncated — `Cannot mark milestone complete: the ROADMAP window for "<version>" is truncated`. This is the case where the milestone's heading is found but its section closes before reaching the roadmap's `### Phase N:` region (typically a closed-milestone heading sitting in between), which previously degraded to an over-inclusive filter and archived *every* phase directory in the project rather than the milestone's own. An unreadable ROADMAP.md or a version with no matching section at all are pre-existing, legitimately-handled states and are not refused here. Same override as below: `gsd-tools milestone complete <version> --force`. A window that is genuinely empty — a freshly-declared milestone with no phases yet — is *not* affected and still completes normally.
> **Truncated-window guard.** Archiving also refuses when the milestone's ROADMAP window is truncated — `Cannot mark milestone complete: the ROADMAP window for "<version>" is truncated`. This is the case where the milestone's heading is found but its section closes before reaching the roadmap's `### Phase N:` region (typically a closed-milestone heading sitting in between), which previously degraded to an over-inclusive filter and archived *every* phase directory in the project rather than the milestone's own. An unreadable ROADMAP.md or a version with no matching section at all are pre-existing, legitimately-handled states and are not refused here. Same override as below: `gsd-tools milestone complete <version> --force --confirm` (#3726: `--confirm` is required for any mutating run; `--force` alone does not imply it). A window that is genuinely empty — a freshly-declared milestone with no phases yet — is *not* affected and still completes normally.
> **Unstarted-phase guard.** Archiving refuses if the milestone's ROADMAP still lists a phase with no phase directory on disk — `Cannot mark milestone complete: ROADMAP lists N unstarted phase(s)`. If a phase was intentionally deferred or merged without a directory, run `gsd-tools milestone complete <version> --force` (the `/gsd-complete-milestone` workflow runs the underlying command without `--force`, so use the CLI directly to override). A `STATE.md` `milestone:` value that does not match `<version>` prints a WARNING and still runs the guard (#2946).
> **Unstarted-phase guard.** Archiving refuses if the milestone's ROADMAP still lists a phase with no phase directory on disk — `Cannot mark milestone complete: ROADMAP lists N unstarted phase(s)`. If a phase was intentionally deferred or merged without a directory, run `gsd-tools milestone complete <version> --force --confirm` (the `/gsd-complete-milestone` workflow runs the underlying command without `--force`, so use the CLI directly to override; `--confirm` is required for any mutating run — #3726). A `STATE.md` `milestone:` value that does not match `<version>` prints a WARNING and still runs the guard (#2946).
> **Sentinel directories stay put.** Moving phase directories into the archive (the default, unless `--no-archive-phases` is passed) now excludes `999.*` (backlog) and `0-*` (pre-milestone) directories via the same sentinel predicate the unstarted-phase guard already uses. Previously the archive move was scoped only by the milestone window, so a sentinel directory sitting inside that window could be archived along with the milestone's own phases.

View File

@@ -323,7 +323,7 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
```bash
# マイルストーンをアーカイブ
node gsd-tools.cjs milestone complete <version> [--name <name>] [--no-archive-phases]
node gsd-tools.cjs milestone complete <version> (--confirm | --dry-run) [--name <name>] [--no-archive-phases] [--force] [--archive-quick]
# 要件を完了としてマーク
node gsd-tools.cjs requirements mark-complete <ids>

View File

@@ -323,7 +323,7 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
```bash
# 마일스톤 아카이브
node gsd-tools.cjs milestone complete <version> [--name <name>] [--no-archive-phases]
node gsd-tools.cjs milestone complete <version> (--confirm | --dry-run) [--name <name>] [--no-archive-phases] [--force] [--archive-quick]
# 요구사항을 완료로 표시
node gsd-tools.cjs requirements mark-complete <ids>

View File

@@ -325,7 +325,7 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
```bash
# Arquiva milestone
node gsd-tools.cjs milestone complete <version> [--name <name>] [--no-archive-phases]
node gsd-tools.cjs milestone complete <version> (--confirm | --dry-run) [--name <name>] [--no-archive-phases] [--force] [--archive-quick]
# Marca requisitos como concluídos
node gsd-tools.cjs requirements mark-complete <ids>

View File

@@ -323,7 +323,7 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
```bash
# 归档里程碑
node gsd-tools.cjs milestone complete <version> [--name <name>] [--no-archive-phases]
node gsd-tools.cjs milestone complete <version> (--confirm | --dry-run) [--name <name>] [--no-archive-phases] [--force] [--archive-quick]
# 将需求标记为完成
node gsd-tools.cjs requirements mark-complete <ids>

View File

@@ -68,7 +68,12 @@
* gaps_found-only, never call on the pass path
*
* Milestone Operations:
* milestone complete <version> Archive milestone, create MILESTONES.md
* milestone complete <version> (--confirm | --dry-run)
* Archive milestone, create MILESTONES.md — one of the two is required
* --confirm REQUIRED to mutate (#3726): the archive is irreversible (ROADMAP/
* REQUIREMENTS archived, phase dirs MOVED, STATE.md rewritten), so
* without this flag the command refuses and mutates nothing
* --dry-run Preview what would move, mutates nothing (no --confirm needed; #2118)
* [--name <name>]
* [--no-archive-phases] Skip moving phase dirs to milestones/vX.Y-phases/ (archived by default)
* [--archive-quick] Move .planning/quick/* dirs to milestones/vX.Y-quick/ + reset the
@@ -2521,7 +2526,11 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
// --no-archive-phases' inverted shape, absence of this flag means
// "do nothing" rather than "skip a default-on behavior".
const archiveQuick = args.includes('--archive-quick');
milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force, dryRun, archiveQuick }, raw);
// #3726: explicit mutation opt-in — without --confirm (and without
// --dry-run) the command refuses before touching anything. Distinct
// from --force, which bypasses the narrow scope guards only.
const confirm = args.includes('--confirm');
milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force, dryRun, archiveQuick, confirm }, raw);
} else if (subcommand === 'archive-quick') {
// #2142 escalation: narrow archival-only entry point (does NOT
// touch ROADMAP/REQUIREMENTS/MILESTONES.md, runs no completion

View File

@@ -520,9 +520,14 @@ If "Yes": set `ARCHIVE_QUICK_FLAG="--archive-quick"`. If "Skip" (or `.planning/q
**Delegate archival to `gsd_run query milestone.complete`:**
```bash
ARCHIVE=$(gsd_run query milestone.complete "v[X.Y]" --name "[Milestone Name]" $ARCHIVE_QUICK_FLAG)
ARCHIVE=$(gsd_run query milestone.complete "v[X.Y]" --name "[Milestone Name]" --confirm $ARCHIVE_QUICK_FLAG)
```
`--confirm` is required (#3726): the archive is irreversible (ROADMAP/REQUIREMENTS archived, phase
directories MOVED, STATE.md rewritten), so `milestone complete` refuses to mutate without it. This
workflow has already gathered the user's explicit intent by this step, so passing the flag here is
correct; `--dry-run` previews the exact move list without mutating if a preview is ever needed first.
The CLI handles:
- Creating `.planning/milestones/` directory
- Archiving ROADMAP.md to `milestones/v[X.Y]-ROADMAP.md`
@@ -545,7 +550,7 @@ Verify after `--archive-quick` was passed: `✅ Quick tasks archived to .plannin
If the user explicitly wants to keep phase directories in place as raw execution history, invoke `milestone complete` with `--no-archive-phases`:
```bash
gsd_run query milestone complete v[X.Y] --no-archive-phases
gsd_run query milestone complete v[X.Y] --no-archive-phases --confirm
```
Verify after a default (archived) completion: `✅ Phase directories archived to .planning/milestones/v[X.Y]-phases/`

View File

@@ -286,6 +286,18 @@ const DOCS_GUARD_TESTS = {
'tests/inventory-manifest-sync.test.cjs': ['docs/INVENTORY.md', 'docs/INVENTORY-MANIFEST.json'],
'tests/kilo-upgrades.test.cjs': ['docs/how-to/connect-gsd-mcp-server.md'],
'tests/live-config-guard.test.cjs': ['docs/TESTING-SUITES.md'],
// #3726: pins the `milestone complete` synopsis (English + four localized
// mirrors), the `--confirm` flag row and the guard-override instructions —
// `type: Fixed` exempts that PR from the docs-required lint, so this is the
// only gate on that prose.
'tests/milestone.test.cjs': [
'docs/CLI-TOOLS.md',
'docs/COMMANDS.md',
'docs/ja-JP/CLI-TOOLS.md',
'docs/ko-KR/CLI-TOOLS.md',
'docs/pt-BR/CLI-TOOLS.md',
'docs/zh-CN/CLI-TOOLS.md',
],
'tests/model-catalog-runtime-defaults.test.cjs': ['docs/CONFIGURATION.md'],
// Scans every git-tracked file in the whole repo via `git ls-files`
// (no-pending-3212-markers.test.cjs:37-46), which includes all of docs/ —

View File

@@ -82,6 +82,16 @@ interface MilestoneCompleteOptions {
force?: boolean;
archivePhases?: boolean;
dryRun?: boolean;
// #3726: explicit mutation opt-in. `milestone complete` is a one-way door
// (ROADMAP/REQUIREMENTS archived, every phase directory in the milestone
// MOVED, STATE.md rewritten) that used to run unconditionally on first
// invocation — including through the `query` meta-prefix, whose name reads
// as a read. Without --confirm (and without --dry-run) the command now
// refuses before any mutation. Deliberately NOT folded into `force`:
// --force bypasses the narrow TRUNCATED-scope and unstarted-phase guards
// and must keep exactly that meaning (#3726 AC 4) — intent-to-mutate and
// intent-to-override-a-guard are different declarations.
confirm?: boolean;
// #2142: opt-in quick-task archival. Default OFF (unlike archivePhases,
// which is default-ON since #1871) — acceptance criterion 1 is explicit
// that Skip/absent must preserve today's behavior. Do NOT mirror
@@ -554,7 +564,7 @@ function applyQuickTasksReset(content: string): { content: string; warning: { fi
function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCompleteOptions, raw: boolean): void {
if (!version) {
error('version required for milestone complete (e.g., v1.0)');
error('version required for milestone complete (e.g., v1.0) — and --confirm to mutate');
}
// #2288 security: `version` is a CLI positional that is interpolated into
// multiple filesystem sinks below — `path.join(archiveDir, `${version}-ROADMAP.md`)`,
@@ -566,6 +576,26 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
error(`milestone complete: version "${version}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
}
// #3726: confirmation gate — refuse before ANY read of the tree beyond the
// arg checks above, so an unconfirmed invocation is a guaranteed no-op on
// disk. The threat model is the NEVER_VALID_FLAGS one (gsd-tools.cjs): a
// caller supplies a token it believes is inert and a destructive operation
// proceeds unchecked — here the caller-side belief was that the `query`
// meta-prefix implies a read, and `query milestone.complete <v>` archived
// the milestone with no confirmation. The prefix is an intentional
// invocation-compatibility mechanism, not a permission boundary, so the
// gate lives on the destructive command itself and covers every invocation
// path. --dry-run needs no confirmation (it mutates nothing and is the
// recommended first step); --force does NOT imply it (see
// MilestoneCompleteOptions.confirm).
if (!options.dryRun && !options.confirm) {
error(
`milestone complete is irreversible: it archives ROADMAP.md and REQUIREMENTS.md, MOVES every phase ` +
`directory for ${version} into .planning/milestones/, and rewrites STATE.md. ` +
`Nothing has been changed. Re-run with --confirm to proceed, or --dry-run to preview exactly what would move.`,
);
}
const roadmapPath = planningPaths(cwd).roadmap;
const reqPath = planningPaths(cwd).requirements;
const statePath = planningPaths(cwd).state;

View File

@@ -49,7 +49,7 @@ describe('bug #2684: milestone.complete forwards version to phases.archive', ()
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const result = runSdkQuery(['milestone.complete', 'v1.0'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete should succeed, got error: ${result.error}`);
assert.ok(
!result.error || !result.error.includes('version required'),
@@ -67,7 +67,7 @@ describe('bug #2684: milestone.complete forwards version to phases.archive', ()
// the guard, so give Phase 1 a real directory so the scan is satisfied.
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const result = runSdkQuery(['milestone.complete', 'v2.5'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v2.5', '--confirm'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
assert.strictEqual(result.data.version, 'v2.5');
});
@@ -82,7 +82,7 @@ describe('bug #2684: milestone.complete forwards version to phases.archive', ()
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-phases'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-phases', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete --archive-phases failed: ${result.error}`);
assert.strictEqual(result.data.version, 'v1.0');
assert.ok(result.data.archived.phases === true, 'phases should be archived');
@@ -580,7 +580,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
});
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), quickTasksStateWithRows(1));
const result = runSdkQuery(['milestone.complete', 'v1.0'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
assert.strictEqual(result.data.archived.quick, false, 'archived.quick must be false when --archive-quick is absent');
assert.ok(fs.existsSync(quickDir), 'quick task directory must remain in place');
@@ -601,7 +601,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
for (const name of names) writeQuickTaskDir(tmpDir, name);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), quickTasksStateWithRows(3));
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete --archive-quick failed: ${result.error}`);
assert.strictEqual(result.data.archived.quick, true);
@@ -631,7 +631,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
test('noOpsWhenQuickDirectoryAbsent', () => {
setupQuickArchiveRoadmap(tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
assert.strictEqual(result.data.archived.quick, false);
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick')));
@@ -641,7 +641,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
setupQuickArchiveRoadmap(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'quick'), { recursive: true });
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
assert.strictEqual(result.data.archived.quick, false, 'boundary 0: an empty quick dir must not count as archived');
assert.ok(
@@ -654,7 +654,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
setupQuickArchiveRoadmap(tmpDir);
writeQuickTaskDir(tmpDir, '2026-02-01-only-one');
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
assert.strictEqual(result.data.archived.quick, true, 'boundary 1: a single quick task dir must archive');
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick', '2026-02-01-only-one')));
@@ -665,7 +665,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
writeQuickTaskDir(tmpDir, '2026-02-01-first');
writeQuickTaskDir(tmpDir, '2026-02-02-second');
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
assert.strictEqual(result.data.archived.quick, true, 'boundary 2: multiple quick task dirs must archive');
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick');
@@ -678,7 +678,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
writeQuickTaskDir(tmpDir, '2026-03-01-no-section');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# STATE\n\n### Blockers/Concerns\nNone\n');
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete must succeed even without a Quick Tasks Completed section: ${result.error}`);
assert.strictEqual(result.data.archived.quick, true);
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick', '2026-03-01-no-section')));
@@ -709,7 +709,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), nonCanonicalState);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete must succeed even when the reset is refused: ${result.error}`);
// The quick directories still move — only the STATE.md table reset is refused.
assert.strictEqual(result.data.archived.quick, true);
@@ -741,7 +741,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
fs.mkdirSync(path.join(archiveDir, name), { recursive: true });
fs.writeFileSync(path.join(archiveDir, name, 'existing-marker.txt'), 'prior run\n');
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
assert.ok(fs.existsSync(path.join(archiveDir, name, 'existing-marker.txt')), 'prior archive entry must survive');
assert.strictEqual(
@@ -761,7 +761,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
const name = '2026-05-01-per-task-summary';
writeQuickTaskDir(tmpDir, name, { [`${name}-SUMMARY.md`]: '# Summary\nDid the thing.\n' });
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick');
assert.ok(fs.statSync(path.join(archiveDir, 'README.md')).isFile(), 'README.md index must be generated');
@@ -788,7 +788,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
const name = '2026-05-02-bare-summary';
writeQuickTaskDir(tmpDir, name, { 'SUMMARY.md': '# Summary\nDid the other thing.\n' });
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick');
assert.ok(fs.statSync(path.join(archiveDir, 'README.md')).isFile(), 'README.md index must be generated');
@@ -807,7 +807,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
const name = '2026-05-03-no-summary';
writeQuickTaskDir(tmpDir, name); // no files at all
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick');
assert.ok(fs.statSync(path.join(archiveDir, 'README.md')).isFile(), 'README.md index must be generated');
@@ -823,7 +823,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'quick', 'stray-notes.txt'), 'not a task dir\n');
writeQuickTaskDir(tmpDir, '2026-06-01-real-task');
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'quick', 'stray-notes.txt')),
@@ -864,7 +864,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
setupQuickArchiveRoadmap(tmpDir);
writeQuickTaskDir(tmpDir, '2026-08-01-evil-version');
const result = runSdkQuery(['milestone.complete', '../evil', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', '../evil', '--archive-quick', '--confirm'], tmpDir);
assert.strictEqual(result.success, false, 'a version containing a path separator must be rejected');
assert.ok(!fs.existsSync(path.join(tmpDir, '..', 'evil')), 'nothing must be created outside the temp fixture root');
const milestonesDir = path.join(tmpDir, '.planning', 'milestones');
@@ -884,7 +884,7 @@ describe('#2142: quick task archival at milestone close-out', () => {
const name = '2026-01-01-café report';
writeQuickTaskDir(tmpDir, name);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick', name)),
@@ -924,7 +924,7 @@ describe('#2142 escalation: milestone.archive-quick — narrow archival entry po
fs.writeFileSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-ROADMAP.md'), archivedRoadmap);
// Confirm the premise this command exists to fix.
const milestoneCompleteResult = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const milestoneCompleteResult = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.strictEqual(
milestoneCompleteResult.success,
false,
@@ -1078,7 +1078,7 @@ describe('#2142 review: README injection, symlink escape, dry-run/real-run parit
throw err;
}
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick');
@@ -1121,7 +1121,7 @@ describe('#2142 review: README injection, symlink escape, dry-run/real-run parit
try {
writeQuickTaskDir(tmpDir, '2026-10-02-real-task');
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
assert.strictEqual(result.data.archived.quick, true, 'the real task dir must still archive');
@@ -1182,7 +1182,7 @@ describe('#2142 review: README injection, symlink escape, dry-run/real-run parit
'the skipped symlink entry must not appear in the dry-run preview',
);
const real = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
const real = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick', '--confirm'], tmpDir);
assert.ok(real.success, `real run failed: ${real.error}`);
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick');
const archivedNames = fs
@@ -1252,7 +1252,7 @@ describe('#3597: milestone complete refuses to archive on a non-COMPLETE window
test('archives NOTHING and leaves every phase dir on disk when the workstream has no ROADMAP.md', () => {
const alphaPhases = seedUnreadableWorkstream(tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete should still succeed (UNREADABLE is not a whole-command refusal): ${result.error}`);
assert.strictEqual(result.data.archived.phases, false, 'phases must NOT be reported as archived');
@@ -1311,7 +1311,7 @@ describe('#3597: milestone complete refuses to archive on a non-COMPLETE window
fs.mkdirSync(path.join(betaPhases, '01-foo'), { recursive: true });
fs.mkdirSync(path.join(betaPhases, '02-out-of-window'), { recursive: true });
const result = runSdkQuery(['milestone.complete', 'v1.0'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete should succeed: ${result.error}`);
assert.strictEqual(result.data.archived.phases, true, 'phases must be reported as archived');
@@ -1346,7 +1346,7 @@ describe('#3597: milestone complete refuses to archive on a non-COMPLETE window
fs.mkdirSync(path.join(phasesDir, '01-parser'), { recursive: true });
fs.mkdirSync(path.join(phasesDir, '02-printable-output'), { recursive: true });
const result = runSdkQuery(['milestone.complete', 'v1.0', '--force'], tmpDir);
const result = runSdkQuery(['milestone.complete', 'v1.0', '--force', '--confirm'], tmpDir);
assert.ok(result.success, `milestone.complete should succeed: ${result.error}`);
assert.strictEqual(result.data.archived.phases, true, 'phases must still be archived for an UNSCOPED (not UNREADABLE) window');

View File

@@ -1046,7 +1046,7 @@ test('milestone complete refuses to archive a truncated window', (t) => {
t.after(() => cleanup(cwd));
buildTruncatedFixture(cwd);
const result = runGsdTools(['milestone', 'complete', 'v3.0', '--cwd', cwd, '--raw'], cwd);
const result = runGsdTools(['milestone', 'complete', 'v3.0', '--cwd', cwd, '--raw', '--confirm'], cwd);
assert.strictEqual(result.success, false);
assert.notStrictEqual(result.exitCode, 0);
});
@@ -1057,7 +1057,7 @@ test('refusal leaves the phases directory untouched', (t) => {
buildTruncatedFixture(cwd);
const before = fs.readdirSync(path.join(cwd, '.planning', 'phases')).sort();
const result = runGsdTools(['milestone', 'complete', 'v3.0', '--cwd', cwd, '--raw'], cwd);
const result = runGsdTools(['milestone', 'complete', 'v3.0', '--cwd', cwd, '--raw', '--confirm'], cwd);
assert.strictEqual(result.success, false);
const after = fs.readdirSync(path.join(cwd, '.planning', 'phases')).sort();
assert.deepStrictEqual(after, before);
@@ -1068,7 +1068,7 @@ test('--force overrides the truncation refusal', (t) => {
t.after(() => cleanup(cwd));
buildTruncatedFixture(cwd);
const result = runGsdTools(['milestone', 'complete', 'v3.0', '--force', '--cwd', cwd, '--raw'], cwd);
const result = runGsdTools(['milestone', 'complete', 'v3.0', '--force', '--cwd', cwd, '--raw', '--confirm'], cwd);
assert.strictEqual(result.success, true, result.error);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.archived.phases, true);
@@ -1083,7 +1083,7 @@ test('genuinely empty milestone still completes', (t) => {
const filter = getMilestonePhaseFilter(cwd, 'v1.0');
assert.strictEqual(filter.scope, SCOPE.COMPLETE);
const result = runGsdTools(['milestone', 'complete', 'v1.0', '--cwd', cwd, '--raw'], cwd);
const result = runGsdTools(['milestone', 'complete', 'v1.0', '--cwd', cwd, '--raw', '--confirm'], cwd);
assert.strictEqual(result.success, true, result.error);
});

View File

@@ -68,7 +68,7 @@ describe('milestone complete command', () => {
// No ROADMAP.md — mirrors 'handles missing ROADMAP.md gracefully' so the
// milestone-phase-filter guard never fires.
const result = runGsdTools('milestone complete v0.5 --name Test', tmpDir);
const result = runGsdTools('milestone complete v0.5 --name Test --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
@@ -91,7 +91,7 @@ describe('milestone complete command', () => {
writeState(tmpDir);
mkPhaseDir(tmpDir, '01-foundation', { oneLiner: 'Set up project infrastructure' });
const result = runGsdTools('milestone complete v1.0 --name MVP Foundation', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name MVP Foundation --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
@@ -249,7 +249,7 @@ describe('milestone complete command', () => {
writeRoadmap(tmpDir, `# Roadmap v1.0\n`);
writeState(tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Beta', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Beta --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8');
@@ -266,9 +266,9 @@ describe('milestone complete command', () => {
writeRoadmap(tmpDir, `# Roadmap v1.1\n`);
writeState(tmpDir);
assert.ok(runGsdTools('milestone complete v1.1 --name Second', tmpDir).success);
assert.ok(runGsdTools('milestone complete v1.1 --name Second --confirm', tmpDir).success);
writeRoadmap(tmpDir, `# Roadmap v1.2\n`);
assert.ok(runGsdTools('milestone complete v1.2 --name Third', tmpDir).success);
assert.ok(runGsdTools('milestone complete v1.2 --name Third --confirm', tmpDir).success);
const m = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8');
const [i10, i11, i12] = ['v1.0 First', 'v1.1 Second', 'v1.2 Third'].map(s => m.indexOf(s));
@@ -282,7 +282,7 @@ describe('milestone complete command', () => {
writeState(tmpDir);
mkPhaseDir(tmpDir, '01-foundation', { oneLiner: 'Set up project infrastructure' });
const result = runGsdTools('milestone complete v1.0 --name MVP --archive-phases', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name MVP --archive-phases --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
@@ -299,7 +299,7 @@ describe('milestone complete command', () => {
writeRoadmap(tmpDir, `# Roadmap v1.0\n`);
writeState(tmpDir);
assert.ok(runGsdTools('milestone complete v1.0 --name MVP', tmpDir).success);
assert.ok(runGsdTools('milestone complete v1.0 --name MVP --confirm', tmpDir).success);
const archivedReq = fs.readFileSync(
path.join(tmpDir, '.planning', 'milestones', 'v1.0-REQUIREMENTS.md'), 'utf-8',
@@ -315,7 +315,7 @@ describe('milestone complete command', () => {
writeRoadmap(tmpDir, `# Roadmap v1.0\n`);
writeState(tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Test --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
@@ -333,7 +333,7 @@ describe('milestone complete command', () => {
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n\n## Current Position\n\nPhase: 03 — EXECUTING\nPlan: 03-02\nStatus: Executing\nLast activity: 2025-01-01 — Running phase\n\n## Operator Next Steps\n\n- Re-run /gsd:complete-milestone v1.0\n`,
);
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Test --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
@@ -347,7 +347,7 @@ describe('milestone complete command', () => {
writeRoadmap(tmpDir, `# Roadmap v1.0\n`);
writeState(tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Test --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
@@ -360,7 +360,7 @@ describe('milestone complete command', () => {
test('handles missing ROADMAP.md gracefully', () => {
writeState(tmpDir);
const result = runGsdTools('milestone complete v1.0 --name NoRoadmap', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name NoRoadmap --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
@@ -387,7 +387,7 @@ describe('milestone complete command', () => {
fs.writeFileSync(path.join(p4, '04-02-PLAN.md'), '# Plan 2\n');
fs.writeFileSync(path.join(p4, '04-01-SUMMARY.md'), '---\none-liner: Polished UI\n---\n# Summary\n');
const result = runGsdTools('milestone complete v1.1 --name "Second Release"', tmpDir);
const result = runGsdTools('milestone complete v1.1 --name "Second Release" --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
@@ -407,7 +407,7 @@ describe('milestone complete command', () => {
mkPhaseDir(tmpDir, '01-old', { plan: true });
mkPhaseDir(tmpDir, '02-current', { plan: true });
assert.ok(runGsdTools('milestone complete v1.1 --name Test --archive-phases', tmpDir).success);
assert.ok(runGsdTools('milestone complete v1.1 --name Test --archive-phases --confirm', tmpDir).success);
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.1-phases', '02-current')));
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01-old')));
@@ -421,7 +421,7 @@ describe('milestone complete command', () => {
mkPhaseDir(tmpDir, '01-foundation', { plan: true, oneLiner: 'Foundation work' });
mkPhaseDir(tmpDir, '10-scaling', { plan: true, oneLiner: 'Scaling work' });
const result = runGsdTools('milestone complete v1.0 --name MVP', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name MVP --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
@@ -439,7 +439,7 @@ describe('milestone complete command', () => {
fs.mkdirSync(misc, { recursive: true });
fs.writeFileSync(path.join(misc, 'PLAN.md'), '# Not a phase\n');
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Test --confirm', tmpDir);
assert.ok(result.success);
const output = JSON.parse(result.output);
@@ -456,7 +456,7 @@ describe('milestone complete command', () => {
mkPhaseDir(tmpDir, '457-integration', { plan: true });
mkPhaseDir(tmpDir, '45-old', { plan: true });
const result = runGsdTools('milestone complete v1.49 --name DACP', tmpDir);
const result = runGsdTools('milestone complete v1.49 --name DACP --confirm', tmpDir);
assert.ok(result.success);
assert.strictEqual(JSON.parse(result.output).phases, 2);
});
@@ -471,7 +471,7 @@ describe('milestone complete command', () => {
`---\none-liner: Built the foundation\n---\n\n# Phase 1: Foundation Summary\n\n**Built the foundation**\n\n## Performance\n\n- **Duration:** 28 min\n- **Tasks:** 7\n- **Files modified:** 12\n`,
);
const result = runGsdTools('milestone complete v1.0 --name MVP', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name MVP --confirm', tmpDir);
assert.ok(result.success);
assert.strictEqual(JSON.parse(result.output).tasks, 7);
});
@@ -486,7 +486,7 @@ describe('milestone complete command', () => {
`---\nphase: "01"\n---\n\n# Phase 1: Foundation Summary\n\n**JWT auth with refresh rotation using jose library**\n\n## Performance\n`,
);
const result = runGsdTools('milestone complete v1.0 --name MVP', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name MVP --confirm', tmpDir);
assert.ok(result.success);
assert.ok(JSON.parse(result.output).accomplishments.includes('JWT auth with refresh rotation using jose library'));
});
@@ -498,7 +498,7 @@ describe('milestone complete command', () => {
`# State\n\nStatus: In progress\nLast Activity: 2025-01-01\nLast Activity Description: Working\n`,
);
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Test --confirm', tmpDir);
assert.ok(result.success);
assert.ok(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8').includes('v1.0 milestone complete'));
});
@@ -507,7 +507,7 @@ describe('milestone complete command', () => {
writeRoadmap(tmpDir, `# Roadmap v1.0\n`);
writeState(tmpDir);
const result = runGsdTools('milestone complete v1.0 --name EmptyPhases', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name EmptyPhases --confirm', tmpDir);
assert.ok(result.success);
const output = JSON.parse(result.output);
@@ -534,7 +534,7 @@ describe('milestone complete command', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const stateBefore = fs.readFileSync(statePath, 'utf-8');
const result = runGsdTools(['milestone', 'complete', 'v1.0', '--name', 'Test'], tmpDir, PINNED_CLOCK_ENV);
const result = runGsdTools(['milestone', 'complete', 'v1.0', '--name', 'Test', '--confirm'], tmpDir, PINNED_CLOCK_ENV);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
@@ -549,7 +549,7 @@ describe('milestone complete command', () => {
writeState(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const run1 = runGsdTools(['milestone', 'complete', 'v1.0', '--name', 'Test', '--force'], tmpDir, PINNED_CLOCK_ENV);
const run1 = runGsdTools(['milestone', 'complete', 'v1.0', '--name', 'Test', '--force', '--confirm'], tmpDir, PINNED_CLOCK_ENV);
assert.ok(run1.success, `first milestone complete failed: ${run1.error}`);
const stateAfter1 = fs.readFileSync(statePath, 'utf-8');
@@ -558,7 +558,7 @@ describe('milestone complete command', () => {
// genuine no-op for STATE.md content, even though MILESTONES.md still
// gains a new (duplicate-looking) entry each call — the two flags are
// independent and must not be conflated.
const run2 = runGsdTools(['milestone', 'complete', 'v1.0', '--name', 'Test', '--force'], tmpDir, PINNED_CLOCK_ENV);
const run2 = runGsdTools(['milestone', 'complete', 'v1.0', '--name', 'Test', '--force', '--confirm'], tmpDir, PINNED_CLOCK_ENV);
assert.ok(run2.success, `second milestone complete failed: ${run2.error}`);
const stateAfter2 = fs.readFileSync(statePath, 'utf-8');
@@ -622,7 +622,7 @@ describe('ADR-3408 §8.3 Matrix B: cmdMilestoneComplete preserves + warns (#3469
test('B1: stale body Stopped at does not clobber a fresher curated frontmatter stopped_at', () => {
writeStateWithSession(tmpDir, { fmStoppedAt: 'Phase 7 verified PASS', sessionStoppedAt: 'Phase 3 work' });
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Test --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
@@ -640,7 +640,7 @@ describe('ADR-3408 §8.3 Matrix B: cmdMilestoneComplete preserves + warns (#3469
test('B2: the preserved frontmatter value is observable via a separate `state get` call', () => {
writeStateWithSession(tmpDir, { fmStoppedAt: 'Phase 7 verified PASS', sessionStoppedAt: 'Phase 3 work' });
const complete = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
const complete = runGsdTools('milestone complete v1.0 --name Test --confirm', tmpDir);
assert.ok(complete.success, `Command failed: ${complete.error}`);
const got = runGsdTools('state get stopped_at', tmpDir);
@@ -659,7 +659,7 @@ describe('ADR-3408 §8.3 Matrix B: cmdMilestoneComplete preserves + warns (#3469
test('B3: a preserved divergence emits preservation_warnings[0].field === "stopped_at"', () => {
writeStateWithSession(tmpDir, { fmStoppedAt: 'Phase 7 verified PASS', sessionStoppedAt: 'Phase 3 work' });
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Test --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
@@ -682,7 +682,7 @@ describe('ADR-3408 §8.3 Matrix B: cmdMilestoneComplete preserves + warns (#3469
// derived result.
writeStateWithSession(tmpDir, { fmStatus: 'executing', fmStoppedAt: undefined, sessionStoppedAt: undefined });
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Test --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
@@ -705,7 +705,7 @@ describe('ADR-3408 §8.3 Matrix B: cmdMilestoneComplete preserves + warns (#3469
mkPhaseDir(tmpDir, '01-foundation', { oneLiner: 'Setup' });
writeState(tmpDir); // no frontmatter at all — nothing curated to diverge from
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Test --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
@@ -721,7 +721,7 @@ describe('ADR-3408 §8.3 Matrix B: cmdMilestoneComplete preserves + warns (#3469
test('B3 (#3471 regression pin): preservation_warnings shape and content unchanged by Phase 4', () => {
writeStateWithSession(tmpDir, { fmStoppedAt: 'Phase 7 verified PASS', sessionStoppedAt: 'Phase 3 work' });
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name Test --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
@@ -1739,7 +1739,7 @@ describe('milestone complete explicit version scope (#3043)', () => {
fs.writeFileSync(path.join(p, 'SUMMARY.md'), `one-liner: ${liner}\n\n## Summary\n${liner.split(' ')[0]}\n`);
}
const result = runGsdTools(['milestone', 'complete', 'v3.6', '--raw'], tmpDir);
const result = runGsdTools(['milestone', 'complete', 'v3.6', '--raw', '--confirm'], tmpDir);
assert.equal(result.success, true, result.error || result.output);
const payload = JSON.parse(result.output);
assert.equal(payload.version, 'v3.6');
@@ -1757,7 +1757,7 @@ describe('milestone complete explicit version scope (#3043)', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const result = runGsdTools(['milestone', 'complete', 'v9.9', '--raw'], tmpDir);
const result = runGsdTools(['milestone', 'complete', 'v9.9', '--raw', '--confirm'], tmpDir);
assert.equal(result.success, false, 'expected command to fail when no phases match explicit version');
assert.match(result.error || '', /no phases|phase/i);
} finally {
@@ -1789,7 +1789,7 @@ describe('#1911 — milestone complete --ws archives to the workstream', () => {
// Root milestones dir pre-exists; it must NOT receive the workstream archive.
fs.mkdirSync(path.join(tmpDir, '.planning', 'milestones'), { recursive: true });
const result = runGsdTools('milestone complete v2.0 --ws ws1 --force', tmpDir);
const result = runGsdTools('milestone complete v2.0 --ws ws1 --force --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
// Archive lands inside the workstream.
@@ -1820,7 +1820,7 @@ describe('#1911 — milestone complete --ws archives to the workstream', () => {
fs.writeFileSync(path.join(wsBase, 'phases', '01-foo', '01-SUMMARY.md'), '---\none-liner: foo done\n---\n# Summary\n');
fs.mkdirSync(path.join(tmpDir, '.planning', 'milestones'), { recursive: true });
const result = runGsdTools('milestone complete v2.0 --ws ws1 --force', tmpDir);
const result = runGsdTools('milestone complete v2.0 --ws ws1 --force --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const archivedReq = fs.readFileSync(
@@ -1858,7 +1858,7 @@ describe('#1871 — milestone complete archives phase dirs by default', () => {
test('archives phase dirs by default (no --archive-phases flag needed)', () => {
seedCompletableMilestone();
const result = runGsdTools('milestone complete v1.0 --name MVP', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name MVP --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(out.archived.phases, 'phases should be archived by default');
@@ -1871,7 +1871,7 @@ describe('#1871 — milestone complete archives phase dirs by default', () => {
test('--no-archive-phases opts out of default archiving', () => {
seedCompletableMilestone();
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
const result = runGsdTools('milestone complete v1.0 --name MVP --no-archive-phases', tmpDir);
const result = runGsdTools('milestone complete v1.0 --name MVP --no-archive-phases --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(!out.archived.phases, 'phases should NOT be archived with --no-archive-phases');
@@ -1945,7 +1945,7 @@ describe('bug-978: milestone complete --force overrides unstarted-phase guard',
makeGuardFixture(tmpDir, 'v1.0');
const result = runGsdTools(
['milestone', 'complete', 'v1.0', '--name', 'Regression Test'],
['milestone', 'complete', 'v1.0', '--name', 'Regression Test', '--confirm'],
tmpDir,
);
@@ -1960,7 +1960,7 @@ describe('bug-978: milestone complete --force overrides unstarted-phase guard',
makeGuardFixture(tmpDir, 'v1.0');
const result = runGsdTools(
['milestone', 'complete', 'v1.0', '--name', 'Regression Test', '--force'],
['milestone', 'complete', 'v1.0', '--name', 'Regression Test', '--force', '--confirm'],
tmpDir,
);
@@ -2482,3 +2482,250 @@ describe('enhancement #72 — Business Context template section', () => {
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// #3726: milestone complete requires --confirm before mutating
// ─────────────────────────────────────────────────────────────────────────────
describe('#3726: milestone complete refuses to mutate without --confirm', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
function seedMutableMilestone() {
writeRoadmap(tmpDir, `# Roadmap v1.0 MVP\n\n### Phase 1: Foundation\n**Goal:** Setup\n`);
writeState(tmpDir);
mkPhaseDir(tmpDir, '01-foundation', { plan: true, oneLiner: 'Set up foundation' });
}
// Full recursive content snapshot of .planning/ — "mutates nothing" is
// asserted as byte-identity of the whole tree, not spot checks.
function snapshotPlanning() {
const root = path.join(tmpDir, '.planning');
const out = {};
(function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) { out[path.relative(root, p) + '/'] = '<dir>'; walk(p); }
else out[path.relative(root, p)] = fs.readFileSync(p, 'utf-8');
}
})(root);
return out;
}
// #3726 AC 1 + AC 5: the canonical form and the `query` meta-prefix form
// resolve to the same implementation, so one test exercises the gate
// through both. The query form is the one the original incident used —
// `query milestone.complete <v>` read as a query and archived the
// milestone.
test('bare invocation refuses, names --confirm, and mutates nothing (canonical + query forms)', () => {
seedMutableMilestone();
const before = snapshotPlanning();
for (const argv of [
['milestone', 'complete', 'v1.0', '--name', 'MVP'],
['query', 'milestone.complete', 'v1.0', '--name', 'MVP'],
]) {
const result = runGsdTools(argv, tmpDir);
assert.strictEqual(result.success, false, `${argv.join(' ')} must refuse without --confirm`);
assert.match(result.error || '', /--confirm/, 'refusal must name the flag that proceeds');
assert.match(result.error || '', /irreversible/i, 'refusal must say why it refused');
assert.deepStrictEqual(snapshotPlanning(), before, `${argv.join(' ')} must leave .planning/ untouched`);
}
});
// #3726 AC 4 boundary: --force keeps its narrow meaning (bypass the
// TRUNCATED-scope / unstarted-phase guards) and does NOT double as the
// mutation opt-in.
test('--force alone does not satisfy the confirmation gate', () => {
seedMutableMilestone();
const before = snapshotPlanning();
const result = runGsdTools(['milestone', 'complete', 'v1.0', '--force'], tmpDir);
assert.strictEqual(result.success, false, '--force without --confirm must still refuse');
assert.match(result.error || '', /--confirm/);
assert.deepStrictEqual(snapshotPlanning(), before, '--force refusal must leave .planning/ untouched');
});
// #3726 boundary triple, third arm (review Minor 1): present-but-falsy.
// The gate is an exact-token match (`args.includes('--confirm')`), so
// `--confirm=false` / `--confirm=0` are not the token and refuse,
// fail-closed. Pinned so a future `=`-aware or prefix-matching arg parser
// cannot silently turn `--confirm=false` into a confirmed run of an
// irreversible command with no test going red.
test('--confirm=false / --confirm=0 do not satisfy the confirmation gate (canonical + query forms)', () => {
seedMutableMilestone();
const before = snapshotPlanning();
for (const argv of [
['milestone', 'complete', 'v1.0', '--confirm=false'],
['milestone', 'complete', 'v1.0', '--confirm=0'],
['query', 'milestone.complete', 'v1.0', '--confirm=false'],
['query', 'milestone.complete', 'v1.0', '--confirm=0'],
]) {
const result = runGsdTools(argv, tmpDir);
assert.strictEqual(result.success, false, `${argv.join(' ')} must refuse — the exact token is absent`);
assert.match(result.error || '', /--confirm/, 'refusal must name the flag that proceeds');
assert.deepStrictEqual(snapshotPlanning(), before, `${argv.join(' ')} must leave .planning/ untouched`);
}
});
// #3726 AC 3: the preview needs no confirmation and still mutates nothing.
test('--dry-run previews without --confirm and mutates nothing', () => {
seedMutableMilestone();
const before = snapshotPlanning();
const result = runGsdTools(['milestone', 'complete', 'v1.0', '--dry-run', '--raw'], tmpDir);
assert.ok(result.success, `dry-run failed: ${result.error}`);
const preview = JSON.parse(result.output);
assert.strictEqual(preview.dry_run, true);
assert.deepStrictEqual(snapshotPlanning(), before, 'dry-run must leave .planning/ untouched');
});
// #3726 AC 2: --confirm is the explicit opt-in and the archive then runs
// exactly as before the gate existed.
test('--confirm proceeds through the archive (query form)', () => {
seedMutableMilestone();
const result = runGsdTools(['query', 'milestone.complete', 'v1.0', '--name', 'MVP', '--confirm'], tmpDir);
assert.ok(result.success, `--confirm run failed: ${result.error}`);
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-ROADMAP.md')), 'ROADMAP archived');
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01-foundation')), 'phase dir moved');
});
// #3726 (PR #3774 review, Nit 1): the documented arg-discovery path is
// "invoke the command without args and the error lists what is required"
// (gsd-tools.cjs top-level usage). The version-required refusal is that
// error for `milestone complete`, so it must name --confirm too — otherwise
// discovering the flag takes a second round trip through the gate.
test('the version-required refusal names --confirm (arg-discovery path)', () => {
seedMutableMilestone();
const before = snapshotPlanning();
const result = runGsdTools(['milestone', 'complete'], tmpDir);
assert.strictEqual(result.success, false, 'bare `milestone complete` must refuse');
assert.match(result.error || '', /version required/, 'refusal must still name the missing version');
assert.match(result.error || '', /--confirm/, 'refusal must name --confirm so one invocation lists everything required');
assert.deepStrictEqual(snapshotPlanning(), before, 'a version-less invocation must leave .planning/ untouched');
});
});
// ────────────────────────────────────────────────────────────────────────
// #3726 docs pin (PR #3774 review, Minor 1). The changeset is `type: Fixed`,
// which the docs-required lint exempts, so nothing in CI would notice a later
// edit that reinstated the bare-`--force` override prose or dropped
// `--confirm` from the synopsis — doc completeness for this command would
// otherwise rest on review attention alone. This block is that gate. It is
// registered in scripts/docs-guard-registry.cjs so it runs on the PR that
// changes these docs, not only after merge.
// ────────────────────────────────────────────────────────────────────────
describe('#3726 milestone complete docs pin', () => {
const REPO_ROOT = path.join(__dirname, '..');
const readDoc = (...segs) => fs.readFileSync(path.join(REPO_ROOT, 'docs', ...segs), 'utf-8');
const SYNOPSIS = 'node gsd-tools.cjs milestone complete <version> (--confirm | --dry-run)';
const MIRRORS = [['CLI-TOOLS.md'], ['ja-JP', 'CLI-TOOLS.md'], ['ko-KR', 'CLI-TOOLS.md'], ['pt-BR', 'CLI-TOOLS.md'], ['zh-CN', 'CLI-TOOLS.md']];
test('the synopsis renders --confirm and --dry-run as alternatives in CLI-TOOLS.md and every localized mirror', () => {
for (const rel of MIRRORS) {
const synopsis = readDoc(...rel).split('\n').filter((l) => l.includes('milestone complete <version>'));
assert.strictEqual(synopsis.length, 1, `docs/${rel.join('/')}: expected exactly one synopsis line`);
assert.ok(synopsis[0].startsWith(SYNOPSIS), `docs/${rel.join('/')}: synopsis must begin "${SYNOPSIS}", got: ${synopsis[0]}`);
}
});
test('the flag table documents --confirm as required to mutate and not implied by --force', () => {
const row = readDoc('CLI-TOOLS.md').split('\n').find((l) => l.startsWith('| `--confirm` |'));
assert.ok(row, 'docs/CLI-TOOLS.md: `--confirm` flag row missing');
assert.ok(row.includes('Required to mutate'), '`--confirm` row must say it is required to mutate');
assert.ok(row.includes('Not implied by `--force`'), '`--confirm` row must say --force does not imply it');
});
// The milestone-complete sections of each doc, bounded by heading — a
// renamed heading fails loudly here instead of silently emptying the sweep.
function section(text, doc, startRe, endRe) {
const lines = text.split('\n');
const from = lines.findIndex((l) => startRe.test(l));
assert.ok(from >= 0, `docs/${doc}: section heading ${startRe} not found`);
let to = lines.findIndex((l, i) => i > from && endRe.test(l));
if (to < 0) to = lines.length;
return lines.slice(from, to).map((line, i) => ({ line, n: from + i + 1 }));
}
const SECTIONS = () => {
const cli = readDoc('CLI-TOOLS.md');
return [
{ doc: 'CLI-TOOLS.md', rows: section(cli, 'CLI-TOOLS.md', /^### `milestone complete` refuses an untrustworthy window/, /^## /) },
{ doc: 'CLI-TOOLS.md', rows: section(cli, 'CLI-TOOLS.md', /^## Milestone Commands/, /^## /) },
{ doc: 'COMMANDS.md', rows: section(readDoc('COMMANDS.md'), 'COMMANDS.md', /^### `\/gsd-complete-milestone`/, /^### /) },
];
};
// Prose is soft-wrapped, so a line is the wrong unit: `Pass \`--force --confirm\`
// to override … (\`--force\` alone does not imply it)` spans two lines in
// docs/CLI-TOOLS.md, and the second reads bare on its own. A table row is a
// paragraph on its own; every paragraph is joined and split at sentence
// boundaries. The unit reported is the paragraph's first line.
function units(rows) {
const out = [];
let para = [];
const flush = () => {
if (para.length === 0) return;
const n = para[0].n;
const text = para.map(({ line }) => line.trim()).join(' ');
// Clause-level: a semicolon-spliced instruction ("…to override; or pass
// `--force` alone…") must not coalesce with the compliant clause before it.
for (const sentence of text.split(/(?<=[.!?;])\s+/)) out.push({ n, unit: sentence });
para = [];
};
for (const row of rows) {
const line = row.line.trim();
if (line === '') { flush(); continue; }
// A table row is its own paragraph, but it is still sentence-split: a
// bare instruction appended inside the `--confirm` cell must not hide
// behind that cell's own `--confirm` token (reviewer-found escape).
if (line.startsWith('|')) { flush(); para.push({ line, n: row.n }); flush(); continue; }
para.push(row);
}
flush();
return out;
}
// Every --force unit that legitimately carries no --confirm, pinned EXACTLY —
// the identity-ratchet shape scripts/lib/allowlist-ratchet.cjs uses for the
// repo's lints. Classifying prose intent by regex is a snapshot of the shapes
// seen so far (the first version of this pin was one, and a reviewer refuted
// it with "re-run with `--force`"); pinning the benign set instead means a
// new --force sentence or clause without --confirm fails, whatever its
// wording, and an edit to a benign one fails loudly until the list is
// updated by hand. Named residual: a bare instruction spliced into the SAME
// clause as a compliant one ("…to override, or just `--force` if you like")
// coalesces with it and passes here; the test below pins the four known
// instructions by guard name (a substring match on each instruction's own
// `--force --confirm` text), so those cannot lose the pairing unnoticed.
const BENIGN_BARE_FORCE_UNITS = [
'| `--force` | Override the unstarted-phase guard (see below).',
'Not implied by `--force`, which only overrides the guards below.',
'`--force` alone does not imply it).',
"The guard runs whenever `--force` is absent, independent of `STATE.md`'s `milestone:` field — if that field is present but does not match `<version>`, a WARNING naming both values is emitted to stderr and the scan still runs (#2946).",
];
test('inside the milestone complete sections, every --force unit without --confirm is a pinned benign one', () => {
const bare = [];
for (const { doc, rows } of SECTIONS()) {
for (const { n, unit } of units(rows)) {
if (unit.includes('--force') && !unit.includes('--confirm')) bare.push({ doc, n, unit });
}
}
const unexpected = bare.filter(({ unit }) => !BENIGN_BARE_FORCE_UNITS.includes(unit));
assert.deepStrictEqual(unexpected.map(({ doc, n, unit }) => `docs/${doc}:${n} ${unit.slice(0, 100)}`), [],
'a --force sentence without --confirm that is not in BENIGN_BARE_FORCE_UNITS — --force alone refuses since #3726; an override instruction must say --force --confirm, and a genuinely benign new sentence is added to the pinned list by hand');
const missing = BENIGN_BARE_FORCE_UNITS.filter((u) => !bare.some(({ unit }) => unit === u));
assert.deepStrictEqual(missing, [], 'a pinned benign unit no longer appears verbatim — update BENIGN_BARE_FORCE_UNITS to the edited text (or drop it) so the pin stays exact');
});
test('both guard-override instructions in each doc read --force --confirm', () => {
// Pinned by guard, not by count: a lost instruction cannot be masked by an
// unrelated new match. CLI-TOOLS.md carries one guard per section; the
// COMMANDS.md section carries both as blockquotes.
const has = (rows, re) => rows.some(({ line }) => re.test(line));
const [truncated, unstarted, commands] = SECTIONS();
assert.ok(has(truncated.rows, /Pass `--force --confirm` to override/), 'docs/CLI-TOOLS.md truncated-window guard: override instruction missing or not --force --confirm');
assert.ok(has(unstarted.rows, /Pass `--force --confirm` to override/), 'docs/CLI-TOOLS.md unstarted-phase guard: override instruction missing or not --force --confirm');
assert.ok(has(commands.rows, /\*\*Truncated-window guard\.\*\*.*milestone complete <version> --force --confirm/), 'docs/COMMANDS.md truncated-window guard: override instruction missing or not --force --confirm');
assert.ok(has(commands.rows, /\*\*Unstarted-phase guard\.\*\*.*milestone complete <version> --force --confirm/), 'docs/COMMANDS.md unstarted-phase guard: override instruction missing or not --force --confirm');
});
});

View File

@@ -469,7 +469,7 @@ describe('milestone complete: version path-traversal guard (#2288 security)', ()
// `version` is interpolated into `${version}-ROADMAP.md`, `${version}-phases`,
// etc. as a MOVED/written path component — a traversal value must be rejected
// before any filesystem mutation.
const result = runGsdTools('milestone complete ../../../gsd-ms-escape', tmpDir);
const result = runGsdTools('milestone complete ../../../gsd-ms-escape --confirm', tmpDir);
assert.ok(!result.success, 'milestone complete must FAIL on a path-traversal version');
// No artifact created outside the project via traversal.
@@ -491,7 +491,7 @@ describe('milestone complete: version path-traversal guard (#2288 security)', ()
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('milestone complete v1\\\\..\\\\evil', tmpDir);
const result = runGsdTools('milestone complete v1\\\\..\\\\evil --confirm', tmpDir);
assert.ok(!result.success, 'milestone complete must FAIL on a backslash-separator version');
assert.ok(fs.existsSync(phase1), 'phase dir must remain in place');
});

View File

@@ -480,7 +480,7 @@ describe('#2528 consumer parity — the eight sites migrated to matchPhaseDirs',
// 8. milestone complete — mutating, own project. The guard blocks
// completion while any roadmap phase has no directory.
const completion = runGsdTools('milestone complete v1.0', project(dirs, query));
const completion = runGsdTools('milestone complete v1.0 --confirm', project(dirs, query));
assert.strictEqual(
completion.success,
resolves,

View File

@@ -41,7 +41,7 @@
{
"at": "ship:post",
"run": [
["milestone", "complete", "1.0", "--force"]
["milestone", "complete", "1.0", "--force", "--confirm"]
]
},
{

View File

@@ -232,7 +232,10 @@ const BOUNDARY_COMMANDS = [
},
{
label: 'milestone complete',
argv: ['milestone', 'complete', 'v1.1', '--force'],
// --confirm is the mutation opt-in (#3726) — without it the command
// refuses before touching anything, and this fixture must genuinely
// succeed. --force alone does not imply it.
argv: ['milestone', 'complete', 'v1.1', '--force', '--confirm'],
setup: (tmpDir) => {
// --force bypasses both the TRUNCATED-scope guard and the
// unstarted-phase (no_directory) guard, so this fixture only needs a

View File

@@ -15682,7 +15682,7 @@ describe('fix #1580 — milestone complete ignores the 999 backlog sentinel', ()
test('completes WITHOUT --force despite a Phase 999 backlog heading', () => {
const result = runGsdTools(
['milestone', 'complete', 'v1.0', '--name', 'Regression'],
['milestone', 'complete', 'v1.0', '--name', 'Regression', '--confirm'],
tmpDir,
);
assert.ok(