diff --git a/.changeset/tidy-goats-jump.md b/.changeset/tidy-goats-jump.md new file mode 100644 index 000000000..ec12a2a24 --- /dev/null +++ b/.changeset/tidy-goats-jump.md @@ -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/-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) diff --git a/CONTEXT.md b/CONTEXT.md index 89586848a..f1846940f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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). diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index 82ec96571..96850a256 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -680,7 +680,10 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ```bash # Archive milestone -node gsd-tools.cjs milestone complete [--name ] [--no-archive-phases] [--force] [--dry-run] +node gsd-tools.cjs milestone complete [--name ] [--no-archive-phases] [--force] [--dry-run] [--archive-quick] + +# Archive .planning/quick/* into milestones/-quick/ WITHOUT the milestone complete close-out (#2142) +node gsd-tools.cjs milestone archive-quick [--dry-run] # Mark requirements as complete node gsd-tools.cjs requirements mark-complete @@ -694,13 +697,27 @@ node gsd-tools.cjs requirements mark-complete | `` | Milestone version label to archive (e.g. `v1.0`). | | `--name ` | Display name for the MILESTONES.md entry. Defaults to ``. | | `--no-archive-phases` | Leave phase directories in place instead of moving them into `.planning/milestones/-phases/`. | +| `--archive-quick` | Opt-in (default OFF, #2142): also move every directory under `.planning/quick/` into `.planning/milestones/-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 `` 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 ``, 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/-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 | +|------|-------------| +| `` | Milestone version label to archive quick-task directories under (e.g. `v1.0`). Same validation as `milestone complete`'s `` — 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/-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 # Append a row to STATE.md's "Quick Tasks Completed" table (schema-backed; #2133) node gsd-tools.cjs quick-tasks-append --task "" +# See "Milestone Commands" below for `milestone archive-quick` (#2142) — sweeps .planning/quick/* into +# milestones/-quick/ and clears this table, without a full `milestone complete`. # Aggregate all SUMMARY.md data node gsd-tools.cjs history-digest diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 8ff9ecf7c..df575c905 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -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 ` 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 ``` diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 894dc496e..bc2950441 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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 ` (#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/-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 ` 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. diff --git a/docs/how-to/handle-quick-and-fast-tasks.md b/docs/how-to/handle-quick-and-fast-tasks.md index ec074b1cb..7c5b630ae 100644 --- a/docs/how-to/handle-quick-and-fast-tasks.md +++ b/docs/how-to/handle-quick-and-fast-tasks.md @@ -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 ` 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/-quick/`. +2. (Re)write that archive directory's `README.md` — a plain list, one entry per archived task directory, linking to its `-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) diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 7e734a879..a8d82984f 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -68,6 +68,14 @@ * milestone complete Archive milestone, create MILESTONES.md * [--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 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); } } diff --git a/gsd-core/workflows/cleanup.md b/gsd-core/workflows/cleanup.md index 042046e40..e2a9cf05d 100644 --- a/gsd-core/workflows/cleanup.md +++ b/gsd-core/workflows/cleanup.md @@ -1,6 +1,6 @@ -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. @@ -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 @@ -69,6 +70,26 @@ Match phase directories to milestone membership. Only include directories that s + + +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). + + + 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. + @@ -147,6 +188,20 @@ Repeat for all milestones in the cleanup set. + + +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. + + + 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 ``` @@ -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 diff --git a/gsd-core/workflows/complete-milestone.md b/gsd-core/workflows/complete-milestone.md index 205a0495e..70ed996cf 100644 --- a/gsd-core/workflows/complete-milestone.md +++ b/gsd-core/workflows/complete-milestone.md @@ -507,10 +507,20 @@ Initial user testing showed demand for shape tools. +**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) diff --git a/src/audit.cts b/src/audit.cts index fe51da7c8..71dcfdf56 100644 --- a/src/audit.cts +++ b/src/audit.cts @@ -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 { - 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, }; diff --git a/src/markdown-table.cts b/src/markdown-table.cts index ee0c447a4..3f42f32d2 100644 --- a/src/markdown-table.cts +++ b/src/markdown-table.cts @@ -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). diff --git a/src/milestone.cts b/src/milestone.cts index f6f7c0309..773a9357f 100644 --- a/src/milestone.cts +++ b/src/milestone.cts @@ -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/