* test(#2142): failing-first coverage for quick-task archival at milestone close-out * enhance(#2142): archive quick tasks at milestone close-out * fix(#2142): resolve review findings — readme injection, move/reset ordering, owned state write * fix(#2142): fold archival under milestone namespace, expose index IR, dedupe reset decision * test(#2142): assert archive-dir-relative summary path in index IR * docs(#2142): backfill changeset pr number to 3592 * test(#2142): skip newline-fixture injection test on windows (control chars illegal in path names) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/tidy-goats-jump.md
Normal file
5
.changeset/tidy-goats-jump.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 3592
|
||||
---
|
||||
**Quick tasks can now be archived at milestone close-out.** `/gsd-complete-milestone` offers an opt-in prompt to sweep `.planning/quick/` into `.planning/milestones/<version>-quick/` with a generated `README.md` index and a reset `Quick Tasks Completed` table, and `/gsd-cleanup` offers the same archival retroactively for milestones that were already closed. (#2142)
|
||||
@@ -9,7 +9,7 @@
|
||||
## Glossary — Domain modules and seams
|
||||
|
||||
### Milestone Module
|
||||
Module owning `milestone complete` (archive roadmap/requirements/phases, build MILESTONES.md entry, update STATE.md), `requirements mark-complete` (checkbox + table update with regex-global-state fix), and `phases clear`. Key behaviors: milestone-phase scoping (extract phases from ROADMAP.md milestone slice, support project-code-prefix dirs e.g. CK-01-name, exclude prior-milestone phases), milestone-archive layout (resolve phase dirs from `.planning/milestones/v*-phases/` when `.planning/phases/` absent), fenced-code-block boundary tracking in `extractCurrentMilestone`. Source of truth: `gsd-core/bin/lib/milestone.cjs` (query handlers for `milestone.complete`, `phases.archive`). Test consolidation: PR #3753 (10 files → 4). (The SDK milestone surface and `GSD.run()` milestone runner were retired with the SDK package per ADR-0174.)
|
||||
Module owning `milestone complete` (archive roadmap/requirements/phases, build MILESTONES.md entry, update STATE.md), `requirements mark-complete` (checkbox + table update with regex-global-state fix), and `phases clear`. Key behaviors: milestone-phase scoping (extract phases from ROADMAP.md milestone slice, support project-code-prefix dirs e.g. CK-01-name, exclude prior-milestone phases), milestone-archive layout (resolve phase dirs from `.planning/milestones/v*-phases/` when `.planning/phases/` absent), fenced-code-block boundary tracking in `extractCurrentMilestone`. Source of truth: `gsd-core/bin/lib/milestone.cjs` (query handlers for `milestone.complete`, `phases.archive`, `milestone.archive-quick`). `milestone.archive-quick` (#2142 escalation) is the narrow archival-only helper `gsd-core/workflows/cleanup.md` uses instead of `milestone.complete --archive-quick` — it shares `milestone.complete`'s quick-task move/README-index logic but skips the ROADMAP/REQUIREMENTS/MILESTONES.md writes and the milestone-completion guards, so it is safe to run against an already-completed milestone. Test consolidation: PR #3753 (10 files → 4). (The SDK milestone surface and `GSD.run()` milestone runner were retired with the SDK package per ADR-0174.)
|
||||
|
||||
### Dispatch Pipeline Module
|
||||
Module that composes Dispatch Policy Module, Query Execution Policy Module, and per-stage handlers (input-validation, plan, execution, result-builder, formatting, error-mapping, observability) into the end-to-end pipeline that produces a `QueryDispatchResult`. The SDK-era pipeline collapsed onto the Command Routing Hub per ADR-0174; current dispatch seam: `gsd-core/bin/lib/command-routing-hub.cjs` (see Command Routing Hub below).
|
||||
|
||||
@@ -680,7 +680,10 @@ 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]
|
||||
node gsd-tools.cjs milestone complete <version> [--name <name>] [--no-archive-phases] [--force] [--dry-run] [--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]
|
||||
|
||||
# Mark requirements as complete
|
||||
node gsd-tools.cjs requirements mark-complete <ids>
|
||||
@@ -694,13 +697,27 @@ node gsd-tools.cjs requirements mark-complete <ids>
|
||||
| `<version>` | Milestone version label to archive (e.g. `v1.0`). |
|
||||
| `--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 to move) without mutating anything. |
|
||||
| `--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.
|
||||
|
||||
**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.
|
||||
|
||||
**`milestone archive-quick` (#2142 escalation)**
|
||||
|
||||
A narrower sibling of `milestone complete --archive-quick`, for callers that need to sweep `.planning/quick/*` WITHOUT the full milestone close-out — chiefly `gsd-core/workflows/cleanup.md`, which runs against milestones that are typically already completed.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `<version>` | Milestone version label to archive quick-task directories under (e.g. `v1.0`). Same validation as `milestone complete`'s `<version>` — letters/digits/`.`/`-`/`_` only, no path separators or `..`. |
|
||||
| `--dry-run` | List what would move (`would_archive`) without mutating anything. |
|
||||
|
||||
It moves every directory under `.planning/quick/` into `.planning/milestones/<version>-quick/`, (re)writes that archive directory's `README.md` index, and clears STATE.md's `### Quick Tasks Completed` table rows — the same move/index/reset logic `milestone complete --archive-quick` uses. Unlike `milestone complete`, it never archives `ROADMAP.md`/`REQUIREMENTS.md`, never writes a `MILESTONES.md` entry, and runs neither the unstarted-phase guard nor the milestone-window refusal — so, unlike `milestone complete --archive-quick`, it can be safely re-run against an already-completed milestone. JSON result: `{ version, archived, entries, archive_dir, state_updated, warnings }`.
|
||||
|
||||
`milestone archive-quick` is a second subcommand of `milestone` (alongside `complete`) — it is not a separate top-level command.
|
||||
|
||||
---
|
||||
|
||||
## Agent Skills
|
||||
@@ -771,6 +788,8 @@ node gsd-tools.cjs verify-path-exists <path>
|
||||
|
||||
# Append a row to STATE.md's "Quick Tasks Completed" table (schema-backed; #2133)
|
||||
node gsd-tools.cjs quick-tasks-append --task "<description>"
|
||||
# See "Milestone Commands" below for `milestone archive-quick` (#2142) — sweeps .planning/quick/* into
|
||||
# milestones/<version>-quick/ and clears this table, without a full `milestone complete`.
|
||||
|
||||
# Aggregate all SUMMARY.md data
|
||||
node gsd-tools.cjs history-digest
|
||||
|
||||
@@ -500,6 +500,8 @@ The marker never overwrites the artifact's own `status:` field for the eight fro
|
||||
|
||||
> **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.
|
||||
|
||||
> **Quick-task archival (opt-in, default OFF, #2142).** Unlike phase archival above, quick-task archival does not run unless you say yes — doing nothing leaves `.planning/quick/` untouched. If `.planning/quick/` contains at least one directory, the workflow asks: `Archive completed quick tasks into this milestone too?` with options `Yes — archive quick tasks into v[X.Y]` / `Skip`. Choosing "Yes" passes `--archive-quick` to the underlying `gsd-tools milestone complete` call, which moves every directory under `.planning/quick/` into `.planning/milestones/v[X.Y]-quick/`, (re)writes that directory's `README.md` index (built by scanning the archive directory, not STATE.md), and clears the data rows of STATE.md's `### Quick Tasks Completed` table while preserving its header and column variant. **Known limit:** there is no on-disk record of which milestone a quick task belongs to, so archival buckets **all** remaining `.planning/quick/*` into the one milestone being completed — a task predating an earlier, unarchived milestone lands in the current bucket regardless. See [Archiving quick tasks](how-to/handle-quick-and-fast-tasks.md#archiving-quick-tasks) for the full walkthrough, including the retroactive path.
|
||||
|
||||
---
|
||||
|
||||
### `/gsd-milestone-summary`
|
||||
@@ -1052,10 +1054,12 @@ covers only orphan worktrees, with the stale-worktree case moving to the new
|
||||
|
||||
### `/gsd-cleanup`
|
||||
|
||||
Archive accumulated phase directories from completed milestones and prune local branches whose upstream has been deleted.
|
||||
Archive accumulated phase directories from completed milestones, prune local branches whose upstream has been deleted, and — when applicable — retroactively archive quick tasks (#2142).
|
||||
|
||||
**Behaviour:** Presents a dry-run summary of phase directories to archive (moved from `.planning/phases/` into `.planning/milestones/v{X.Y}-phases/`) and local branches whose upstream is gone (pruned via `git fetch --prune`). Requires confirmation before writing any changes. The currently checked-out branch is never pruned.
|
||||
|
||||
**Retroactive quick-task archival (opt-in, #2142).** When `.planning/quick/` contains at least one directory, `/gsd-cleanup` additionally offers to sweep it: `Archive ALL {N} quick-task directories into v{X.Y} — {Milestone Name}? This buckets every remaining quick task into this ONE milestone; there is no way to split them per-milestone.` with options `Yes — archive quick tasks into v{X.Y}` / `Skip`. The target is the single most recent completed milestone (from `MILESTONES.md`) that does not yet have a `v{X.Y}-quick` archive directory. If `.planning/quick/` is empty, this step is not offered at all. Confirming calls the narrower `gsd-tools milestone archive-quick <version>` command — the same move/README-index/table-reset logic `/gsd-complete-milestone`'s `--archive-quick` uses, but without touching `ROADMAP.md`, `REQUIREMENTS.md`, `MILESTONES.md`, or milestone-completion guards, since `/gsd-cleanup` typically targets a milestone that is already closed. See [Archiving quick tasks](how-to/handle-quick-and-fast-tasks.md#archiving-quick-tasks) for the full walkthrough and the silent/failure cases.
|
||||
|
||||
```bash
|
||||
/gsd-cleanup
|
||||
```
|
||||
|
||||
@@ -182,6 +182,9 @@
|
||||
- [External-Job Capability](#155-external-job-capability)
|
||||
- [API-Coverage Gate](#156-api-coverage-gate)
|
||||
- [State Rebuild & Configurable Graph Path](#157-state-rebuild--configurable-graph-path)
|
||||
- [Broken-Windows Ledger](#158-broken-windows-ledger)
|
||||
- [Complexity-Triggered Refactor](#159-complexity-triggered-refactor)
|
||||
- [Archive Quick Tasks at Milestone Close](#160-archive-quick-tasks-at-milestone-close)
|
||||
|
||||
---
|
||||
|
||||
@@ -3384,3 +3387,24 @@ The load-bearing wire is the `plan-phase` lift into `must_haves.prohibitions`, s
|
||||
**Config:** `refactor.trigger_enabled` (master gate, default `false`), `refactor.complexity_threshold` (default `15`), `refactor.complexity_jump_delta` (default `5`), `refactor.trigger_strict` (default `false`). See [Configuration Reference](CONFIGURATION.md#refactor-trigger-settings).
|
||||
|
||||
**Backward compatibility:** Off by default. When `refactor.trigger_enabled` is `false` the hook never runs and writes nothing; a project that never enables it is completely unaffected.
|
||||
|
||||
---
|
||||
|
||||
### 160. Archive Quick Tasks at Milestone Close
|
||||
|
||||
**Command:** `/gsd-complete-milestone` (forward path), `/gsd-cleanup` (retroactive path), `gsd-tools milestone complete --archive-quick` / `gsd-tools milestone archive-quick <version>` (#2142)
|
||||
|
||||
**Behavior:** `.planning/quick/` otherwise accumulates one directory per `/gsd-quick` task forever. `/gsd-complete-milestone` now offers a Yes/Skip prompt — when accepted, it moves every directory under `.planning/quick/` into `.planning/milestones/<version>-quick/`, (re)writes that archive directory's `README.md` (an index built by scanning the archive directory, one entry per task, linked to its `SUMMARY.md` when one exists), and clears the data rows of `STATE.md`'s `### Quick Tasks Completed` table while preserving its header and detected column variant. `/gsd-cleanup` offers the same archival retroactively, for milestones that were already closed before their quick tasks were swept, via the narrower `milestone archive-quick <version>` command — identical move/index/reset behavior, but without touching `ROADMAP.md`, `REQUIREMENTS.md`, `MILESTONES.md`, or milestone-completion guards, so it can be re-run safely against an already-completed milestone.
|
||||
|
||||
**Why opt-in.** Phase-directory archival is default-ON (#1871) — omitting a phase directory from an archive would silently leave stale execution history in the way of the next milestone's roadmap. Quick tasks carry no such downstream conflict, so archival here defaults OFF: a user who never passes `--archive-quick` sees zero behavior change. This is a deliberate asymmetry with phase archival, not an oversight.
|
||||
|
||||
**Why bucket-all, not per-milestone.** `.planning/quick/` is a flat directory with no on-disk record of which milestone a given task belongs to. Splitting tasks per milestone was considered and rejected — inferring provenance from dates (creation time vs. a milestone's shipped date) is a proxy, not a fact, and a wrong inference on a one-way `mv` is silently irreversible. Archival instead buckets everything currently in `.planning/quick/` into the one milestone being completed (or, on the retroactive path, the one milestone chosen), and says so in the confirmation prompt.
|
||||
|
||||
**Why the index is built from disk, not from `STATE.md`'s table.** The `### Quick Tasks Completed` table is a running log a workflow step appends to — it demonstrably drifts from what's actually in `.planning/quick/` (the motivating case: 53 rows against 49 directories, ~22 rows pointing at directories that no longer existed, 18 directories with no row at all). Building the archive's `README.md` index by scanning the archive directory itself, rather than trusting the table, means the index can never inherit that drift; a re-run's index also naturally includes entries a prior run already archived, since it's re-derived from what's physically present.
|
||||
|
||||
**Known limits:**
|
||||
- No per-milestone provenance — bucket-all is the only option (see above).
|
||||
- A `### Quick Tasks Completed` table whose columns match neither registered variant (with/without a Status column) is left untouched with a warning rather than reset, since clearing it would risk destroying rows under a schema GSD doesn't recognize.
|
||||
- A `STATE.md` with no `### Quick Tasks Completed` section at all is a normal, silent no-op for the reset step — the section is created lazily by `/gsd-quick`, not present in the project template.
|
||||
|
||||
See [Archiving quick tasks](how-to/handle-quick-and-fast-tasks.md#archiving-quick-tasks) for the full walkthrough.
|
||||
|
||||
@@ -113,6 +113,59 @@ The key distinction is subagent isolation. `/gsd-quick` spawns a fresh planner a
|
||||
|
||||
---
|
||||
|
||||
## Archiving quick tasks
|
||||
|
||||
`.planning/quick/` accumulates one directory per `/gsd-quick` task forever unless you archive it. Archival is **opt-in** (#2142) — it deliberately does not mirror phase-directory archival, which is default-ON. Do nothing and `.planning/quick/` stays exactly as it is.
|
||||
|
||||
There are two paths in, depending on when you archive:
|
||||
|
||||
### Forward path — archive at milestone close-out
|
||||
|
||||
When you run `/gsd-complete-milestone` and `.planning/quick/` has at least one directory, you are asked:
|
||||
|
||||
```text
|
||||
Archive completed quick tasks into this milestone too?
|
||||
Yes — archive quick tasks into v[X.Y]
|
||||
Skip
|
||||
```
|
||||
|
||||
Choosing "Yes" folds `--archive-quick` into the same `gsd-tools milestone complete` call that archives `ROADMAP.md`/`REQUIREMENTS.md` and phase directories — one command, one `STATE.md` write. "Skip" (or an empty `.planning/quick/`) leaves everything untouched.
|
||||
|
||||
### Retroactive path — archive an already-completed milestone
|
||||
|
||||
`/gsd-complete-milestone` only runs once per milestone, so if quick tasks piled up after you already closed one out, use `/gsd-cleanup` instead. When it finds `.planning/quick/` non-empty, it offers to sweep it into the most recent completed milestone that doesn't yet have a `-quick` archive:
|
||||
|
||||
```text
|
||||
Archive ALL {N} quick-task directories into v{X.Y} — {Milestone Name}?
|
||||
This buckets every remaining quick task into this ONE milestone;
|
||||
there is no way to split them per-milestone.
|
||||
Yes — archive quick tasks into v{X.Y}
|
||||
Skip
|
||||
```
|
||||
|
||||
Under the hood this calls the narrower `gsd-tools milestone archive-quick <version>` command — the same move/index/reset logic as `--archive-quick`, but without touching `ROADMAP.md`, `REQUIREMENTS.md`, `MILESTONES.md`, or milestone-completion guards, so it is safe to run against a milestone that's already closed.
|
||||
|
||||
### What both paths do
|
||||
|
||||
1. Move every directory under `.planning/quick/` into `.planning/milestones/<version>-quick/`.
|
||||
2. (Re)write that archive directory's `README.md` — a plain list, one entry per archived task directory, linking to its `<dir>-SUMMARY.md` (or legacy bare `SUMMARY.md`) when one exists, listed unlinked when it doesn't. The index is built by scanning the archive directory itself, never from `STATE.md`'s table — the table is known to drift from disk, so the filesystem is the only source of truth here.
|
||||
3. Clear the data rows of `STATE.md`'s `### Quick Tasks Completed` table, preserving its header row and whichever column variant (with or without a Status column) was detected.
|
||||
|
||||
**Bucket-all, not per-milestone.** Quick tasks carry no on-disk record of which milestone they belong to, so every remaining `.planning/quick/*` directory lands in the ONE milestone you're archiving into — including tasks that predate an earlier, unarchived milestone. There's no way to split them after the fact; this is a known limit, not a bug.
|
||||
|
||||
### Telling "nothing to archive" from "refused to touch it"
|
||||
|
||||
Four cases look similar from the outside but mean different things:
|
||||
|
||||
| What you see | Meaning |
|
||||
|---|---|
|
||||
| No archive prompt at all | `.planning/quick/` is absent or empty — nothing to archive, nothing reported |
|
||||
| Archive prompt appears, directories move, but `STATE.md` doesn't change | Your `STATE.md` has no `### Quick Tasks Completed` section yet — normal, since `/gsd-quick` creates that section lazily on first completion, not the project template. This is a silent no-op, not a failure |
|
||||
| Directories move, but you see a `warnings` entry naming `quick_tasks_table` | Your `### Quick Tasks Completed` table has a column set that matches neither registered variant. The reset is **refused** — every row is preserved untouched rather than risk destroying data under a schema GSD doesn't recognize |
|
||||
| Fewer directories moved than existed in `.planning/quick/` | A rename failed partway through. The result's `archived` count reflects exactly what succeeded — never a false full count — and the directories that didn't move are still in `.planning/quick/` for a retry |
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [The phase loop](../explanation/the-phase-loop.md)
|
||||
|
||||
@@ -68,6 +68,14 @@
|
||||
* milestone complete <version> Archive milestone, create MILESTONES.md
|
||||
* [--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
|
||||
* Quick Tasks Completed table (#2142; opt-in, default OFF)
|
||||
*
|
||||
* milestone archive-quick <version> Move .planning/quick/* dirs to milestones/vX.Y-quick/ + reset the
|
||||
* Quick Tasks Completed table, WITHOUT the milestone complete close-out
|
||||
* (no ROADMAP/REQUIREMENTS/MILESTONES.md writes, no completion guards);
|
||||
* safe against an already-completed milestone (#2142 escalation)
|
||||
* [--dry-run] Preview what would move, mutates nothing
|
||||
*
|
||||
* User Story Validation:
|
||||
* user-story validate --story "..." Validate "As a / I want to / so that" format
|
||||
@@ -2134,9 +2142,20 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
|
||||
const force = args.includes('--force');
|
||||
// #2118: --dry-run prints a preview plan without mutating.
|
||||
const dryRun = args.includes('--dry-run');
|
||||
milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force, dryRun }, raw);
|
||||
// #2142: quick-task archival is opt-in (default OFF) — unlike
|
||||
// --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);
|
||||
} else if (subcommand === 'archive-quick') {
|
||||
// #2142 escalation: narrow archival-only entry point (does NOT
|
||||
// touch ROADMAP/REQUIREMENTS/MILESTONES.md, runs no completion
|
||||
// guards) — safe to call against an already-completed milestone,
|
||||
// unlike `milestone complete --archive-quick`.
|
||||
const dryRun = args.includes('--dry-run');
|
||||
milestone.cmdQuickArchive(cwd, args[2], { dryRun }, raw);
|
||||
} else {
|
||||
error('Unknown milestone subcommand. Available: complete', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
error('Unknown milestone subcommand. Available: complete, archive-quick', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<purpose>
|
||||
|
||||
Archive accumulated phase directories from completed milestones into `.planning/milestones/v{X.Y}-phases/`. Identifies which phases belong to each completed milestone, shows a dry-run summary, and moves directories on confirmation.
|
||||
Archive accumulated phase directories from completed milestones into `.planning/milestones/v{X.Y}-phases/`. Identifies which phases belong to each completed milestone, shows a dry-run summary, and moves directories on confirmation. Also offers retroactive archival of `.planning/quick/` (#2142) when it is non-empty.
|
||||
|
||||
</purpose>
|
||||
|
||||
@@ -9,6 +9,7 @@ Archive accumulated phase directories from completed milestones into `.planning/
|
||||
1. `.planning/MILESTONES.md`
|
||||
2. `.planning/milestones/` directory listing
|
||||
3. `.planning/phases/` directory listing
|
||||
4. `.planning/quick/` directory listing
|
||||
|
||||
</required_reading>
|
||||
|
||||
@@ -69,6 +70,26 @@ Match phase directories to milestone membership. Only include directories that s
|
||||
|
||||
</step>
|
||||
|
||||
<step name="identify_quick_tasks">
|
||||
|
||||
Check whether `.planning/quick/` has anything to retroactively archive (#2142):
|
||||
|
||||
```bash
|
||||
ls -d .planning/quick/*/ 2>/dev/null || true
|
||||
```
|
||||
|
||||
**If no directories are found:** `.planning/quick/` is empty (or absent) — say nothing about quick-task archival and do not offer the step. Skip straight to `show_dry_run` with no quick-task summary or prompt.
|
||||
|
||||
**If at least one directory is found:** determine the target milestone. Unlike phase directories — whose milestone membership is derivable from the archived ROADMAP snapshot each completed milestone already has — quick tasks carry **no on-disk provenance** at all; there is no way to tell which milestone any given quick task directory belongs to. The target is therefore the single most recent completed milestone (from `.planning/MILESTONES.md`, already read in `identify_completed_milestones`, listed newest-first) that does not yet have a `-quick` archive directory:
|
||||
|
||||
```bash
|
||||
ls -d .planning/milestones/v*-quick 2>/dev/null || true
|
||||
```
|
||||
|
||||
Walk `.planning/MILESTONES.md`'s entries newest-first and pick the first version with no matching `v{version}-quick` directory above. If every completed milestone already has a `-quick` archive, or `.planning/MILESTONES.md` has no entries, there is no valid target — say so and skip the quick-task step entirely (do not prompt).
|
||||
|
||||
</step>
|
||||
|
||||
<step name="show_dry_run">
|
||||
|
||||
Present a dry-run summary for each milestone:
|
||||
@@ -92,6 +113,19 @@ These phase directories will be archived:
|
||||
Destination: .planning/milestones/v{X.Z}-phases/
|
||||
```
|
||||
|
||||
**If a quick-task target milestone was determined in `identify_quick_tasks`**, add:
|
||||
|
||||
```
|
||||
### Quick tasks — bucket-all into v{X.Y}
|
||||
{N} directories under .planning/quick/ will ALL be archived into this ONE milestone
|
||||
(v{X.Y} — {Milestone Name}), regardless of when each was actually completed.
|
||||
Quick tasks carry no on-disk record of which milestone they belong to, so this is
|
||||
a bucket-all, not a per-milestone split — unlike the phase archival above, which
|
||||
is derived per-milestone from each archived ROADMAP snapshot.
|
||||
|
||||
Destination: .planning/milestones/v{X.Y}-quick/
|
||||
```
|
||||
|
||||
**Stale local branches (upstream gone):**
|
||||
|
||||
First, update remote-tracking refs so the candidate list matches the execution list exactly:
|
||||
@@ -112,11 +146,12 @@ Show each branch name. If none, show:
|
||||
No stale local branches detected.
|
||||
```
|
||||
|
||||
If no phase directories remain to archive (all already moved or deleted) AND no stale branches exist:
|
||||
If no phase directories remain to archive (all already moved or deleted) AND no stale branches exist AND no quick-task target milestone was determined:
|
||||
|
||||
```
|
||||
No phase directories found to archive. Phases may have been removed or archived previously.
|
||||
No stale local branches detected either.
|
||||
No quick tasks to archive either.
|
||||
```
|
||||
|
||||
Stop here.
|
||||
@@ -127,6 +162,12 @@ AskUserQuestion: "Proceed with archiving and pruning?" with options: "Yes — ar
|
||||
|
||||
If "Cancel": Stop.
|
||||
|
||||
**If a quick-task target milestone was determined in `identify_quick_tasks`**, ask a separate, explicit question — this is a distinct, bucket-all action and must not be silently folded into the "Yes" above:
|
||||
|
||||
AskUserQuestion: "Archive ALL {N} quick-task directories into v{X.Y} — {Milestone Name}? This buckets every remaining quick task into this ONE milestone; there is no way to split them per-milestone." with options: "Yes — archive quick tasks into v{X.Y}" | "Skip"
|
||||
|
||||
If "Skip": do not run `archive_quick_tasks` — proceed to `archive_phases` (or `report`, if there were no phase directories to archive) with quick-task archival omitted.
|
||||
|
||||
</step>
|
||||
|
||||
<step name="archive_phases">
|
||||
@@ -147,6 +188,20 @@ Repeat for all milestones in the cleanup set.
|
||||
|
||||
</step>
|
||||
|
||||
<step name="archive_quick_tasks">
|
||||
|
||||
Only run this step when the "Yes — archive quick tasks into v{X.Y}" option was confirmed in `show_dry_run`.
|
||||
|
||||
Uses the narrow `milestone.archive-quick` command (#2142 escalation) rather than `milestone.complete --archive-quick`: cleanup runs against milestones that are typically ALREADY completed, and `milestone.complete` is the full close-out — it archives ROADMAP/REQUIREMENTS and writes a MILESTONES.md entry, so re-running it against an already-completed milestone would clobber that milestone's archived ROADMAP/REQUIREMENTS snapshot (the very snapshot this cleanup depends on) and duplicate its MILESTONES.md entry. `milestone.archive-quick` shares the same move/README-index/table-reset logic as `milestone.complete --archive-quick` (same underlying helper) without any of that.
|
||||
|
||||
```bash
|
||||
gsd_run query milestone.archive-quick "v{X.Y}"
|
||||
```
|
||||
|
||||
This moves every directory under `.planning/quick/` into `.planning/milestones/v{X.Y}-quick/`, (re)writes that directory's `README.md` index, and clears STATE.md's `### Quick Tasks Completed` table rows — identical move/index/reset behavior to the `--archive-quick` flag documented in `complete-milestone.md`'s `archive_milestone` step, without touching ROADMAP.md, REQUIREMENTS.md, MILESTONES.md, or milestone-completion guards. Extract `archived` from the result to confirm.
|
||||
|
||||
</step>
|
||||
|
||||
<step name="prune_local_branches">
|
||||
|
||||
After phase archival, prune local branches whose upstream has been deleted. Use the same filter as the dry-run so the execution list matches exactly what the user confirmed:
|
||||
@@ -168,7 +223,7 @@ Notes:
|
||||
Commit the changes:
|
||||
|
||||
```bash
|
||||
gsd_run query commit "chore: archive phase directories from completed milestones" --files .planning/milestones/ .planning/phases/
|
||||
gsd_run query commit "chore: archive phase directories from completed milestones" --files .planning/milestones/ .planning/phases/ .planning/quick/ .planning/STATE.md
|
||||
```
|
||||
|
||||
</step>
|
||||
@@ -179,6 +234,8 @@ gsd_run query commit "chore: archive phase directories from completed milestones
|
||||
Archived:
|
||||
{For each milestone}
|
||||
- v{X.Y}: {N} phase directories → .planning/milestones/v{X.Y}-phases/
|
||||
{If quick-task archival ran}
|
||||
- v{X.Y}: {N} quick-task directories → .planning/milestones/v{X.Y}-quick/ (bucket-all — see known limit)
|
||||
|
||||
Pruned: {N} local branches whose upstream is gone.
|
||||
|
||||
@@ -196,6 +253,8 @@ Pruned: {N} local branches whose upstream is gone.
|
||||
- [ ] Dry-run summary shown and user confirmed (covers both archival and pruning)
|
||||
- [ ] Phase directories moved to `.planning/milestones/v{X.Y}-phases/`
|
||||
- [ ] Stale local branches pruned (branches whose upstream is gone)
|
||||
- [ ] `.planning/quick/` checked; quick-task archival offered only when non-empty
|
||||
- [ ] When offered and confirmed, ALL remaining quick-task directories archived into the single named target milestone (bucket-all, not per-milestone) via `milestone.archive-quick`
|
||||
- [ ] Changes committed
|
||||
|
||||
</success_criteria>
|
||||
|
||||
@@ -507,10 +507,20 @@ Initial user testing showed demand for shape tools.
|
||||
|
||||
<step name="archive_milestone">
|
||||
|
||||
**Text mode (`workflow.text_mode: true` in config or `--text` flag):** Set `TEXT_MODE=true` if `--text` is present in `$ARGUMENTS` OR `text_mode` from init JSON is `true`. When TEXT_MODE is active, replace every `AskUserQuestion` call with a plain-text numbered list and ask the user to type their choice number. This is required for non-Claude runtimes (OpenAI Codex, Gemini CLI, etc.) where `AskUserQuestion` is not available.
|
||||
|
||||
**Quick-task archival (opt-in — NOT symmetrical with phase archival below, #2142):** unlike phase archival, quick-task archival is **opt-in, default OFF**. Doing nothing leaves `.planning/quick/` untouched, exactly like today's behavior. Decide this BEFORE calling `milestone complete` below, so the flag can be folded into that single invocation rather than issuing a second, redundant call.
|
||||
|
||||
If `.planning/quick/` contains at least one directory, ask:
|
||||
|
||||
AskUserQuestion: "Archive completed quick tasks into this milestone too?" with options: "Yes — archive quick tasks into v[X.Y]" | "Skip"
|
||||
|
||||
If "Yes": set `ARCHIVE_QUICK_FLAG="--archive-quick"`. If "Skip" (or `.planning/quick/` is empty): set `ARCHIVE_QUICK_FLAG=""`.
|
||||
|
||||
**Delegate archival to `gsd-tools.cjs query milestone.complete`:**
|
||||
|
||||
```bash
|
||||
ARCHIVE=$(gsd_run query milestone.complete "v[X.Y]" --name "[Milestone Name]")
|
||||
ARCHIVE=$(gsd_run query milestone.complete "v[X.Y]" --name "[Milestone Name]" $ARCHIVE_QUICK_FLAG)
|
||||
```
|
||||
|
||||
The CLI handles:
|
||||
@@ -520,11 +530,16 @@ The CLI handles:
|
||||
- Moving audit file to milestones if it exists
|
||||
- Creating/appending MILESTONES.md entry with accomplishments from SUMMARY.md files
|
||||
- Updating STATE.md (status, last activity)
|
||||
- When `ARCHIVE_QUICK_FLAG` is `--archive-quick`: moving every directory under `.planning/quick/` into `.planning/milestones/v[X.Y]-quick/`, writing a `README.md` index into that archive directory (generated by scanning the archive directory itself), and clearing the data rows of STATE.md's `### Quick Tasks Completed` table — preserving the table's header and whichever column variant (with/without a Status column) was detected
|
||||
|
||||
Extract from result: `version`, `date`, `phases`, `plans`, `tasks`, `accomplishments`, `archived`.
|
||||
|
||||
Verify: `✅ Milestone archived to .planning/milestones/`
|
||||
|
||||
**Known limit (quick-task archival):** there is no on-disk provenance recording which milestone a given quick task belonged to. Archival buckets **all** remaining `.planning/quick/*` into the completing milestone — a quick task that predates an earlier, unarchived milestone lands in the current bucket regardless.
|
||||
|
||||
Verify after `--archive-quick` was passed: `✅ Quick tasks archived to .planning/milestones/v[X.Y]-quick/`
|
||||
|
||||
**Phase archival (default-on):** `milestone complete` archives phase directories to `milestones/v[X.Y]-phases/` by default (#1871), so the next `/gsd:new-milestone` never inherits un-archived dirs. No manual `mkdir`/`mv` or `--archive-phases` flag is needed.
|
||||
|
||||
If the user explicitly wants to keep phase directories in place as raw execution history, invoke `milestone complete` with `--no-archive-phases`:
|
||||
@@ -535,8 +550,6 @@ gsd_run query milestone complete v[X.Y] --no-archive-phases
|
||||
|
||||
Verify after a default (archived) completion: `✅ Phase directories archived to .planning/milestones/v[X.Y]-phases/`
|
||||
|
||||
**Text mode (`workflow.text_mode: true` in config or `--text` flag):** Set `TEXT_MODE=true` if `--text` is present in `$ARGUMENTS` OR `text_mode` from init JSON is `true`. When TEXT_MODE is active, replace every `AskUserQuestion` call with a plain-text numbered list and ask the user to type their choice number. This is required for non-Claude runtimes (OpenAI Codex, Gemini CLI, etc.) where `AskUserQuestion` is not available.
|
||||
|
||||
After archival, the AI still handles:
|
||||
- Reorganizing ROADMAP.md with milestone grouping (requires judgment) — overwrite in place after extracting Backlog section, with the write-guard's single-use sentinel armed first (a per-step env var cannot reach a hook — see the reorganize step for the sentinel mechanics)
|
||||
- Full PROJECT.md evolution review (requires understanding)
|
||||
|
||||
@@ -19,7 +19,7 @@ import { collectSection } from './markdown-sectionizer.cjs';
|
||||
import { splitLines } from './text-lines.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir } = planningWorkspace;
|
||||
const { planningDir, quickDirFrom } = planningWorkspace;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatter = require('./frontmatter.cjs');
|
||||
const { extractFrontmatter, spliceFrontmatter } = frontmatter;
|
||||
@@ -530,7 +530,9 @@ function resolveQuickTaskSummaryFile(taskDir: string, dirName: string): string |
|
||||
* Incomplete if SUMMARY.md missing or status !== 'complete'.
|
||||
*/
|
||||
function scanQuickTasks(planDir: string): ScanOutcome<QuickTaskItem> {
|
||||
const quickDir = path.join(planDir, 'quick');
|
||||
// #2142: routed through the shared quickDirFrom composer (planning-workspace.cts)
|
||||
// so `.planning/quick` has exactly ONE owner instead of two ad-hoc path.joins.
|
||||
const quickDir = quickDirFrom(planDir);
|
||||
if (!fs.existsSync(quickDir)) return { items: [], acknowledged: 0 };
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
@@ -1682,4 +1684,7 @@ export = {
|
||||
formatAuditReport,
|
||||
listAuditPhaseTargets,
|
||||
cmdAuditAcknowledge,
|
||||
// #2142: exported so src/milestone.cts's archiveQuickTaskDirectories README
|
||||
// index generator shares this ONE discovery rule rather than re-deriving it.
|
||||
resolveQuickTaskSummaryFile,
|
||||
};
|
||||
|
||||
@@ -720,6 +720,18 @@ export function escapeCell(value: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared sentinel `reason` returned by both `appendQuickTaskRow` and
|
||||
* `resetQuickTaskRows` when the "Quick Tasks Completed" heading is absent
|
||||
* from `stateContent` (#2142). The section is created lazily by
|
||||
* `gsd-core/workflows/quick.md` Step 7b and is absent from
|
||||
* `gsd-core/templates/state.md`, so an absent section is the common case,
|
||||
* not an anomaly — callers compare against this constant rather than
|
||||
* matching on the free-form reason string (CONTRIBUTING.md "Prohibited:
|
||||
* Raw Text Matching").
|
||||
*/
|
||||
export const QUICK_TASKS_SECTION_ABSENT = 'no Quick Tasks Completed section';
|
||||
|
||||
/** Fields needed to render one "Quick Tasks Completed" row (schema-driven). */
|
||||
export interface QuickTaskFields {
|
||||
description: string;
|
||||
@@ -754,7 +766,7 @@ export function appendQuickTaskRow(
|
||||
): Result<{ content: string; row: string; variant: string }> {
|
||||
const section = collectSection(stateContent, (h) => /^quick tasks completed$/i.test(h.text.trim()));
|
||||
if (!section) {
|
||||
return { ok: false, reason: 'no Quick Tasks Completed section' };
|
||||
return { ok: false, reason: QUICK_TASKS_SECTION_ABSENT };
|
||||
}
|
||||
|
||||
const parsed = parseMarkdownTable(section.body);
|
||||
@@ -810,5 +822,94 @@ export function appendQuickTaskRow(
|
||||
return { ok: true, value: { content, row, variant: match.label } };
|
||||
}
|
||||
|
||||
// ─── resetQuickTaskRows (#2142) ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clear every DATA row from STATE.md's "Quick Tasks Completed" table, leaving
|
||||
* the header + delimiter lines byte-identical, for use at milestone close when
|
||||
* `--archive-quick` has actually moved the underlying `.planning/quick/*`
|
||||
* directories out from under the table (see `src/milestone.cts`'s
|
||||
* `archiveQuickTaskDirectories` / `cmdMilestoneComplete` wiring).
|
||||
*
|
||||
* Mirrors `appendQuickTaskRow`'s exact contract (same `collectSection` ->
|
||||
* `parseMarkdownTable` -> `matchTableSchema` pipeline, same fail-loud posture,
|
||||
* same EOL-detect-before-split handling) rather than inventing a second one:
|
||||
* - no "Quick Tasks Completed" heading -> `{ok:false, reason:
|
||||
* QUICK_TASKS_SECTION_ABSENT}` (no-op; a STATE.md without the section has
|
||||
* nothing to reset — per #2142 design doc §40, behavior table row 5, the
|
||||
* section is created lazily by quick.md Step 7b and is absent from
|
||||
* templates/state.md, so absence is the common path, not an anomaly.
|
||||
* Callers MUST treat this sentinel as silent — never surface it as a
|
||||
* `preservation_warnings` entry).
|
||||
* - the section body doesn't parse as a GFM table -> `{ok:false, reason}`.
|
||||
* - the table's header doesn't match a known `TABLE_SCHEMAS.QuickTasks`
|
||||
* variant -> `{ok:false, reason}` and — CRITICAL — no modification at
|
||||
* all. A user-added column means the data can't be safely addressed by
|
||||
* name, so clearing it would destroy rows under a schema we don't
|
||||
* understand (Postel's Law: liberal in accepting known shapes,
|
||||
* conservative about destroying what we don't).
|
||||
*/
|
||||
export function resetQuickTaskRows(
|
||||
stateContent: string,
|
||||
): Result<{ content: string; cleared: number; variant: string }> {
|
||||
if (typeof stateContent !== 'string' || stateContent.trim() === '') {
|
||||
return { ok: false, reason: 'empty or non-string input' };
|
||||
}
|
||||
|
||||
const section = collectSection(stateContent, (h) => /^quick tasks completed$/i.test(h.text.trim()));
|
||||
if (!section) {
|
||||
return { ok: false, reason: QUICK_TASKS_SECTION_ABSENT };
|
||||
}
|
||||
|
||||
const parsed = parseMarkdownTable(section.body);
|
||||
if (!parsed.ok) {
|
||||
return { ok: false, reason: `quick-tasks table: ${parsed.reason}` };
|
||||
}
|
||||
|
||||
const match = matchTableSchema(parsed.value.columns);
|
||||
if (!match || match.id !== 'QuickTasks') {
|
||||
// Refuse the reset — keep every row, caller-owned content is untouched.
|
||||
return {
|
||||
ok: false,
|
||||
reason: `unrecognized Quick Tasks schema (columns: ${parsed.value.columns.join(' | ')})`,
|
||||
};
|
||||
}
|
||||
|
||||
const cleared = parsed.value.rows.length;
|
||||
|
||||
// Detect the section's EOL BEFORE splitting on /\r?\n/ (which discards it) —
|
||||
// exactly `appendQuickTaskRow`'s convention — so a CRLF document is not
|
||||
// downgraded to mixed EOL by the rejoin below.
|
||||
const eol = /\r\n/.test(section.body) ? '\r\n' : '\n';
|
||||
const lines = section.body.split(/\r?\n/);
|
||||
|
||||
let headerIdx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().startsWith('|')) { headerIdx = i; break; }
|
||||
}
|
||||
// headerIdx is always found here — parseMarkdownTable already confirmed a
|
||||
// header + delimiter row exist in this same `section.body`.
|
||||
|
||||
let lastTableLineIdx = headerIdx + 1; // delimiter row, when there are zero data rows
|
||||
for (let i = headerIdx + 2; i < lines.length; i++) {
|
||||
if (!lines[i].trim().startsWith('|')) break;
|
||||
lastTableLineIdx = i;
|
||||
}
|
||||
|
||||
// Keep the header + delimiter lines [0 .. headerIdx+1] plus everything
|
||||
// after the contiguous run of `|`-prefixed data rows — dropping only the
|
||||
// data rows themselves. Non-table content before/after the table inside
|
||||
// the section is preserved untouched.
|
||||
const newLines = [
|
||||
...lines.slice(0, headerIdx + 2),
|
||||
...lines.slice(lastTableLineIdx + 1),
|
||||
];
|
||||
const newBody = newLines.join(eol);
|
||||
|
||||
const content = replaceSection(stateContent, section, newBody);
|
||||
|
||||
return { ok: true, value: { content, cleared, variant: match.label } };
|
||||
}
|
||||
|
||||
// Consumers: require('../gsd-core/bin/lib/markdown-table.cjs')
|
||||
// Named CJS exports are the canonical surface (ADR-457 .cts → .cjs build-at-publish).
|
||||
|
||||
@@ -20,7 +20,11 @@ import { realClock } from './clock.cjs';
|
||||
import { transitionCore } from './state-transition.cjs';
|
||||
import { writeSetComplete } from './write-set.cjs';
|
||||
import type { WriteSet } from './write-set.cjs';
|
||||
import { updateTableCell } from './markdown-table.cjs';
|
||||
import { updateTableCell, resetQuickTaskRows, QUICK_TASKS_SECTION_ABSENT } from './markdown-table.cjs';
|
||||
import { requireSafePath } from './security.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- audit.cjs is an export= CommonJS module
|
||||
import auditMod = require('./audit.cjs');
|
||||
const { resolveQuickTaskSummaryFile } = auditMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
const { output, error } = ioMod;
|
||||
@@ -56,7 +60,7 @@ const { extractFrontmatter } = frontmatterMod;
|
||||
// divergence signal). Routed through the single write-seam composition
|
||||
// (`syncAndPreserveStateMd`) instead, under `withStateLock` — see
|
||||
// `cmdMilestoneComplete`'s own STATE.md-update block for the full rationale.
|
||||
const { syncAndPreserveStateMd, withStateLock } = stateMod;
|
||||
const { syncAndPreserveStateMd, withStateLock, readModifyWriteStateMd } = stateMod;
|
||||
|
||||
// #2288 security: a milestone version label becomes a filesystem directory
|
||||
// component (`milestones/<label>-phases/`) into which phase directories are
|
||||
@@ -72,6 +76,11 @@ interface MilestoneCompleteOptions {
|
||||
force?: boolean;
|
||||
archivePhases?: boolean;
|
||||
dryRun?: 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
|
||||
// archivePhases' inverted `--no-archive-phases` shape.
|
||||
archiveQuick?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -510,6 +519,33 @@ function cmdRequirementsRevertPhase(cwd: string, reqIdsRaw: string[], raw: boole
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #2142 (code-review FIX 4): the single owned "should the Quick Tasks
|
||||
* Completed table be reset, and is a reset failure worth a warning" decision
|
||||
* — shared by `cmdMilestoneComplete` (which folds this into its own
|
||||
* `withStateLock` transform, since it already holds that lock for the
|
||||
* closure-transition write happening in the same block) and `cmdQuickArchive`
|
||||
* (which routes through `readModifyWriteStateMd`'s own transform instead, per
|
||||
* the lock-reentrancy note on that function). Only the WRITE mechanics
|
||||
* differ between the two callers — the decision itself ("skip a
|
||||
* `QUICK_TASKS_SECTION_ABSENT` result silently; surface any other failure")
|
||||
* was previously duplicated verbatim at both call sites.
|
||||
*
|
||||
* Never throws: a reset failure degrades to returning `content` unchanged
|
||||
* with a non-null `warning`, mirroring both callers' pre-existing
|
||||
* "liberal but visible" posture.
|
||||
*/
|
||||
function applyQuickTasksReset(content: string): { content: string; warning: { field: string; reason: string } | null } {
|
||||
const resetResult = resetQuickTaskRows(content);
|
||||
if (resetResult.ok) {
|
||||
return { content: resetResult.value.content, warning: null };
|
||||
}
|
||||
if (resetResult.reason !== QUICK_TASKS_SECTION_ABSENT) {
|
||||
return { content, warning: { field: 'quick_tasks_table', reason: resetResult.reason } };
|
||||
}
|
||||
return { content, warning: null };
|
||||
}
|
||||
|
||||
function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCompleteOptions, raw: boolean): void {
|
||||
if (!version) {
|
||||
error('version required for milestone complete (e.g., v1.0)');
|
||||
@@ -777,6 +813,14 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
// pass below would move.
|
||||
phaseDirsToArchive.push(...listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: version }).value);
|
||||
}
|
||||
// #2142 MAJOR 5 (review): dry-run preview of quick-task archival —
|
||||
// read-only, routed through the SAME `listQuickTaskDirsForArchive`
|
||||
// selection `archiveQuickTaskDirectories` uses for real (directory
|
||||
// entries only, `requireSafePath`-guarded, sorted) so this preview can
|
||||
// never disagree with what a real run actually archives. Absent
|
||||
// --archive-quick this stays `[]` and nothing on disk is touched either
|
||||
// way (dry-run always returns before any mutation below).
|
||||
const quickDirsToArchive: string[] = options.archiveQuick ? listQuickTaskDirsForArchive(cwd) : [];
|
||||
const dryRunResult = {
|
||||
dry_run: true,
|
||||
version,
|
||||
@@ -794,6 +838,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
? { source: path.relative(cwd, path.join(planningBase, `${version}-MILESTONE-AUDIT.md`)).split(path.sep).join('/'), target: path.relative(cwd, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)).split(path.sep).join('/') }
|
||||
: null,
|
||||
phases: phaseDirsToArchive,
|
||||
quick: quickDirsToArchive,
|
||||
},
|
||||
would_update: {
|
||||
milestones_md: path.relative(cwd, milestonesPath).split(path.sep).join('/'),
|
||||
@@ -868,6 +913,25 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
|
||||
}
|
||||
|
||||
// #2142 BLOCKER 2 (review): opt-in quick-task archival. This call MUST sit
|
||||
// immediately adjacent to the STATE.md write block directly below it, with
|
||||
// NO unguarded IO in between (unlike the ROADMAP/REQUIREMENTS/audit/
|
||||
// MILESTONES.md writes above, none of which are wrapped in a try/catch).
|
||||
// If the move ran earlier — e.g. right after `platformEnsureDir(archiveDir)`
|
||||
// — and any one of those unguarded writes then threw, the quick-task
|
||||
// directories would already be gone from `.planning/quick/` while the
|
||||
// STATE.md Quick Tasks table reset (which lives inside `withStateLock`
|
||||
// immediately below) would never be reached. That is precisely the
|
||||
// STATE-vs-disk drift #2142 exists to eliminate: a table still describing
|
||||
// directories that no longer exist. Keeping the move and the reset
|
||||
// adjacent — separated only by this comment, never by IO that can throw —
|
||||
// means either both happen or (if the move itself throws) neither does.
|
||||
// `archiveQuick` is opt-in (default OFF); absent the flag this is `null`
|
||||
// and every downstream read of it degrades to "no quick archival happened".
|
||||
const quickArchiveResult = options.archiveQuick
|
||||
? archiveQuickTaskDirectories(cwd, version)
|
||||
: null;
|
||||
|
||||
// Update STATE.md — keep frontmatter/body semantically aligned after closure.
|
||||
// ADR-1769 Phase 5: dispatches to the STATE.md Transition Module. The closure
|
||||
// write (Status, Last Activity, Last Activity Description, Current Position
|
||||
@@ -930,9 +994,37 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
if (typeof preCurrentPhaseName === 'string' && preCurrentPhaseName.trim().length > 0) {
|
||||
authoritativeFm['current_phase_name'] = preCurrentPhaseName;
|
||||
}
|
||||
|
||||
// #2142: fold the Quick Tasks table reset into this SAME
|
||||
// `withStateLock` transform — no second lock acquisition, no second
|
||||
// `syncAndPreserveStateMd`/`platformWriteSync` pass. Only applied when
|
||||
// quick archival actually MOVED something (never when the flag was
|
||||
// absent, and never for a mere dry-run preview, which never reaches
|
||||
// here at all). A refused reset degrades to leaving the content
|
||||
// untouched and never fails milestone completion, but the two refusal
|
||||
// shapes are NOT equally noteworthy (design doc §40, behavior table
|
||||
// row 5): an ABSENT "Quick Tasks Completed" section is the normal,
|
||||
// common case — the section is created lazily by
|
||||
// `gsd-core/workflows/quick.md` Step 7b, not by
|
||||
// `gsd-core/templates/state.md`, so most projects simply don't have
|
||||
// one — and is silently skipped (compared via the shared
|
||||
// `QUICK_TASKS_SECTION_ABSENT` sentinel, never by matching on the
|
||||
// free-form reason string). A section that EXISTS but couldn't be
|
||||
// reset (unparseable table, or columns matching neither registered
|
||||
// QuickTasks variant) is a genuine anomaly and IS surfaced via
|
||||
// `preservationWarnings`, the same "liberal but visible" posture the
|
||||
// rest of this block already uses for a disagreeing derived STATE.md
|
||||
// value.
|
||||
let quickTasksResetContent = result.content;
|
||||
if (quickArchiveResult && quickArchiveResult.archived > 0) {
|
||||
const { content: resetContent, warning } = applyQuickTasksReset(quickTasksResetContent);
|
||||
quickTasksResetContent = resetContent;
|
||||
if (warning) preservationWarnings.push(warning);
|
||||
}
|
||||
|
||||
const finalContent = syncAndPreserveStateMd(
|
||||
originalStateContent,
|
||||
result.content,
|
||||
quickTasksResetContent,
|
||||
statePath,
|
||||
cwd,
|
||||
{
|
||||
@@ -1003,6 +1095,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
requirements: fs.existsSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`)),
|
||||
audit: fs.existsSync(path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
|
||||
phases: phasesArchived,
|
||||
quick: !!quickArchiveResult && quickArchiveResult.archived > 0,
|
||||
},
|
||||
milestones_updated: true,
|
||||
state_updated: fs.existsSync(statePath),
|
||||
@@ -1183,10 +1276,446 @@ function archivePhaseDirectories(cwd: string, phasesDir: string, dirs: ReadonlyA
|
||||
return { archiveDir: archivePhasesDir, archived };
|
||||
}
|
||||
|
||||
/**
|
||||
* #2142 BLOCKER 1 (review): escape one directory-name span for insertion as
|
||||
* markdown LINK TEXT (`[...]`) — a directory name containing a literal `|`,
|
||||
* `[` or `]` must not be able to break the enclosing markdown. `mkdirSync`
|
||||
* accepts an embedded newline in a directory name on POSIX (and `isDirectory()`
|
||||
* still reports true for it), so an unescaped newline would let attacker-
|
||||
* controlled content — including a markdown HEADING — land verbatim in the
|
||||
* generated README.md, an indirect prompt-injection vector for any agent
|
||||
* workflow step that later reads that file. Mirrors `escapeCell`'s exact
|
||||
* convention (markdown-table.cts `escapeCell`): collapse `\r?\n+` to a single
|
||||
* space FIRST (so a newline can never re-enter the output as a line break),
|
||||
* THEN escape the escape char itself (before the rest, so a literal backslash
|
||||
* in the name is never mistaken for part of an escape sequence this function
|
||||
* introduces), THEN the markdown-syntax characters.
|
||||
*/
|
||||
function escapeMarkdownLinkText(text: string): string {
|
||||
return text
|
||||
.replace(/\r?\n+/g, ' ')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\|/g, '\\|')
|
||||
.replace(/\[/g, '\\[')
|
||||
.replace(/\]/g, '\\]');
|
||||
}
|
||||
|
||||
/**
|
||||
* #2142 BLOCKER 1 (review): encode one path span for insertion as a markdown
|
||||
* link DESTINATION (`(...)`) — `relSummary` is built from a directory name
|
||||
* that may legally contain a space, a `(`/`)`, or a control character
|
||||
* (including an embedded newline) on POSIX. Per CommonMark, an unbracketed
|
||||
* link destination terminates at the first ASCII space/control character and
|
||||
* requires parens to be balanced or escaped — any of those would truncate or
|
||||
* corrupt the link, or let attacker-controlled content spill out of the
|
||||
* `(...)` span into the surrounding markdown (the same indirect
|
||||
* prompt-injection vector `escapeMarkdownLinkText` guards the link TEXT
|
||||
* against). Percent-encodes just the unsafe set (space, `(`, `)`, and C0
|
||||
* control chars incl. `\r`/`\n`, plus DEL) rather than switching to the
|
||||
* angle-bracket `<...>` destination form — percent-encoding is reversible (a
|
||||
* markdown viewer resolving the link still reaches the right file) and does
|
||||
* not introduce a new pair of syntax characters (`<`/`>`) that would in turn
|
||||
* need their own escaping.
|
||||
*/
|
||||
function encodeMarkdownLinkTarget(target: string): string {
|
||||
return target.replace(/[\x00-\x1f\x7f ()]/g, (ch) => `%${ch.charCodeAt(0).toString(16).padStart(2, '0').toUpperCase()}`);
|
||||
}
|
||||
|
||||
interface QuickArchiveIndexEntry {
|
||||
/** Escaped for markdown LINK TEXT (`escapeMarkdownLinkText`) — see below. */
|
||||
name: string;
|
||||
/**
|
||||
* POSIX-relative path (from `archiveQuickDir`) to the task's summary file,
|
||||
* NOT yet percent-encoded for markdown link-destination use — `render()`
|
||||
* applies `encodeMarkdownLinkTarget` at render time. `null` when the task
|
||||
* has no resolvable summary file.
|
||||
*/
|
||||
summary: string | null;
|
||||
}
|
||||
|
||||
interface QuickArchiveIndex {
|
||||
entries: QuickArchiveIndexEntry[];
|
||||
/** Render the entries as the `README.md` markdown body. */
|
||||
render(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2142 (code-review FIX 2): PURE builder — scans `archiveQuickDir` and
|
||||
* resolves each entry's summary link, but performs NO IO beyond the read
|
||||
* scan itself; never writes. Split out of the former `writeQuickArchiveReadme`
|
||||
* so tests can assert on the returned structured IR (`entries`) instead of
|
||||
* substring-matching rendered markdown (CONTRIBUTING.md "Prohibited: Raw Text
|
||||
* Matching on Test Outputs" — a generated archive index is a "Rendered file",
|
||||
* which requires a pure builder returning IR, not the `.md`-IS-the-runtime-
|
||||
* artifact exemption).
|
||||
*
|
||||
* (re)generates an index of every quick-task directory PHYSICALLY PRESENT in
|
||||
* the archive, built by scanning the ARCHIVE directory on disk. Deliberately
|
||||
* NOT built from STATE.md's Quick Tasks table (the issue evidenced that table
|
||||
* drifting — 53 rows against 49 dirs, ~22 rows pointing at absent dirs, 18
|
||||
* dirs missing from the table — the filesystem is the only source of truth)
|
||||
* and NOT from the pre-move source list either, so a RE-RUN's index includes
|
||||
* entries a PRIOR run already archived, not just this run's (design row 11).
|
||||
*
|
||||
* Each entry's summary link is resolved via `resolveQuickTaskSummaryFile`
|
||||
* (audit.cts) — the SAME rule `scanQuickTasks` uses to read a task's record
|
||||
* — imported rather than re-derived, so the read and write paths can never
|
||||
* disagree about which file is a task's summary. A task WITHOUT a summary is
|
||||
* still listed, just without a link — never omitted (an omission would
|
||||
* under-report the index, which is worse than an unlinked entry).
|
||||
*
|
||||
* Entries are sorted for deterministic output. A directory name containing
|
||||
* `|`, `[`, `]` or an embedded newline is neutralized via
|
||||
* `escapeMarkdownLinkText` (link TEXT) so it cannot break the generated
|
||||
* markdown or inject a heading — applied here, at build time, so `entries`
|
||||
* itself already carries the injection-safe name (the regression test
|
||||
* asserts on THIS, not on rendered output). The destination is separately
|
||||
* encoded via `encodeMarkdownLinkTarget` (link TARGET) at RENDER time, so a
|
||||
* space/paren/control char in the name cannot truncate or corrupt the
|
||||
* `(...)` span. The summary path is normalized to POSIX
|
||||
* (`.split(path.sep).join('/')`) so the link is stable across platforms.
|
||||
*
|
||||
* Throws when `archiveQuickDir` is unreadable — the caller (`writeQuickArchiveReadme`)
|
||||
* is the best-effort boundary, not this builder.
|
||||
*/
|
||||
function buildQuickArchiveIndex(archiveQuickDir: string): QuickArchiveIndex {
|
||||
const dirEntries = fs.readdirSync(archiveQuickDir, { withFileTypes: true });
|
||||
const dirNames = dirEntries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
const entries: QuickArchiveIndexEntry[] = dirNames.map((dirName) => {
|
||||
const taskDir = path.join(archiveQuickDir, dirName);
|
||||
const summaryPath = resolveQuickTaskSummaryFile(taskDir, dirName);
|
||||
const escapedName = escapeMarkdownLinkText(dirName);
|
||||
if (summaryPath) {
|
||||
const relSummary = path.relative(archiveQuickDir, summaryPath).split(path.sep).join('/');
|
||||
return { name: escapedName, summary: relSummary };
|
||||
}
|
||||
// No summary file — list the directory, but never link into it (there
|
||||
// is nothing to point at). See indexListsTaskWithoutSummaryWithoutLink.
|
||||
return { name: escapedName, summary: null };
|
||||
});
|
||||
|
||||
return {
|
||||
entries,
|
||||
render(): string {
|
||||
const lines: string[] = ['# Archived Quick Tasks', ''];
|
||||
for (const entry of entries) {
|
||||
if (entry.summary !== null) {
|
||||
lines.push(`- [${entry.name}](${encodeMarkdownLinkTarget(entry.summary)})`);
|
||||
} else {
|
||||
lines.push(`- ${entry.name}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* #2142: thin writer — calls `buildQuickArchiveIndex` and writes its
|
||||
* `render()` output to `<archiveQuickDir>/README.md`. No-ops (writes
|
||||
* nothing) when `archiveQuickDir` is unreadable — this is a best-effort
|
||||
* index, not a gate on milestone completion. #2142 MAJOR 4 (review): the
|
||||
* whole body is wrapped in a try/catch so a failure of the WRITE itself
|
||||
* (read-only archive dir, full disk, or a quick-task directory literally
|
||||
* named `README.md` colliding with the file being written) degrades the same
|
||||
* way — this function genuinely cannot throw, matching its own
|
||||
* "best-effort, not a gate" contract; the directories are already safely
|
||||
* archived by the time this runs.
|
||||
*/
|
||||
function writeQuickArchiveReadme(archiveQuickDir: string): void {
|
||||
try {
|
||||
const index = buildQuickArchiveIndex(archiveQuickDir);
|
||||
platformWriteSync(path.join(archiveQuickDir, 'README.md'), index.render());
|
||||
} catch {
|
||||
/* best-effort (#2142 MAJOR 4): a read-only archive dir, a full disk, or a
|
||||
* quick-task directory literally named `README.md` colliding with the
|
||||
* file this function writes must never crash `milestone complete` —
|
||||
* the quick-task directories are already safely archived on disk by the
|
||||
* time this index-generation step runs. */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #2142 MAJOR 5 (review): the single owned selection rule for "which
|
||||
* directories under `.planning/quick/` would/will move" — directory entries
|
||||
* only (symlinks are excluded here, per the MAJOR 3 note above), each
|
||||
* additionally guarded with `requireSafePath` (the same guard
|
||||
* `scanQuickTasks`/`archiveQuickTaskDirectories` use), sorted for
|
||||
* deterministic output. Extracted so `cmdMilestoneComplete`'s dry-run
|
||||
* preview, `cmdQuickArchive`'s dry-run preview, and the REAL selection inside
|
||||
* `archiveQuickTaskDirectories` all call this ONE function instead of each
|
||||
* re-deriving the rule — the "Generative Fix Divergence" anti-pattern this
|
||||
* repo explicitly guards against (a prior version of this code had the rule
|
||||
* written three times, and only the real-run copy applied `requireSafePath`,
|
||||
* so a dry-run preview could list a directory the real run would silently
|
||||
* skip).
|
||||
*/
|
||||
function listQuickTaskDirsForArchive(cwd: string): string[] {
|
||||
const planningBase = planningPaths(cwd).planning;
|
||||
const quickDir = planningPaths(cwd).quick;
|
||||
let sourceEntries: fs.Dirent[];
|
||||
try {
|
||||
sourceEntries = fs.readdirSync(quickDir, { withFileTypes: true });
|
||||
} catch {
|
||||
// .planning/quick absent or unreadable — nothing to select.
|
||||
return [];
|
||||
}
|
||||
const names: string[] = [];
|
||||
for (const entry of sourceEntries) {
|
||||
if (!entry.isDirectory()) continue; // excludes symlinks too — see MAJOR 3 note above
|
||||
try {
|
||||
requireSafePath(path.join(quickDir, entry.name), planningBase, 'quick task dir', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue; // symlink/escape attempt — never a candidate, in preview OR real run
|
||||
}
|
||||
names.push(entry.name);
|
||||
}
|
||||
return names.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* #2142: move each DIRECTORY entry under `.planning/quick/` into
|
||||
* `milestones/<version>-quick/` (collision-safe), then (re)write that
|
||||
* archive directory's README.md index. Sibling of `archivePhaseDirectories`
|
||||
* — extracted rather than inlined into `cmdMilestoneComplete` (already
|
||||
* cyclomatic 61) — mirroring its collision-safe destination-suffix loop,
|
||||
* `retryRenameSync`, and `platformEnsureDir` usage.
|
||||
*
|
||||
* `version` is ALREADY validated by `ARCHIVE_VERSION_LABEL_RE` at
|
||||
* `cmdMilestoneComplete`'s entry — this helper does not re-validate it, and
|
||||
* must only ever be called after that guard has run.
|
||||
*
|
||||
* #2142 MAJOR 3 (review): a symlink under `.planning/quick/` — even one that
|
||||
* targets a directory — is excluded by the `dirEntries` filter below
|
||||
* (`fs.Dirent.isDirectory()` returns FALSE for a symlink, regardless of what
|
||||
* it points at), so it is never a candidate `entry` in the first place and
|
||||
* `requireSafePath` below never runs against it. `requireSafePath` is
|
||||
* retained here as defense-in-depth for the NON-symlink path (a real
|
||||
* directory entry whose resolved path still needs re-validating against
|
||||
* `planningBase`) — the SAME guard `scanQuickTasks` (audit.cts) uses — so an
|
||||
* entry that fails it is skipped, never archived, never counted. See the
|
||||
* symlink regression tests in tests/milestone-archive.test.cjs
|
||||
* (`symlinkEscapeIsNeverArchivedByMilestoneComplete` /
|
||||
* `symlinkEscapeIsNeverArchivedByQuickArchive`) for a fixture proving neither
|
||||
* the symlink nor its external target is ever moved or altered — added
|
||||
* specifically so a future change to this filter cannot silently reopen the
|
||||
* escape with nothing to catch it.
|
||||
*
|
||||
* No-op (returns `{archived: 0, entries: []}`, creates NOTHING on disk) when
|
||||
* `.planning/quick/` does not exist or contains zero DIRECTORY entries — a
|
||||
* stray file with no sibling directory is neither an empty-dir case nor an
|
||||
* archive case.
|
||||
*
|
||||
* A mid-loop rename failure (or a failure to create the archive directory
|
||||
* itself) does not crash `milestone complete` — it degrades to whatever
|
||||
* `archived`/`entries` had already accumulated before the failure, mirroring
|
||||
* the `archivedCount` finally-pattern `cmdMilestoneComplete`'s own phase
|
||||
* archival uses a few hundred lines above (so a partial archive reports the
|
||||
* TRUE count, never a false `0`/`false`).
|
||||
*/
|
||||
function archiveQuickTaskDirectories(cwd: string, version: string): { archiveDir: string; archived: number; entries: string[] } {
|
||||
const planningBase = planningPaths(cwd).planning;
|
||||
const quickDir = planningPaths(cwd).quick;
|
||||
const archiveQuickDir = path.join(planningBase, 'milestones', `${version}-quick`);
|
||||
|
||||
// #2142 MAJOR 5 (review): dirNames is the SAME selection
|
||||
// `listQuickTaskDirsForArchive` hands to both dry-run previews — this is
|
||||
// the real run, so it cannot disagree with what a preview reported.
|
||||
const dirNames = listQuickTaskDirsForArchive(cwd);
|
||||
if (dirNames.length === 0) {
|
||||
// Boundary 0 (#2142): zero (safe) directory entries (empty dir, only
|
||||
// stray files, or every entry excluded by the selection rule) must not
|
||||
// create the archive directory. Also covers `.planning/quick/` being
|
||||
// absent/unreadable — `listQuickTaskDirsForArchive` degrades to `[]`.
|
||||
return { archiveDir: archiveQuickDir, archived: 0, entries: [] };
|
||||
}
|
||||
|
||||
let archived = 0;
|
||||
const entries: string[] = [];
|
||||
try {
|
||||
platformEnsureDir(archiveQuickDir);
|
||||
for (const name of dirNames) {
|
||||
const src = path.join(quickDir, name);
|
||||
let safeSrc: string;
|
||||
try {
|
||||
// Re-validated here (not just trusted from the selection above) as
|
||||
// TOCTOU defense-in-depth: `listQuickTaskDirsForArchive` and this
|
||||
// rename are two separate filesystem observations, and an entry
|
||||
// that was a safe real directory at selection time could in theory
|
||||
// be swapped for a symlink before this loop reaches it.
|
||||
safeSrc = requireSafePath(src, planningBase, 'quick task dir', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue; // symlink/escape attempt — skip, not archived
|
||||
}
|
||||
|
||||
// Collision-safe: if a same-named archive entry exists (re-run), suffix it.
|
||||
let dest = path.join(archiveQuickDir, name);
|
||||
let destName = name;
|
||||
let n = 1;
|
||||
while (fs.existsSync(dest)) {
|
||||
destName = `${name}.${n++}`;
|
||||
dest = path.join(archiveQuickDir, destName);
|
||||
}
|
||||
retryRenameSync(safeSrc, dest);
|
||||
archived++;
|
||||
entries.push(destName);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort: platformEnsureDir failed, or the rename loop failed
|
||||
* partway — `archived`/`entries` above already reflect exactly what
|
||||
* succeeded before the failure (accumulated incrementally, never lost
|
||||
* with the swallowed exception — mirrors the archivedCount pattern at
|
||||
* cmdMilestoneComplete's phase-archival block). */
|
||||
}
|
||||
|
||||
// Regenerate the README from whatever is ACTUALLY on disk now — covers
|
||||
// both a clean full archive and a degraded partial one, and (on a re-run)
|
||||
// includes entries a PRIOR run already archived. Skipped only when the
|
||||
// archive directory itself was never created (ensureDir failed above).
|
||||
if (fs.existsSync(archiveQuickDir)) {
|
||||
writeQuickArchiveReadme(archiveQuickDir);
|
||||
}
|
||||
|
||||
return { archiveDir: archiveQuickDir, archived, entries };
|
||||
}
|
||||
|
||||
interface QuickArchiveOptions {
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2142 escalation: `milestone.archive-quick` (CLI: `milestone archive-quick`,
|
||||
* renamed from the original `quick.archive` per code-review FIX 1 — folded
|
||||
* under the existing `milestone` namespace rather than adding a new top-level
|
||||
* command) — the narrow archival helper the issue's own "Scope of changes"
|
||||
* anticipated ("a `quick.archive`-style routine"), for callers (chiefly
|
||||
* `gsd-core/workflows/cleanup.md`) that need to sweep
|
||||
* `.planning/quick/*` WITHOUT the full `milestone complete` close-out.
|
||||
*
|
||||
* `milestone complete --archive-quick` cannot be reused for this: it
|
||||
* hard-errors via `missingExplicitVersion` for an already-completed
|
||||
* milestone (no `### Phase N:` headings left in its ROADMAP window),
|
||||
* re-archives ROADMAP.md over the very snapshot cleanup depends on, and
|
||||
* appends a duplicate MILESTONES.md entry on every re-run.
|
||||
*
|
||||
* This command performs ONLY the two things `archiveQuickTaskDirectories`
|
||||
* already does (move `.planning/quick/*` dirs into
|
||||
* `milestones/<version>-quick/` + (re)write that archive's README index —
|
||||
* the SAME helper `cmdMilestoneComplete` calls, so the two entry points can
|
||||
* never diverge on step 1) plus a Quick Tasks Completed table reset. It
|
||||
* NEVER touches ROADMAP.md, REQUIREMENTS.md, or MILESTONES.md, and runs
|
||||
* NEITHER the unstarted-phase guard NOR the milestone-window/TRUNCATED
|
||||
* refusal — those remain `milestone complete`'s alone.
|
||||
*
|
||||
* #2142 MAJOR 6 (review): the STATE.md write now routes through
|
||||
* `readModifyWriteStateMd` — the same owned read-transform-write composition
|
||||
* `gsd-tools.cjs`'s `quick-tasks-append` handler uses (ADR-3408 §8.3 / #3469:
|
||||
* "the single owned composition ... so the composition cannot diverge"). A
|
||||
* prior version of this function called `platformWriteSync` directly with
|
||||
* the reset result, bypassing `syncAndPreserveStateMd` entirely — the exact
|
||||
* bypass shape that ADR closed. Per `src/state.cts:3289-3330`,
|
||||
* `readModifyWriteStateMd` ALREADY acquires its own exclusive lock
|
||||
* (`acquireStateLock`/`releaseStateLock`, a real `O_CREAT|O_EXCL` file lock,
|
||||
* not reentrant) across its own read -> transform -> write cycle — so, unlike
|
||||
* `cmdMilestoneComplete` (which folds the table reset into its own
|
||||
* pre-existing `withStateLock` transform because it ALSO needs that lock for
|
||||
* the closure-transition write happening in the same block), this function
|
||||
* must NOT wrap the call in its own `withStateLock`: doing so would acquire
|
||||
* the same lock file twice in the same process, and the second acquire would
|
||||
* spin against a lock this same call already holds until it times out.
|
||||
*/
|
||||
function cmdQuickArchive(cwd: string, version: string, options: QuickArchiveOptions, raw: boolean): void {
|
||||
if (!version) {
|
||||
error('version required for milestone.archive-quick (e.g., v1.0)');
|
||||
}
|
||||
// #2288-class security: `version` becomes a filesystem directory component
|
||||
// (`milestones/<version>-quick/`) that directories are MOVED into — same
|
||||
// guard + wording shape `cmdMilestoneComplete` uses for its own version arg.
|
||||
if (!ARCHIVE_VERSION_LABEL_RE.test(version)) {
|
||||
error(`milestone.archive-quick: version "${version}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
|
||||
}
|
||||
|
||||
const statePath = planningPaths(cwd).state;
|
||||
const planningBase = planningPaths(cwd).planning;
|
||||
const toPosixRel = (p: string): string => path.relative(cwd, p).split(path.sep).join('/');
|
||||
|
||||
// --dry-run: preview only, mutates nothing. #2142 MAJOR 5 (review): routed
|
||||
// through the SAME `listQuickTaskDirsForArchive` selection
|
||||
// `cmdMilestoneComplete`'s own dry-run preview and the real
|
||||
// `archiveQuickTaskDirectories` both use, so all three can never disagree.
|
||||
if (options.dryRun) {
|
||||
const quickDirsToArchive: string[] = listQuickTaskDirsForArchive(cwd);
|
||||
output(
|
||||
{
|
||||
dry_run: true,
|
||||
version,
|
||||
would_archive: quickDirsToArchive,
|
||||
archive_dir: toPosixRel(path.join(planningBase, 'milestones', `${version}-quick`)),
|
||||
},
|
||||
raw,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const quickArchiveResult = archiveQuickTaskDirectories(cwd, version);
|
||||
const warnings: Array<{ field: string; reason: string }> = [];
|
||||
let stateUpdated = false;
|
||||
|
||||
// Same silent/surfaced rule `cmdMilestoneComplete` applies: only attempt
|
||||
// the reset when something actually moved, and treat the
|
||||
// QUICK_TASKS_SECTION_ABSENT sentinel as a silent no-op (the section is
|
||||
// created lazily by quick.md Step 7b and absent from templates/state.md,
|
||||
// so absence is the common case, not an anomaly). Any other reset failure
|
||||
// is surfaced via `warnings`, never thrown.
|
||||
//
|
||||
// #2142 MAJOR 6 (review): routed through `readModifyWriteStateMd` (see the
|
||||
// docstring above) instead of a bare `platformWriteSync` — the transform
|
||||
// returns the ORIGINAL content unchanged whenever the reset did not apply
|
||||
// (sentinel-absent or a genuine failure), so `readModifyWriteStateMd`'s own
|
||||
// no-op guard (#948, state.cts:3304) skips the write and its `false`
|
||||
// return accurately reports "nothing was written" — the same "state_updated
|
||||
// must report accurately" contract the prior direct-write version upheld.
|
||||
if (quickArchiveResult.archived > 0 && fs.existsSync(statePath)) {
|
||||
let resetWarning: { field: string; reason: string } | null = null;
|
||||
stateUpdated = readModifyWriteStateMd(
|
||||
statePath,
|
||||
(content: string) => {
|
||||
const { content: nextContent, warning } = applyQuickTasksReset(content);
|
||||
resetWarning = warning;
|
||||
return nextContent;
|
||||
},
|
||||
cwd,
|
||||
);
|
||||
if (resetWarning) {
|
||||
warnings.push(resetWarning);
|
||||
}
|
||||
}
|
||||
|
||||
output(
|
||||
{
|
||||
version,
|
||||
archived: quickArchiveResult.archived,
|
||||
entries: quickArchiveResult.entries,
|
||||
archive_dir: toPosixRel(quickArchiveResult.archiveDir),
|
||||
state_updated: stateUpdated,
|
||||
warnings,
|
||||
},
|
||||
raw,
|
||||
`${quickArchiveResult.archived} quick task director${quickArchiveResult.archived === 1 ? 'y' : 'ies'} archived`,
|
||||
);
|
||||
}
|
||||
|
||||
export = {
|
||||
cmdRequirementsMarkComplete,
|
||||
cmdRequirementsReadyIds,
|
||||
cmdRequirementsRevertPhase,
|
||||
cmdMilestoneComplete,
|
||||
cmdPhasesClear,
|
||||
cmdQuickArchive,
|
||||
buildQuickArchiveIndex,
|
||||
};
|
||||
|
||||
@@ -167,6 +167,17 @@ interface PlanningPaths {
|
||||
phases: string;
|
||||
requirements: string;
|
||||
debug: string;
|
||||
quick: string;
|
||||
}
|
||||
|
||||
// #2142: the quick-task directory. Exported as its own function (not only as a
|
||||
// `planningPaths` key) because `audit.cts`'s `scanQuickTasks` receives an
|
||||
// already-resolved planning base rather than a `cwd`, so it cannot reach
|
||||
// `planningPaths`. Without this shared helper, adding the `quick` key would
|
||||
// leave TWO composers of `<planning>/quick` — the DEFECT.GENERATIVE-FIX shape
|
||||
// the `debug` key (#3149) was introduced to eliminate.
|
||||
function quickDirFrom(planningBase: string): string {
|
||||
return path.join(planningBase, 'quick');
|
||||
}
|
||||
|
||||
function planningPaths(cwd: string, ws?: string | null): PlanningPaths {
|
||||
@@ -183,6 +194,8 @@ function planningPaths(cwd: string, ws?: string | null): PlanningPaths {
|
||||
// `debug_dir` field and `init.debug`'s — previously each composed its own
|
||||
// `path.join(planning, 'debug')` (DEFECT.GENERATIVE-FIX).
|
||||
debug: path.join(base, 'debug'),
|
||||
// #2142: quick-task directory, composed via the shared quickDirFrom helper.
|
||||
quick: quickDirFrom(base),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -425,6 +438,7 @@ export = {
|
||||
planningRoot,
|
||||
listAvailableWorkstreams,
|
||||
planningPaths,
|
||||
quickDirFrom,
|
||||
withPlanningLock,
|
||||
getActiveWorkstream,
|
||||
setActiveWorkstream,
|
||||
|
||||
6
tests/emitted-drift-acks/2142-quick-task-archival.json
Normal file
6
tests/emitted-drift-acks/2142-quick-task-archival.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"cleanup.md": "#2142: adds retroactive quick-task archival — identify_quick_tasks step (target-milestone selection), dry-run summary + AskUserQuestion, archive_quick_tasks step using the narrow `milestone.archive-quick` command (not `milestone.complete --archive-quick`, which cannot be safely re-run against an already-completed milestone — see the step's own note), and success_criteria updates; the command was renamed from `quick.archive` to `milestone.archive-quick` (code-review FIX 1: fold under the existing `milestone` namespace, no new top-level command). 10319 -> 14941 bytes. (complete-milestone.md's #2142 growth is acknowledged in tests/emitted-drift-acks/3409-unreachable-guard-arms.json — two ack sources may never name the same path.)"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"paths": {
|
||||
"gsd-phase-researcher.md": "#3409: guarded `cat \"$phase_dir\"/*-CONTEXT.md` against nullglob wiping the pattern to zero operands when no CONTEXT.md exists — a bare `cat` with no operands blocks reading stdin (hangs the agent) instead of the `2>/dev/null` guard ever firing, since a stalled read is not a failing exit. Now checks `${_CTX[0]}` is a real path before invoking cat. Growth is the array-guard idiom itself (+43 bytes).",
|
||||
"gsd-verifier.md": "#3409: same nullglob-hang fix as gsd-phase-researcher.md, applied to `cat \"$PHASE_DIR\"/*-VERIFICATION.md` in Step 0 — an absent VERIFICATION.md previously left a zero-operand `cat` blocking on stdin instead of falling through to first-verification mode. Growth is the array-guard idiom (+49 bytes). — #3206 append (merged into this fragment because two ack sources may never name the same path): +52 bytes, 49098 -> 49150 (2 under the LARGE cap). The growth is the literal fix for the term 5b used undefined: the compressed explicit-evidence definition inlined at 5b (+34 net on the rewritten line — the trailing honest-verifier cite there is dropped as superseded by the inline definition; honest-verifier.md stays cited at 5c) plus gsd-core/ path-prefix repairs on the two 404ing bare references/ cites at 5c (honest-verifier.md) and the MVP-mode section (verify-mvp-mode.md) (+9 each). Lazy extraction remains untakeable in this change: the large extractable blocks are content-pinned by tests that read the agent file directly (tests/verifier-behavior-unverified.test.cjs, tests/verification-overrides.test.cjs), so extraction is its own coordinated change.",
|
||||
"complete-milestone.md": "#3409: guarded `cat .planning/phases/*-*/*-SUMMARY.md` — with `shopt -s nullglob` active in this block's preamble (#2962), zero matching phase summaries collapses the glob to nothing and a bare `cat` blocks reading stdin rather than producing empty output, wedging the milestone-completion review. Growth is the array-existence-check idiom (+73 bytes, two glob segments makes this longer than the single-glob sites).",
|
||||
"complete-milestone.md": "#3409: guarded `cat .planning/phases/*-*/*-SUMMARY.md` — with `shopt -s nullglob` active in this block's preamble (#2962), zero matching phase summaries collapses the glob to nothing and a bare `cat` blocks reading stdin rather than producing empty output, wedging the milestone-completion review. Growth is the array-existence-check idiom (+73 bytes, two glob segments makes this longer than the single-glob sites). — #2142 append (merged into this fragment because two ack sources may never name the same path): +1605 bytes, 40498 -> 42103. The `archive_milestone` step now documents the opt-in `--archive-quick` quick-task archival flag (default OFF, deliberately NOT symmetrical with phase archival's default-ON posture), folds the AskUserQuestion decision for it into the SAME `milestone.complete` invocation (avoiding a redundant second call), and states the known bucket-all provenance limit.",
|
||||
"discuss-phase-assumptions.md": "#3409: replaced the unreachable `AUTO_MODE=$(gsd_run query check auto-mode --pick active 2>/dev/null || echo \"false\")` — `||` never fires because the query exits 0 with empty stdout when the field is absent, not a failure, so AUTO_MODE silently ended up empty rather than \"false\" — with a two-line capture-then-default (`AUTO_MODE=\"${AUTO_MODE:-false}\"`) that actually reaches the fallback. Growth is the extra default-assignment line (+19 bytes).",
|
||||
"plan-phase.md": "#3409: three sites. `AUTO_CHAIN` and `PHASE_REQ_IDS` get the same unreachable-`||`-fallback fix as discuss-phase-assumptions.md (empty-but-successful `gsd_run query` output never triggered `|| echo`, now uses `${VAR:-default}`); `PRIOR_SUMMARIES` additionally swapped `gsd_run query phases.list --pick summaries_total` for `--type summaries --pick count` since the old pick key produced the same unreachable-fallback failure mode for the walking-skeleton check. Net growth across the three sites is +39 bytes.",
|
||||
"session-report.md": "#3409: guarded `ls -la .planning/reports/SESSION_REPORT*.md 2>/dev/null || echo \"No previous reports\"` — with nullglob active, zero prior reports collapses the pattern to nothing and `ls -la` with no operands lists the current directory (a successful exit, wrong output) instead of failing into the `|| echo` fallback, so the report-existence check silently printed a directory listing. Replaced with an array-existence check that only lists when a real report file is present. Growth is the guard idiom (+58 bytes).",
|
||||
|
||||
@@ -23,7 +23,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
|
||||
const { parseMarkdownTable, matchTableSchema, TABLE_SCHEMAS, appendQuickTaskRow, findTableBySchema, findTableWithColumns, updateTableCell, deleteTableRow } = require('../gsd-core/bin/lib/markdown-table.cjs');
|
||||
const { parseMarkdownTable, matchTableSchema, TABLE_SCHEMAS, appendQuickTaskRow, findTableBySchema, findTableWithColumns, updateTableCell, deleteTableRow, resetQuickTaskRows, QUICK_TASKS_SECTION_ABSENT } = require('../gsd-core/bin/lib/markdown-table.cjs');
|
||||
const { buildHeader, normalize } = require('../scripts/lint-table-schema-drift.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
@@ -319,7 +319,7 @@ describe('appendQuickTaskRow (#2133)', () => {
|
||||
const noSection = '# STATE\n\n### Blockers/Concerns\nNone\n';
|
||||
const result = appendQuickTaskRow(noSection, { description: 'x', date: '2026-07-13', commit: 'abc' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /no Quick Tasks Completed section/);
|
||||
assert.equal(result.reason, QUICK_TASKS_SECTION_ABSENT);
|
||||
});
|
||||
|
||||
test('boundary: next row number is 1 with zero data rows, 3 with two data rows', () => {
|
||||
@@ -989,3 +989,190 @@ describe('TABLE_SCHEMAS parity: registry headers must appear verbatim in their s
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resetQuickTaskRows (#2142) ─────────────────────────────────────────────
|
||||
|
||||
describe('resetQuickTaskRows (#2142)', () => {
|
||||
const noStatusState = [
|
||||
'# STATE',
|
||||
'',
|
||||
'### Quick Tasks Completed',
|
||||
'',
|
||||
'| # | Description | Date | Commit | Directory |',
|
||||
'|---|-------------|------|--------|-----------|',
|
||||
'| 1 | fix typo | 2026-01-01 | abc1234 | — |',
|
||||
'| 2 | bump version | 2026-01-02 | def5678 | — |',
|
||||
'',
|
||||
'### Blockers/Concerns',
|
||||
'None',
|
||||
].join('\n');
|
||||
|
||||
const withStatusState = [
|
||||
'# STATE',
|
||||
'',
|
||||
'### Quick Tasks Completed',
|
||||
'',
|
||||
'| # | Description | Date | Commit | Status | Directory |',
|
||||
'|---|-------------|------|--------|--------|-----------|',
|
||||
'| 1 | fix typo | 2026-01-01 | abc1234 | Pass | — |',
|
||||
'',
|
||||
'### Blockers/Concerns',
|
||||
'None',
|
||||
].join('\n');
|
||||
|
||||
const emptyNoStatusState = [
|
||||
'# STATE',
|
||||
'',
|
||||
'### Quick Tasks Completed',
|
||||
'',
|
||||
'| # | Description | Date | Commit | Directory |',
|
||||
'|---|-------------|------|--------|-----------|',
|
||||
'',
|
||||
'### Blockers/Concerns',
|
||||
'None',
|
||||
].join('\n');
|
||||
|
||||
test('reset clears all rows in a 5-col no-status table; header/delimiter byte-identical, variant no-status, cleared=2', () => {
|
||||
const beforeLines = noStatusState.split('\n');
|
||||
const result = resetQuickTaskRows(noStatusState);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.cleared, 2);
|
||||
assert.equal(result.value.variant, 'no-status');
|
||||
|
||||
const afterLines = result.value.content.split('\n');
|
||||
assert.equal(afterLines[4], beforeLines[4], 'header row byte-identical');
|
||||
assert.equal(afterLines[5], beforeLines[5], 'delimiter row byte-identical');
|
||||
assert.ok(!result.value.content.includes('fix typo'));
|
||||
assert.ok(!result.value.content.includes('bump version'));
|
||||
|
||||
const section = result.value.content.split('### Blockers/Concerns')[0];
|
||||
const reparsed = parseMarkdownTable(section);
|
||||
assert.equal(reparsed.ok, true);
|
||||
assert.equal(reparsed.value.rows.length, 0);
|
||||
});
|
||||
|
||||
test('reset preserves the 6-col with-status header; variant with-status', () => {
|
||||
const result = resetQuickTaskRows(withStatusState);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.variant, 'with-status');
|
||||
assert.equal(result.value.cleared, 1);
|
||||
assert.ok(!result.value.content.includes('fix typo'));
|
||||
|
||||
const headerLine = withStatusState.split('\n')[4];
|
||||
assert.ok(result.value.content.includes(headerLine), 'the 6-col header must survive verbatim');
|
||||
});
|
||||
|
||||
test('reset is a no-op on an already-empty Quick Tasks table', () => {
|
||||
const result = resetQuickTaskRows(emptyNoStatusState);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.cleared, 0);
|
||||
assert.equal(result.value.content, emptyNoStatusState, 'content must be byte-identical when there were zero rows');
|
||||
});
|
||||
|
||||
test('refuses to reset an unrecognized Quick Tasks schema, leaving rows intact', () => {
|
||||
const garbled = [
|
||||
'# STATE',
|
||||
'',
|
||||
'### Quick Tasks Completed',
|
||||
'',
|
||||
'| # | Thing | When |',
|
||||
'|---|-------|------|',
|
||||
'| 1 | fix typo | 2026-01-01 |',
|
||||
].join('\n');
|
||||
const before = garbled;
|
||||
|
||||
const result = resetQuickTaskRows(garbled);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /unrecognized/);
|
||||
// The caller-owned content must never be mutated on a refused write.
|
||||
assert.equal(garbled, before);
|
||||
assert.ok(garbled.includes('| 1 | fix typo | 2026-01-01 |'), 'the original row must still be present — no write occurred');
|
||||
});
|
||||
|
||||
test('empty input and non-string input both return a typed failure, never throw', () => {
|
||||
let r1;
|
||||
let r2;
|
||||
assert.doesNotThrow(() => { r1 = resetQuickTaskRows(''); });
|
||||
assert.equal(r1.ok, false);
|
||||
assert.doesNotThrow(() => { r2 = resetQuickTaskRows(42); });
|
||||
assert.equal(r2.ok, false);
|
||||
});
|
||||
|
||||
test('fails loud with a reason when the Quick Tasks Completed section is absent', () => {
|
||||
const noSection = '# STATE\n\n### Blockers/Concerns\nNone\n';
|
||||
const result = resetQuickTaskRows(noSection);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, QUICK_TASKS_SECTION_ABSENT);
|
||||
});
|
||||
|
||||
test('CRLF input keeps \\r\\n in the touched section (no mixed EOL)', () => {
|
||||
const crlfState = noStatusState.replace(/\n/g, '\r\n');
|
||||
const result = resetQuickTaskRows(crlfState);
|
||||
assert.equal(result.ok, true);
|
||||
|
||||
const section = result.value.content.split('### Blockers/Concerns')[0];
|
||||
assert.ok(!/(?<!\r)\n/.test(section), 'expected no bare \\n (mixed EOL) in the touched section');
|
||||
assert.ok(section.includes('\r\n'), 'expected \\r\\n to be preserved');
|
||||
});
|
||||
|
||||
test('an unrelated preceding table (different schema) is untouched; only the quick table is cleared', () => {
|
||||
const doc = [
|
||||
'# STATE',
|
||||
'',
|
||||
'## Roadmap Progress',
|
||||
'',
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'| --- | --- | --- | --- |',
|
||||
'| 1. Alpha | 2/2 | Complete | ✅ |',
|
||||
'',
|
||||
'### Quick Tasks Completed',
|
||||
'',
|
||||
'| # | Description | Date | Commit | Directory |',
|
||||
'|---|-------------|------|--------|-----------|',
|
||||
'| 1 | fix typo | 2026-01-01 | abc1234 | — |',
|
||||
'',
|
||||
'### Blockers/Concerns',
|
||||
'None',
|
||||
].join('\n');
|
||||
|
||||
const result = resetQuickTaskRows(doc);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.cleared, 1);
|
||||
assert.ok(
|
||||
result.value.content.includes('| 1. Alpha | 2/2 | Complete | ✅ |'),
|
||||
'the unrelated RoadmapProgress row must survive untouched',
|
||||
);
|
||||
assert.ok(!result.value.content.includes('fix typo'));
|
||||
});
|
||||
|
||||
test('property: reset always clears exactly the input row count and never touches the header line', () => {
|
||||
const rowCountArb = fc.integer({ min: 0, max: 50 });
|
||||
fc.assert(
|
||||
fc.property(rowCountArb, (n) => {
|
||||
const rows = [];
|
||||
for (let i = 1; i <= n; i++) {
|
||||
rows.push(`| ${i} | task ${i} | 2026-01-01 | abc${String(i).padStart(4, '0')} | — |`);
|
||||
}
|
||||
const state = [
|
||||
'# STATE',
|
||||
'',
|
||||
'### Quick Tasks Completed',
|
||||
'',
|
||||
'| # | Description | Date | Commit | Directory |',
|
||||
'|---|-------------|------|--------|-----------|',
|
||||
...rows,
|
||||
'',
|
||||
'### Blockers/Concerns',
|
||||
'None',
|
||||
].join('\n');
|
||||
const headerLine = state.split('\n')[4];
|
||||
|
||||
const result = resetQuickTaskRows(state);
|
||||
assert.equal(result.ok, true, `expected ok:true, got ${JSON.stringify(result)}`);
|
||||
assert.equal(result.value.cleared, n);
|
||||
assert.ok(result.value.content.includes(headerLine), 'header line must be unchanged');
|
||||
}),
|
||||
{ seed: 20260817, numRuns: 50 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempProject, cleanup, runGsdTools, toPosixPath } = require('./helpers.cjs');
|
||||
const { findTableBySchema } = require('../gsd-core/bin/lib/markdown-table.cjs');
|
||||
const { buildQuickArchiveIndex } = require('../gsd-core/bin/lib/milestone.cjs');
|
||||
|
||||
function runSdkQuery(args, cwd) {
|
||||
const result = runGsdTools(args, cwd);
|
||||
@@ -515,3 +517,677 @@ describe('bug #3600: milestone phase filter understands project-code-prefixed di
|
||||
'only CK-01-first should match Phase 1; CK-99 and CK-100 must be excluded');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #2142: quick task archival at milestone close-out
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function setupQuickArchiveRoadmap(tmpDir) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n`,
|
||||
);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
||||
}
|
||||
|
||||
function writeQuickTaskDir(tmpDir, name, files = {}) {
|
||||
const dir = path.join(tmpDir, '.planning', 'quick', name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const [filename, content] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(dir, filename), content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function quickTasksStateWithRows(count) {
|
||||
const rows = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
rows.push(`| ${i} | quick task ${i} | 2026-01-0${i} | abc000${i} | — |`);
|
||||
}
|
||||
return [
|
||||
'# STATE',
|
||||
'',
|
||||
'### Quick Tasks Completed',
|
||||
'',
|
||||
'| # | Description | Date | Commit | Directory |',
|
||||
'|---|-------------|------|--------|-----------|',
|
||||
...rows,
|
||||
'',
|
||||
'### Blockers/Concerns',
|
||||
'None',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
describe('#2142: quick task archival at milestone close-out', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('leavesQuickTasksInPlaceWhenFlagAbsent', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
const quickDir = writeQuickTaskDir(tmpDir, '2026-01-01-fix-typo', {
|
||||
'2026-01-01-fix-typo-SUMMARY.md': '# Summary\n',
|
||||
});
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), quickTasksStateWithRows(1));
|
||||
|
||||
const result = runSdkQuery(['milestone.complete', 'v1.0'], 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');
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick')),
|
||||
'no quick archive dir should be created',
|
||||
);
|
||||
|
||||
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
const table = findTableBySchema(stateContent, 'QuickTasks');
|
||||
assert.ok(table, 'Quick Tasks table must still be present');
|
||||
assert.strictEqual(table.rows.length, 1, 'quick task row must remain untouched');
|
||||
});
|
||||
|
||||
test('archivesQuickTasksAndResetsTableWhenFlagPassed', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
const names = ['2026-01-01-a', '2026-01-02-b', '2026-01-03-c'];
|
||||
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);
|
||||
assert.ok(result.success, `milestone.complete --archive-quick failed: ${result.error}`);
|
||||
assert.strictEqual(result.data.archived.quick, true);
|
||||
|
||||
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick');
|
||||
for (const name of names) {
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning', 'quick', name)),
|
||||
`${name} must be moved out of .planning/quick`,
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(archiveDir, name)), `${name} must exist in the archive dir`);
|
||||
}
|
||||
const readmeStat = fs.statSync(path.join(archiveDir, 'README.md'));
|
||||
assert.ok(readmeStat.isFile(), 'README.md index must be generated');
|
||||
assert.ok(readmeStat.size > 0, 'README.md index must be non-empty');
|
||||
const index = buildQuickArchiveIndex(archiveDir);
|
||||
const indexedNames = index.entries.map((e) => e.name);
|
||||
for (const name of names) {
|
||||
assert.ok(indexedNames.includes(name), `index entries must name ${name}`);
|
||||
}
|
||||
|
||||
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
const table = findTableBySchema(stateContent, 'QuickTasks');
|
||||
assert.ok(table, 'Quick Tasks table header must survive the reset');
|
||||
assert.strictEqual(table.rows.length, 0, 'all quick task rows must be cleared');
|
||||
});
|
||||
|
||||
test('noOpsWhenQuickDirectoryAbsent', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
|
||||
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], 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')));
|
||||
});
|
||||
|
||||
test('doesNotCreateArchiveDirForEmptyQuickDir', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'quick'), { recursive: true });
|
||||
|
||||
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], 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(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick')),
|
||||
'no archive dir for zero entries',
|
||||
);
|
||||
});
|
||||
|
||||
test('archivesSingleQuickTaskDirectory', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
writeQuickTaskDir(tmpDir, '2026-02-01-only-one');
|
||||
|
||||
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], 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')));
|
||||
});
|
||||
|
||||
test('archivesMultipleQuickTaskDirectories', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
writeQuickTaskDir(tmpDir, '2026-02-01-first');
|
||||
writeQuickTaskDir(tmpDir, '2026-02-02-second');
|
||||
|
||||
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], 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');
|
||||
assert.ok(fs.existsSync(path.join(archiveDir, '2026-02-01-first')));
|
||||
assert.ok(fs.existsSync(path.join(archiveDir, '2026-02-02-second')));
|
||||
});
|
||||
|
||||
test('archivesWhenStateHasNoQuickTasksSection', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
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);
|
||||
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')));
|
||||
// #2142 design doc §40 behavior table row 5: an absent "Quick Tasks
|
||||
// Completed" section is the common, silent no-op path (the section is
|
||||
// created lazily by quick.md, not by templates/state.md) — it must
|
||||
// never be surfaced as a preservation_warnings entry.
|
||||
assert.ok(
|
||||
!(result.data.preservation_warnings || []).some((w) => w.field === 'quick_tasks_table'),
|
||||
`an absent Quick Tasks Completed section must not produce a quick_tasks_table warning, got: ${JSON.stringify(result.data.preservation_warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('refusesResetAndWarnsWhenQuickTasksTableHasNonCanonicalHeader', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
writeQuickTaskDir(tmpDir, '2026-03-02-noncanonical');
|
||||
const nonCanonicalState = [
|
||||
'# STATE',
|
||||
'',
|
||||
'### Quick Tasks Completed',
|
||||
'',
|
||||
'| # | Thing | When |',
|
||||
'|---|-------|------|',
|
||||
'| 1 | custom thing | 2026-03-02 |',
|
||||
'',
|
||||
'### Blockers/Concerns',
|
||||
'None',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), nonCanonicalState);
|
||||
|
||||
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], 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);
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick', '2026-03-02-noncanonical')));
|
||||
|
||||
assert.ok(
|
||||
(result.data.preservation_warnings || []).some((w) => w.field === 'quick_tasks_table'),
|
||||
`a non-canonical Quick Tasks table header must produce a quick_tasks_table warning, got: ${JSON.stringify(result.data.preservation_warnings)}`,
|
||||
);
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (#2142)
|
||||
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(
|
||||
stateContent.includes('| # | Thing | When |'),
|
||||
'the non-canonical header must survive byte-exact since the reset was refused',
|
||||
);
|
||||
assert.ok(
|
||||
stateContent.includes('| 1 | custom thing | 2026-03-02 |'),
|
||||
'the original data row must remain on disk — a refused reset must not drop rows',
|
||||
);
|
||||
});
|
||||
|
||||
test('suffixesCollidingQuickTaskDirectoryOnRerun', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
const name = '2026-04-01-rerun';
|
||||
writeQuickTaskDir(tmpDir, name, { 'new-marker.txt': 'new run\n' });
|
||||
|
||||
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick');
|
||||
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);
|
||||
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(
|
||||
fs.readFileSync(path.join(archiveDir, name, 'existing-marker.txt'), 'utf-8'),
|
||||
'prior run\n',
|
||||
'prior archive entry contents must be untouched',
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(archiveDir, `${name}.1`)), 'the newly-archived dir must be suffixed .1');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(archiveDir, `${name}.1`, 'new-marker.txt')),
|
||||
"the suffixed dir must carry this run's content",
|
||||
);
|
||||
});
|
||||
|
||||
test('indexLinksPerTaskSummaryFile', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
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);
|
||||
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');
|
||||
const index = buildQuickArchiveIndex(archiveDir);
|
||||
const entry = index.entries.find((e) => e.name === name);
|
||||
assert.ok(entry, 'index entries must list the task directory');
|
||||
assert.strictEqual(
|
||||
entry.summary,
|
||||
`${name}/${name}-SUMMARY.md`,
|
||||
'index entry must link the per-task summary file via its archive-dir-relative path (name/name-SUMMARY.md), not a bare filename',
|
||||
);
|
||||
const rendered = index.render();
|
||||
const linkMatch = rendered.match(new RegExp(`\\[${name}\\]\\(([^)]+)\\)`));
|
||||
assert.ok(linkMatch, 'rendered index must contain a markdown link for the task');
|
||||
assert.strictEqual(
|
||||
linkMatch[1],
|
||||
`${name}/${name}-SUMMARY.md`,
|
||||
'rendered link target must resolve into the task subdirectory, not the archive root',
|
||||
);
|
||||
});
|
||||
|
||||
test('indexLinksLegacyBareSummaryFile', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
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);
|
||||
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');
|
||||
const index = buildQuickArchiveIndex(archiveDir);
|
||||
const entry = index.entries.find((e) => e.name === name);
|
||||
assert.ok(entry, 'index entries must list the task directory');
|
||||
assert.strictEqual(
|
||||
entry.summary,
|
||||
`${name}/SUMMARY.md`,
|
||||
'index entry must link the legacy bare summary file via its archive-dir-relative path (name/SUMMARY.md), not a bare filename',
|
||||
);
|
||||
});
|
||||
|
||||
test('indexListsTaskWithoutSummaryWithoutLink', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
const name = '2026-05-03-no-summary';
|
||||
writeQuickTaskDir(tmpDir, name); // no files at all
|
||||
|
||||
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], 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');
|
||||
const index = buildQuickArchiveIndex(archiveDir);
|
||||
const entry = index.entries.find((e) => e.name === name);
|
||||
assert.ok(entry, 'index entries must still list a task directory with no summary');
|
||||
assert.strictEqual(entry.summary, null, 'index entry must not link into a directory that has no summary file to point at');
|
||||
});
|
||||
|
||||
test('skipsNonDirectoryEntriesInQuickDir', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'quick'), { recursive: true });
|
||||
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);
|
||||
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'quick', 'stray-notes.txt')),
|
||||
'a loose file must not be archived',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick', 'stray-notes.txt')),
|
||||
'loose file must not appear under the archive dir',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick', '2026-06-01-real-task')),
|
||||
'the real task directory must still archive',
|
||||
);
|
||||
});
|
||||
|
||||
test('dryRunPreviewsQuickArchivalWithoutMutating', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
const names = ['2026-07-01-preview-a', '2026-07-02-preview-b'];
|
||||
for (const name of names) writeQuickTaskDir(tmpDir, name);
|
||||
|
||||
const result = runSdkQuery(['milestone.complete', 'v1.0', '--dry-run', '--archive-quick'], tmpDir);
|
||||
assert.ok(result.success, `milestone.complete --dry-run failed: ${result.error}`);
|
||||
assert.ok(Array.isArray(result.data.would_archive.quick), 'would_archive.quick must be an array');
|
||||
for (const name of names) {
|
||||
assert.ok(result.data.would_archive.quick.includes(name), `would_archive.quick must name ${name}`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'quick', name)),
|
||||
`${name} must remain on disk after a dry run`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick')),
|
||||
'dry run must not create the archive dir',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejectsVersionWithPathSeparator', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
writeQuickTaskDir(tmpDir, '2026-08-01-evil-version');
|
||||
|
||||
const result = runSdkQuery(['milestone.complete', '../evil', '--archive-quick'], 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');
|
||||
if (fs.existsSync(milestonesDir)) {
|
||||
for (const entry of fs.readdirSync(milestonesDir)) {
|
||||
assert.ok(!entry.includes('..'), `no traversal-shaped entry may exist under milestones/: ${entry}`);
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'quick', '2026-08-01-evil-version')),
|
||||
'quick task dir must remain untouched on refusal',
|
||||
);
|
||||
});
|
||||
|
||||
test('archivesQuickTaskWithUnicodeAndSpaces', () => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
const name = '2026-01-01-café report';
|
||||
writeQuickTaskDir(tmpDir, name);
|
||||
|
||||
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
|
||||
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick', name)),
|
||||
'a unicode/space-containing quick task directory name must archive correctly',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #2142 escalation: `milestone.archive-quick` — narrow archival entry point
|
||||
//
|
||||
// `milestone.complete --archive-quick` cannot be reused by cleanup.md: it
|
||||
// hard-errors via `missingExplicitVersion` for an already-completed milestone
|
||||
// (no `### Phase N:` headings left in its ROADMAP window), re-archives
|
||||
// ROADMAP.md over the very snapshot cleanup depends on, and would append a
|
||||
// duplicate MILESTONES.md entry on every re-run. `milestone.archive-quick` is the
|
||||
// narrow replacement — see `cmdQuickArchive` in src/milestone.cts.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#2142 escalation: milestone.archive-quick — narrow archival entry point', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('quickArchiveMovesDirectoriesWithoutTouchingRoadmap', () => {
|
||||
// Already-completed-milestone shape: v1.0 was archived by a PRIOR
|
||||
// milestone.complete run (its ROADMAP snapshot lives at
|
||||
// milestones/v1.0-ROADMAP.md), and the LIVE ROADMAP.md has moved on to
|
||||
// v1.1 — it carries no `### Phase N:` heading for v1.0 at all. This is
|
||||
// exactly the shape that makes `milestone.complete v1.0 --archive-quick`
|
||||
// fail with `missingExplicitVersion`.
|
||||
const liveRoadmap = '# Roadmap\n\n## v1.1: Next\n\n### Phase 1: New Work\n**Goal:** Ship more.\n';
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), liveRoadmap);
|
||||
const archivedRoadmap = '# Roadmap\n\n## v1.0: First\n\n### Phase 1: Foundation\n**Goal:** Setup.\n';
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'milestones'), { recursive: true });
|
||||
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);
|
||||
assert.strictEqual(
|
||||
milestoneCompleteResult.success,
|
||||
false,
|
||||
'milestone.complete v1.0 --archive-quick must still fail against an already-archived milestone',
|
||||
);
|
||||
|
||||
writeQuickTaskDir(tmpDir, '2026-09-01-fix-typo');
|
||||
|
||||
const result = runSdkQuery(['milestone.archive-quick', 'v1.0'], tmpDir);
|
||||
assert.ok(result.success, `milestone.archive-quick should succeed where milestone.complete fails: ${result.error}`);
|
||||
assert.strictEqual(result.data.archived, 1);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick', '2026-09-01-fix-typo')),
|
||||
'quick task dir must be moved into the v1.0-quick archive',
|
||||
);
|
||||
|
||||
const liveRoadmapAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.strictEqual(liveRoadmapAfter, liveRoadmap, '.planning/ROADMAP.md must be byte-identical after milestone.archive-quick');
|
||||
const archivedRoadmapAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-ROADMAP.md'), 'utf-8');
|
||||
assert.strictEqual(
|
||||
archivedRoadmapAfter,
|
||||
archivedRoadmap,
|
||||
'the archived v1.0-ROADMAP.md snapshot must be byte-identical after milestone.archive-quick',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')),
|
||||
'milestone.archive-quick must never write a MILESTONES.md entry',
|
||||
);
|
||||
});
|
||||
|
||||
test('quickArchiveRejectsVersionWithPathSeparator', () => {
|
||||
writeQuickTaskDir(tmpDir, '2026-09-02-evil-version');
|
||||
|
||||
const result = runSdkQuery(['milestone.archive-quick', '../evil'], 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');
|
||||
if (fs.existsSync(milestonesDir)) {
|
||||
for (const entry of fs.readdirSync(milestonesDir)) {
|
||||
assert.ok(!entry.includes('..'), `no traversal-shaped entry may exist under milestones/: ${entry}`);
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'quick', '2026-09-02-evil-version')),
|
||||
'quick task dir must remain untouched on refusal',
|
||||
);
|
||||
});
|
||||
|
||||
test('quickArchiveDryRunMutatesNothing', () => {
|
||||
const names = ['2026-09-03-preview-a', '2026-09-03-preview-b'];
|
||||
for (const name of names) writeQuickTaskDir(tmpDir, name);
|
||||
|
||||
const result = runSdkQuery(['milestone.archive-quick', 'v1.0', '--dry-run'], tmpDir);
|
||||
assert.ok(result.success, `milestone.archive-quick --dry-run failed: ${result.error}`);
|
||||
assert.ok(Array.isArray(result.data.would_archive), 'would_archive must be an array');
|
||||
for (const name of names) {
|
||||
assert.ok(result.data.would_archive.includes(name), `would_archive must name ${name}`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'quick', name)),
|
||||
`${name} must remain on disk after a dry run`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick')),
|
||||
'dry run must not create the archive dir',
|
||||
);
|
||||
});
|
||||
|
||||
test('quickArchiveIsNoOpWhenQuickDirAbsent', () => {
|
||||
const result = runSdkQuery(['milestone.archive-quick', 'v1.0'], tmpDir);
|
||||
assert.ok(result.success, `milestone.archive-quick should succeed with no .planning/quick: ${result.error}`);
|
||||
assert.strictEqual(result.data.archived, 0);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick')),
|
||||
'no archive dir should be created when .planning/quick is absent',
|
||||
);
|
||||
});
|
||||
|
||||
// MAJOR 6 (#2142 review): `milestone.archive-quick`'s STATE.md write now routes
|
||||
// through `readModifyWriteStateMd` (src/milestone.cts cmdQuickArchive)
|
||||
// instead of a bare `platformWriteSync`. Behavioral proof that the reset
|
||||
// still applies correctly and `state_updated` still reports `true`.
|
||||
test('quickArchiveResetsStateTableThroughOwnedCompositionAndReportsStateUpdated', () => {
|
||||
writeQuickTaskDir(tmpDir, '2026-09-04-a');
|
||||
writeQuickTaskDir(tmpDir, '2026-09-04-b');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), quickTasksStateWithRows(2));
|
||||
|
||||
const result = runSdkQuery(['milestone.archive-quick', 'v1.0'], tmpDir);
|
||||
assert.ok(result.success, `milestone.archive-quick failed: ${result.error}`);
|
||||
assert.strictEqual(result.data.archived, 2);
|
||||
assert.strictEqual(result.data.state_updated, true, 'state_updated must be true when the table reset actually applied');
|
||||
assert.deepStrictEqual(result.data.warnings, []);
|
||||
|
||||
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
const table = findTableBySchema(stateContent, 'QuickTasks');
|
||||
assert.ok(table, 'Quick Tasks table header must survive the reset');
|
||||
assert.strictEqual(table.rows.length, 0, 'all quick task rows must be cleared');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #2142 review — BLOCKER 1, MAJOR 3, MAJOR 5 regression coverage
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Placement note (code-review FIX 3): per docs/TESTING-SUITES.md this
|
||||
// adversarial/prompt-injection + path-escape coverage belongs in a
|
||||
// `*.security.test.cjs` file, not this unsuffixed unit lane. It stays here
|
||||
// instead: `scripts/lint-test-file-count.allowlist.json`'s `milestone` entry
|
||||
// is an IDENTITY ratchet (an exact, already-over-cap list of 8 known
|
||||
// filenames) — a new `milestone-archive.security.test.cjs` buckets into the
|
||||
// same `milestone` module (`testEffectivePrefix` strips `.test.cjs`, and
|
||||
// `milestone-archive.security` still starts with `milestone-`) and is a
|
||||
// NOVEL file the ratchet has never seen, so `node scripts/lint-test-file-count.cjs`
|
||||
// fails it outright (verified empirically: FAIL_NOVEL_FILES, exit 1).
|
||||
// Splitting this describe block out is blocked by that ratchet, not by
|
||||
// oversight; revisit if the `milestone` module's test files are ever
|
||||
// consolidated below the cap.
|
||||
describe('#2142 review: README injection, symlink escape, dry-run/real-run parity', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
// BLOCKER 1: a quick-task directory name containing an embedded newline
|
||||
// plus markdown heading syntax must never let that heading land verbatim
|
||||
// in the generated README.md (indirect prompt-injection vector).
|
||||
test('embeddedNewlineInDirNameCannotInjectAHeadingIntoTheGeneratedReadme', (t) => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
const maliciousName = '2026-10-01-evil\n\n## Injected';
|
||||
// Windows forbids control characters (0x00-0x1F, which includes \n) in
|
||||
// path names outright, so `fs.mkdirSync` below cannot even create this
|
||||
// fixture there — it fails during SETUP, not as a defect in the escaping
|
||||
// under test. Skip deterministically by platform rather than by error
|
||||
// code: Windows reports this specific failure as the generic ENOENT
|
||||
// (verified in CI, errno -4058), and ENOENT is also the code a genuine
|
||||
// POSIX fixture-setup bug (e.g. a missing parent directory) would throw.
|
||||
// Adding ENOENT to the catch below would blanket-skip that real failure
|
||||
// on POSIX too, silently turning a defect into a pass — do not
|
||||
// "simplify" this back to a single try/catch.
|
||||
if (process.platform === 'win32') {
|
||||
t.skip('Windows forbids control characters (including newline) in path names, so this fixture cannot be created here; the escaping it guards is exercised on POSIX');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
writeQuickTaskDir(tmpDir, maliciousName);
|
||||
} catch (err) {
|
||||
if (err && ['EINVAL', 'ENAMETOOLONG'].includes(err.code)) {
|
||||
t.skip(`this platform's filesystem rejects a newline in a directory name (${err.code})`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], 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');
|
||||
const index = buildQuickArchiveIndex(archiveDir);
|
||||
assert.strictEqual(index.entries.length, 1, 'exactly one archived quick-task directory');
|
||||
assert.ok(
|
||||
!index.entries[0].name.includes('\n'),
|
||||
`escaped entry name must not contain a raw newline — a newline surviving escaping is what would let ` +
|
||||
`an embedded "## Injected" become a standalone markdown heading line; got: ${JSON.stringify(index.entries[0].name)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// MAJOR 3: a symlink under `.planning/quick/` — even one targeting a real
|
||||
// directory OUTSIDE the planning root — must never be archived (moved) and
|
||||
// its target must never be altered, for BOTH archival entry points.
|
||||
function setupSymlinkEscape(t) {
|
||||
const outsideDir = createTempProject('gsd-quick-escape-target-');
|
||||
fs.writeFileSync(path.join(outsideDir, 'marker.txt'), 'do not touch\n');
|
||||
const symlinkPath = path.join(tmpDir, '.planning', 'quick', '2026-10-02-escape-symlink');
|
||||
fs.mkdirSync(path.dirname(symlinkPath), { recursive: true });
|
||||
try {
|
||||
fs.symlinkSync(outsideDir, symlinkPath, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
} catch (err) {
|
||||
cleanup(outsideDir);
|
||||
if (err && ['EPERM', 'EACCES', 'ENOTSUP'].includes(err.code)) {
|
||||
t.skip(`symlink creation is not available on this platform (${err.code})`);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return { outsideDir, symlinkPath };
|
||||
}
|
||||
|
||||
test('symlinkEscapeIsNeverArchivedByMilestoneComplete', (t) => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
const escape = setupSymlinkEscape(t);
|
||||
if (!escape) return; // t.skip already recorded above
|
||||
const { outsideDir, symlinkPath } = escape;
|
||||
try {
|
||||
writeQuickTaskDir(tmpDir, '2026-10-02-real-task');
|
||||
|
||||
const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
|
||||
assert.ok(result.success, `milestone.complete failed: ${result.error}`);
|
||||
assert.strictEqual(result.data.archived.quick, true, 'the real task dir must still archive');
|
||||
|
||||
assert.ok(fs.existsSync(symlinkPath), 'the symlink must remain in .planning/quick, never moved');
|
||||
assert.ok(fs.lstatSync(symlinkPath).isSymbolicLink(), 'the entry must still be a symlink, untouched');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(outsideDir, 'marker.txt')),
|
||||
"the symlink's external target must never be moved or altered",
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick', '2026-10-02-escape-symlink')),
|
||||
'the symlink must never appear inside the archive directory',
|
||||
);
|
||||
} finally {
|
||||
cleanup(outsideDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('symlinkEscapeIsNeverArchivedByQuickArchive', (t) => {
|
||||
const escape = setupSymlinkEscape(t);
|
||||
if (!escape) return; // t.skip already recorded above
|
||||
const { outsideDir, symlinkPath } = escape;
|
||||
try {
|
||||
writeQuickTaskDir(tmpDir, '2026-10-03-real-task');
|
||||
|
||||
const result = runSdkQuery(['milestone.archive-quick', 'v1.0'], tmpDir);
|
||||
assert.ok(result.success, `milestone.archive-quick failed: ${result.error}`);
|
||||
assert.strictEqual(result.data.archived, 1, 'only the real task dir must archive');
|
||||
|
||||
assert.ok(fs.existsSync(symlinkPath), 'the symlink must remain in .planning/quick, never moved');
|
||||
assert.ok(fs.lstatSync(symlinkPath).isSymbolicLink(), 'the entry must still be a symlink, untouched');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(outsideDir, 'marker.txt')),
|
||||
"the symlink's external target must never be moved or altered",
|
||||
);
|
||||
} finally {
|
||||
cleanup(outsideDir);
|
||||
}
|
||||
});
|
||||
|
||||
// MAJOR 5: dry-run preview must be produced by the SAME selection rule
|
||||
// (`listQuickTaskDirsForArchive`) as the real archive pass, so a fixture
|
||||
// containing an entry the real run would skip (a symlink) is ALSO absent
|
||||
// from the dry-run preview — they cannot disagree.
|
||||
test('dryRunPreviewMatchesRealArchiveWhenAnEntryIsSkipped', (t) => {
|
||||
setupQuickArchiveRoadmap(tmpDir);
|
||||
const escape = setupSymlinkEscape(t);
|
||||
if (!escape) return; // t.skip already recorded above
|
||||
const { outsideDir } = escape;
|
||||
try {
|
||||
writeQuickTaskDir(tmpDir, '2026-10-04-keep');
|
||||
|
||||
const dryRun = runSdkQuery(['milestone.complete', 'v1.0', '--dry-run', '--archive-quick'], tmpDir);
|
||||
assert.ok(dryRun.success, `dry-run failed: ${dryRun.error}`);
|
||||
assert.deepStrictEqual(
|
||||
dryRun.data.would_archive.quick,
|
||||
['2026-10-04-keep'],
|
||||
'the skipped symlink entry must not appear in the dry-run preview',
|
||||
);
|
||||
|
||||
const real = runSdkQuery(['milestone.complete', 'v1.0', '--archive-quick'], tmpDir);
|
||||
assert.ok(real.success, `real run failed: ${real.error}`);
|
||||
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-quick');
|
||||
const archivedNames = fs
|
||||
.readdirSync(archiveDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
assert.deepStrictEqual(
|
||||
archivedNames,
|
||||
dryRun.data.would_archive.quick,
|
||||
'the real run must archive exactly what the dry-run preview reported',
|
||||
);
|
||||
} finally {
|
||||
cleanup(outsideDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, toPosixPath } = require('./helpers.cjs');
|
||||
const { makeFakeClock } = require('./helpers/clock.cjs');
|
||||
|
||||
const planningWorkspaceDirect = require('../gsd-core/bin/lib/planning-workspace.cjs');
|
||||
@@ -654,3 +654,43 @@ describe('bug #1883 — findContextMdIn distinguishes a permission error from em
|
||||
'array path returns null when no match');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #2142: quick task workspace path (planningPaths().quick)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#2142: quick task workspace path (planningPaths().quick)', () => {
|
||||
const cwd = '/fake/repo';
|
||||
let savedProject;
|
||||
let savedWorkstream;
|
||||
|
||||
beforeEach(() => {
|
||||
savedProject = process.env.GSD_PROJECT;
|
||||
savedWorkstream = process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedProject !== undefined) process.env.GSD_PROJECT = savedProject;
|
||||
else delete process.env.GSD_PROJECT;
|
||||
if (savedWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedWorkstream;
|
||||
else delete process.env.GSD_WORKSTREAM;
|
||||
});
|
||||
|
||||
test('exposesQuickDirectoryPath: planningPaths(cwd).quick ends with .planning/quick', () => {
|
||||
const paths = planningPaths(cwd, null);
|
||||
assert.strictEqual(toPosixPath(paths.quick), toPosixPath(path.join(cwd, '.planning', 'quick')));
|
||||
assert.ok(toPosixPath(paths.quick).endsWith('.planning/quick'));
|
||||
});
|
||||
|
||||
test('resolvesQuickPathUnderWorkstream: quick resolves under the workstream base like phases', () => {
|
||||
const paths = planningPaths(cwd, 'feature-x');
|
||||
assert.strictEqual(
|
||||
toPosixPath(paths.quick),
|
||||
toPosixPath(path.join(cwd, '.planning', 'workstreams', 'feature-x', 'quick')),
|
||||
);
|
||||
// Same parent directory as `phases` — quick is workstream-aware exactly like phases.
|
||||
assert.strictEqual(path.dirname(paths.quick), path.dirname(paths.phases));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user