enhance(#2142): archive quick tasks at milestone close-out (#3592)

* 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:
Tom Boucher
2026-08-17 14:51:00 -04:00
committed by GitHub
parent b08af152e4
commit 98ecb2ba8c
18 changed files with 1776 additions and 22 deletions

View File

@@ -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,
};

View File

@@ -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).

View File

@@ -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,
};

View File

@@ -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,