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

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

View File

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

View File

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

View File

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

View File

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