feat: open artifact audit gate for milestone close and phase verify (#2157, #2158) (#2160)

* feat(2158): add audit.cjs open artifact scanner with security-hardened path handling

- Scans 8 .planning/ artifact categories for unresolved state
- Debug sessions, quick tasks, threads, todos, seeds, UAT gaps, verification gaps, CONTEXT open questions
- requireSafePath with allowAbsolute:true on all file reads
- sanitizeForDisplay on all output strings
- Graceful per-category error handling, never throws
- formatAuditReport returns human-readable report with emoji indicators

* feat(2158): add audit-open CLI command to gsd-tools.cjs + Deferred Items to state template

- Add audit-open [--json] case to switch router
- Add audit-open entry to header comment block
- Add Deferred Items section to state.md template for milestone carry-forward

* feat(2157): add phase artifact scan step to verify-work workflow

- scan_phase_artifacts step runs audit-open --json after UAT completion
- Surfaces UAT gaps, VERIFICATION gaps, and CONTEXT open questions for current phase
- Prompts user to confirm or decline before marking phase verified
- Records acknowledged gaps in VERIFICATION.md Acknowledged Gaps section
- SECURITY note: file paths validated, content truncated and sanitized before display

* feat(2158): add pre-close artifact audit gate to complete-milestone workflow

- pre_close_artifact_audit step runs before verify_readiness
- Displays full audit report when open items exist
- Three-way choice: Resolve, Acknowledge all, or Cancel
- Acknowledge path writes deferred items table to STATE.md
- Records deferred count in MILESTONES.md entry
- Adds three new success criteria checklist items
- SECURITY note on sanitizing all STATE.md writes

* test(2157,2158): add milestone audit gate tests

- 6 tests for audit.cjs: structured result, graceful missing dirs, open debug detection,
  resolved session exclusion, formatAuditReport header, all-clear message
- 3 tests for complete-milestone.md: pre_close_artifact_audit step, Deferred Items,
  security note presence
- 2 tests for verify-work.md: scan_phase_artifacts step, user prompt for gaps
- 1 test for state.md template: Deferred Items section
This commit is contained in:
Tom Boucher
2026-04-12 10:06:42 -04:00
committed by GitHub
parent 7b07dde150
commit f0a20e4dd7
6 changed files with 998 additions and 0 deletions

View File

@@ -70,6 +70,9 @@
* audit-uat Scan all phases for unresolved UAT/verification items
* uat render-checkpoint --file <path> Render the current UAT checkpoint block
*
* Open Artifact Audit:
* audit-open [--json] Scan all .planning/ artifact types for unresolved items
*
* Intel:
* intel query <term> Query intel files for a term
* intel status Show intel file freshness
@@ -763,6 +766,18 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
break;
}
case 'audit-open': {
const { auditOpenArtifacts, formatAuditReport } = require('./lib/audit.cjs');
const includeRaw = args.includes('--json');
const result = auditOpenArtifacts(cwd);
if (includeRaw) {
output(JSON.stringify(result, null, 2), raw);
} else {
output(formatAuditReport(result), raw);
}
break;
}
case 'uat': {
const subcommand = args[1];
const uat = require('./lib/uat.cjs');

View File

@@ -0,0 +1,757 @@
/**
* Open Artifact Audit — Cross-type unresolved state scanner
*
* Scans all .planning/ artifact categories for items with open/unresolved state.
* Returns structured JSON for workflow consumption.
* Called by: gsd-tools.cjs audit-open
* Used by: /gsd-complete-milestone pre-close gate
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { planningDir, toPosixPath } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { requireSafePath, sanitizeForDisplay } = require('./security.cjs');
/**
* Scan .planning/debug/ for open sessions.
* Open = status NOT in ['resolved', 'complete'].
* Ignores the resolved/ subdirectory.
*/
function scanDebugSessions(planDir) {
const debugDir = path.join(planDir, 'debug');
if (!fs.existsSync(debugDir)) return [];
const results = [];
let files;
try {
files = fs.readdirSync(debugDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
}
for (const entry of files) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const filePath = path.join(debugDir, entry.name);
let safeFilePath;
try {
safeFilePath = requireSafePath(filePath, planDir, 'debug session file', { allowAbsolute: true });
} catch {
continue;
}
let content;
try {
content = fs.readFileSync(safeFilePath, 'utf-8');
} catch {
continue;
}
const fm = extractFrontmatter(content);
const status = (fm.status || 'unknown').toLowerCase();
if (status === 'resolved' || status === 'complete') continue;
// Extract hypothesis from "Current Focus" block if parseable
let hypothesis = '';
const focusMatch = content.match(/##\s*Current Focus[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i);
if (focusMatch) {
const focusText = focusMatch[1].trim().split('\n')[0].trim();
hypothesis = sanitizeForDisplay(focusText.slice(0, 100));
}
const slug = path.basename(entry.name, '.md');
results.push({
slug: sanitizeForDisplay(slug),
status: sanitizeForDisplay(status),
updated: sanitizeForDisplay(String(fm.updated || fm.date || '')),
hypothesis,
});
}
return results;
}
/**
* Scan .planning/quick/ for incomplete tasks.
* Incomplete if SUMMARY.md missing or status !== 'complete'.
*/
function scanQuickTasks(planDir) {
const quickDir = path.join(planDir, 'quick');
if (!fs.existsSync(quickDir)) return [];
let entries;
try {
entries = fs.readdirSync(quickDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
}
const results = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dirName = entry.name;
const taskDir = path.join(quickDir, dirName);
let safeTaskDir;
try {
safeTaskDir = requireSafePath(taskDir, planDir, 'quick task dir', { allowAbsolute: true });
} catch {
continue;
}
const summaryPath = path.join(safeTaskDir, 'SUMMARY.md');
let status = 'missing';
let description = '';
if (fs.existsSync(summaryPath)) {
let safeSum;
try {
safeSum = requireSafePath(summaryPath, planDir, 'quick task summary', { allowAbsolute: true });
} catch {
continue;
}
try {
const content = fs.readFileSync(safeSum, 'utf-8');
const fm = extractFrontmatter(content);
status = (fm.status || 'unknown').toLowerCase();
} catch {
status = 'unreadable';
}
}
if (status === 'complete') continue;
// Parse date and slug from directory name: YYYYMMDD-slug or YYYY-MM-DD-slug
let date = '';
let slug = sanitizeForDisplay(dirName);
const dateMatch = dirName.match(/^(\d{4}-?\d{2}-?\d{2})-(.+)$/);
if (dateMatch) {
date = dateMatch[1];
slug = sanitizeForDisplay(dateMatch[2]);
}
results.push({
slug,
date,
status: sanitizeForDisplay(status),
description,
});
}
return results;
}
/**
* Scan .planning/threads/ for open threads.
* Open if status in ['open', 'in_progress', 'in progress'] (case-insensitive).
*/
function scanThreads(planDir) {
const threadsDir = path.join(planDir, 'threads');
if (!fs.existsSync(threadsDir)) return [];
let files;
try {
files = fs.readdirSync(threadsDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
}
const openStatuses = new Set(['open', 'in_progress', 'in progress']);
const results = [];
for (const entry of files) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const filePath = path.join(threadsDir, entry.name);
let safeFilePath;
try {
safeFilePath = requireSafePath(filePath, planDir, 'thread file', { allowAbsolute: true });
} catch {
continue;
}
let content;
try {
content = fs.readFileSync(safeFilePath, 'utf-8');
} catch {
continue;
}
const fm = extractFrontmatter(content);
let status = (fm.status || '').toLowerCase().trim();
// Fall back to scanning body for ## Status: OPEN / IN PROGRESS
if (!status) {
const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i);
if (bodyStatusMatch) {
status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_');
}
}
if (!openStatuses.has(status)) continue;
// Extract title from # Thread: heading or frontmatter title
let title = sanitizeForDisplay(String(fm.title || ''));
if (!title) {
const headingMatch = content.match(/^#\s*Thread:\s*(.+)$/m);
if (headingMatch) {
title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
}
}
const slug = path.basename(entry.name, '.md');
results.push({
slug: sanitizeForDisplay(slug),
status: sanitizeForDisplay(status),
updated: sanitizeForDisplay(String(fm.updated || fm.date || '')),
title,
});
}
return results;
}
/**
* Scan .planning/todos/pending/ for pending todos.
* Returns array of { filename, priority, area, summary }.
* Display limited to first 5 + count of remainder.
*/
function scanTodos(planDir) {
const pendingDir = path.join(planDir, 'todos', 'pending');
if (!fs.existsSync(pendingDir)) return [];
let files;
try {
files = fs.readdirSync(pendingDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
}
const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
const results = [];
const displayFiles = mdFiles.slice(0, 5);
for (const entry of displayFiles) {
const filePath = path.join(pendingDir, entry.name);
let safeFilePath;
try {
safeFilePath = requireSafePath(filePath, planDir, 'todo file', { allowAbsolute: true });
} catch {
continue;
}
let content;
try {
content = fs.readFileSync(safeFilePath, 'utf-8');
} catch {
continue;
}
const fm = extractFrontmatter(content);
// Extract first line of body after frontmatter
const bodyMatch = content.replace(/^---[\s\S]*?---\n?/, '');
const firstLine = bodyMatch.trim().split('\n')[0] || '';
const summary = sanitizeForDisplay(firstLine.slice(0, 100));
results.push({
filename: sanitizeForDisplay(entry.name),
priority: sanitizeForDisplay(String(fm.priority || '')),
area: sanitizeForDisplay(String(fm.area || '')),
summary,
});
}
if (mdFiles.length > 5) {
results.push({ _remainder_count: mdFiles.length - 5 });
}
return results;
}
/**
* Scan .planning/seeds/SEED-*.md for unimplemented seeds.
* Unimplemented if status in ['dormant', 'active', 'triggered'].
*/
function scanSeeds(planDir) {
const seedsDir = path.join(planDir, 'seeds');
if (!fs.existsSync(seedsDir)) return [];
let files;
try {
files = fs.readdirSync(seedsDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
}
const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
const results = [];
for (const entry of files) {
if (!entry.isFile()) continue;
if (!entry.name.startsWith('SEED-') || !entry.name.endsWith('.md')) continue;
const filePath = path.join(seedsDir, entry.name);
let safeFilePath;
try {
safeFilePath = requireSafePath(filePath, planDir, 'seed file', { allowAbsolute: true });
} catch {
continue;
}
let content;
try {
content = fs.readFileSync(safeFilePath, 'utf-8');
} catch {
continue;
}
const fm = extractFrontmatter(content);
const status = (fm.status || 'dormant').toLowerCase();
if (!unimplementedStatuses.has(status)) continue;
// Extract seed_id from filename or frontmatter
const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md');
const slug = sanitizeForDisplay(seed_id.replace(/^SEED-/, ''));
let title = sanitizeForDisplay(String(fm.title || ''));
if (!title) {
const headingMatch = content.match(/^#\s*(.+)$/m);
if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
}
results.push({
seed_id: sanitizeForDisplay(seed_id),
slug,
status: sanitizeForDisplay(status),
title,
});
}
return results;
}
/**
* Scan .planning/phases for UAT gaps (UAT files with status != 'complete').
*/
function scanUatGaps(planDir) {
const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return [];
let dirs;
try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true }];
}
const results = [];
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files;
try {
files = fs.readdirSync(phaseDir);
} catch {
continue;
}
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
const filePath = path.join(phaseDir, file);
let safeFilePath;
try {
safeFilePath = requireSafePath(filePath, planDir, 'UAT file', { allowAbsolute: true });
} catch {
continue;
}
let content;
try {
content = fs.readFileSync(safeFilePath, 'utf-8');
} catch {
continue;
}
const fm = extractFrontmatter(content);
const status = (fm.status || 'unknown').toLowerCase();
if (status === 'complete') continue;
// Count open scenarios
const pendingMatches = (content.match(/result:\s*(?:pending|\[pending\])/gi) || []).length;
results.push({
phase: sanitizeForDisplay(phaseNum),
file: sanitizeForDisplay(file),
status: sanitizeForDisplay(status),
open_scenario_count: pendingMatches,
});
}
}
return results;
}
/**
* Scan .planning/phases for VERIFICATION gaps.
*/
function scanVerificationGaps(planDir) {
const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return [];
let dirs;
try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true }];
}
const results = [];
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files;
try {
files = fs.readdirSync(phaseDir);
} catch {
continue;
}
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
const filePath = path.join(phaseDir, file);
let safeFilePath;
try {
safeFilePath = requireSafePath(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
} catch {
continue;
}
let content;
try {
content = fs.readFileSync(safeFilePath, 'utf-8');
} catch {
continue;
}
const fm = extractFrontmatter(content);
const status = (fm.status || 'unknown').toLowerCase();
if (status !== 'gaps_found' && status !== 'human_needed') continue;
results.push({
phase: sanitizeForDisplay(phaseNum),
file: sanitizeForDisplay(file),
status: sanitizeForDisplay(status),
});
}
}
return results;
}
/**
* Scan .planning/phases for CONTEXT files with open_questions.
*/
function scanContextQuestions(planDir) {
const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return [];
let dirs;
try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true }];
}
const results = [];
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files;
try {
files = fs.readdirSync(phaseDir);
} catch {
continue;
}
for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
const filePath = path.join(phaseDir, file);
let safeFilePath;
try {
safeFilePath = requireSafePath(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
} catch {
continue;
}
let content;
try {
content = fs.readFileSync(safeFilePath, 'utf-8');
} catch {
continue;
}
const fm = extractFrontmatter(content);
// Check frontmatter open_questions field
let questions = [];
if (fm.open_questions) {
if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
questions = fm.open_questions.map(q => sanitizeForDisplay(String(q).slice(0, 200)));
}
}
// Also check for ## Open Questions section in body
if (questions.length === 0) {
const oqMatch = content.match(/##\s*Open Questions[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i);
if (oqMatch) {
const oqBody = oqMatch[1].trim();
if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
const items = oqBody.split('\n')
.map(l => l.trim())
.filter(l => l && l !== '-' && l !== '*')
.filter(l => /^[-*\d]/.test(l) || l.includes('?'));
questions = items.slice(0, 3).map(q => sanitizeForDisplay(q.slice(0, 200)));
}
}
}
if (questions.length === 0) continue;
results.push({
phase: sanitizeForDisplay(phaseNum),
file: sanitizeForDisplay(file),
question_count: questions.length,
questions: questions.slice(0, 3),
});
}
}
return results;
}
/**
* Main audit function. Scans all .planning/ artifact categories.
*
* @param {string} cwd - Project root directory
* @returns {object} Structured audit result
*/
function auditOpenArtifacts(cwd) {
const planDir = planningDir(cwd);
const debugSessions = (() => {
try { return scanDebugSessions(planDir); } catch { return [{ scan_error: true }]; }
})();
const quickTasks = (() => {
try { return scanQuickTasks(planDir); } catch { return [{ scan_error: true }]; }
})();
const threads = (() => {
try { return scanThreads(planDir); } catch { return [{ scan_error: true }]; }
})();
const todos = (() => {
try { return scanTodos(planDir); } catch { return [{ scan_error: true }]; }
})();
const seeds = (() => {
try { return scanSeeds(planDir); } catch { return [{ scan_error: true }]; }
})();
const uatGaps = (() => {
try { return scanUatGaps(planDir); } catch { return [{ scan_error: true }]; }
})();
const verificationGaps = (() => {
try { return scanVerificationGaps(planDir); } catch { return [{ scan_error: true }]; }
})();
const contextQuestions = (() => {
try { return scanContextQuestions(planDir); } catch { return [{ scan_error: true }]; }
})();
// Count real items (not scan_error sentinels)
const countReal = arr => arr.filter(i => !i.scan_error && !i._remainder_count).length;
const counts = {
debug_sessions: countReal(debugSessions),
quick_tasks: countReal(quickTasks),
threads: countReal(threads),
todos: countReal(todos),
seeds: countReal(seeds),
uat_gaps: countReal(uatGaps),
verification_gaps: countReal(verificationGaps),
context_questions: countReal(contextQuestions),
};
counts.total = Object.values(counts).reduce((s, n) => s + n, 0);
return {
scanned_at: new Date().toISOString(),
has_open_items: counts.total > 0,
counts,
items: {
debug_sessions: debugSessions,
quick_tasks: quickTasks,
threads,
todos,
seeds,
uat_gaps: uatGaps,
verification_gaps: verificationGaps,
context_questions: contextQuestions,
},
};
}
/**
* Format the audit result as a human-readable report.
*
* @param {object} auditResult - Result from auditOpenArtifacts()
* @returns {string} Formatted report
*/
function formatAuditReport(auditResult) {
const { counts, items, has_open_items } = auditResult;
const lines = [];
const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
lines.push(hr);
lines.push(' Milestone Close: Open Artifact Audit');
lines.push(hr);
if (!has_open_items) {
lines.push('');
lines.push(' All artifact types clear. Safe to proceed.');
lines.push('');
lines.push(hr);
return lines.join('\n');
}
// Debug sessions (blocking quality — red)
if (counts.debug_sessions > 0) {
lines.push('');
lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open)`);
for (const item of items.debug_sessions.filter(i => !i.scan_error)) {
const hyp = item.hypothesis ? ` — ${item.hypothesis}` : '';
lines.push(` • ${item.slug} [${item.status}]${hyp}`);
}
}
// UAT gaps (blocking quality — red)
if (counts.uat_gaps > 0) {
lines.push('');
lines.push(`🔴 UAT Gaps (${counts.uat_gaps} phases with incomplete UAT)`);
for (const item of items.uat_gaps.filter(i => !i.scan_error)) {
lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}] — ${item.open_scenario_count} pending scenarios`);
}
}
// Verification gaps (blocking quality — red)
if (counts.verification_gaps > 0) {
lines.push('');
lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved)`);
for (const item of items.verification_gaps.filter(i => !i.scan_error)) {
lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}]`);
}
}
// Quick tasks (incomplete work — yellow)
if (counts.quick_tasks > 0) {
lines.push('');
lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete)`);
for (const item of items.quick_tasks.filter(i => !i.scan_error)) {
const d = item.date ? ` (${item.date})` : '';
lines.push(` • ${item.slug}${d} [${item.status}]`);
}
}
// Todos (incomplete work — yellow)
if (counts.todos > 0) {
const realTodos = items.todos.filter(i => !i.scan_error && !i._remainder_count);
const remainder = items.todos.find(i => i._remainder_count);
lines.push('');
lines.push(`🟡 Pending Todos (${counts.todos} pending)`);
for (const item of realTodos) {
const area = item.area ? ` [${item.area}]` : '';
const pri = item.priority ? ` (${item.priority})` : '';
lines.push(` • ${item.filename}${area}${pri}`);
if (item.summary) lines.push(` ${item.summary}`);
}
if (remainder) {
lines.push(` ... and ${remainder._remainder_count} more`);
}
}
// Threads (deferred decisions — blue)
if (counts.threads > 0) {
lines.push('');
lines.push(`🔵 Open Threads (${counts.threads} active)`);
for (const item of items.threads.filter(i => !i.scan_error)) {
const title = item.title ? ` — ${item.title}` : '';
lines.push(` • ${item.slug} [${item.status}]${title}`);
}
}
// Seeds (deferred decisions — blue)
if (counts.seeds > 0) {
lines.push('');
lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending)`);
for (const item of items.seeds.filter(i => !i.scan_error)) {
const title = item.title ? ` — ${item.title}` : '';
lines.push(` • ${item.seed_id} [${item.status}]${title}`);
}
}
// Context questions (deferred decisions — blue)
if (counts.context_questions > 0) {
lines.push('');
lines.push(`🔵 CONTEXT Open Questions (${counts.context_questions} phases with open questions)`);
for (const item of items.context_questions.filter(i => !i.scan_error)) {
lines.push(` • Phase ${item.phase}: ${item.file} (${item.question_count} question${item.question_count !== 1 ? 's' : ''})`);
for (const q of item.questions) {
lines.push(` - ${q}`);
}
}
}
lines.push('');
lines.push(hr);
lines.push(` ${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.`);
lines.push(hr);
return lines.join('\n');
}
module.exports = { auditOpenArtifacts, formatAuditReport };

View File

@@ -66,6 +66,14 @@ None yet.
None yet.
## Deferred Items
Items acknowledged and carried forward from previous milestone close:
| Category | Item | Status | Deferred At |
|----------|------|--------|-------------|
| *(none)* | | | |
## Session Continuity
Last session: [YYYY-MM-DD HH:MM]

View File

@@ -37,6 +37,48 @@ When a milestone completes:
<process>
<step name="pre_close_artifact_audit">
Before proceeding with milestone close, run the comprehensive open artifact audit:
```bash
node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" audit-open 2>/dev/null
```
If the output contains open items (any section with count > 0):
Display the full audit report to the user.
Then ask:
```
These items are open. Choose an action:
[R] Resolve — stop and fix items, then re-run /gsd-complete-milestone
[A] Acknowledge all — document as deferred and proceed with close
[C] Cancel — exit without closing
```
If user chooses [A] (Acknowledge):
1. Re-run `audit-open --json` to get structured data
2. Write acknowledged items to STATE.md under `## Deferred Items` section:
```markdown
## Deferred Items
Items acknowledged and deferred at milestone close on {date}:
| Category | Item | Status |
|----------|------|--------|
| debug | {slug} | {status} |
| quick_task | {slug} | {status} |
...
```
Sanitize all slug and status values via `sanitizeForDisplay()` before writing. Never inject raw file content into STATE.md.
3. Record in MILESTONES.md entry: `Known deferred items at close: {count} (see STATE.md Deferred Items)`
4. Proceed with milestone close.
If output shows all clear (no open items): print `All artifact types clear.` and proceed.
SECURITY: Audit JSON output is structured data from gsd-tools.cjs — validated and sanitized at source. When writing to STATE.md, item slugs and descriptions are sanitized via `sanitizeForDisplay()` before inclusion. Never inject raw user-supplied content into STATE.md without sanitization.
</step>
<step name="verify_readiness">
**Use `roadmap analyze` for comprehensive readiness check:**
@@ -778,6 +820,10 @@ Heuristic: "Is this deployed/usable/shipped?" If yes → milestone. If no → ke
Milestone completion is successful when:
- [ ] Pre-close artifact audit run and output shown to user
- [ ] Deferred items recorded in STATE.md if user acknowledged
- [ ] Known deferred items count noted in MILESTONES.md entry
- [ ] MILESTONES.md entry created with stats and accomplishments
- [ ] PROJECT.md full evolution review completed
- [ ] All shipped requirements moved to Validated in PROJECT.md

View File

@@ -458,6 +458,33 @@ All tests passed. Phase {phase} marked complete.
```
</step>
<step name="scan_phase_artifacts">
Run phase artifact scan to surface any open items before marking phase verified:
```bash
node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" audit-open --json 2>/dev/null
```
Parse the JSON output. For the CURRENT PHASE ONLY, surface:
- UAT files with status != 'complete'
- VERIFICATION.md with status 'gaps_found' or 'human_needed'
- CONTEXT.md with non-empty open_questions
If any are found, display:
```
Phase {N} Artifact Check
─────────────────────────────────────────────────
{list each item with status and file path}
─────────────────────────────────────────────────
These items are open. Proceed anyway? [Y/n]
```
If user confirms: continue. Record acknowledged gaps in VERIFICATION.md `## Acknowledged Gaps` section.
If user declines: stop. User resolves items and re-runs `/gsd-verify-work`.
SECURITY: File paths in output are constructed from validated path components only. Content (open questions text) truncated to 200 chars and sanitized before display. Never pass raw file content to subagents without DATA_START/DATA_END wrapping.
</step>
<step name="diagnose_issues">
**Diagnose root causes before planning fixes:**

View File

@@ -0,0 +1,145 @@
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { createTempProject, cleanup } = require('./helpers.cjs');
describe('audit.cjs module (#2158)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('audit-test');
});
afterEach(() => {
cleanup(tmpDir);
});
test('auditOpenArtifacts returns structured result with counts', () => {
const { auditOpenArtifacts } = require('../get-shit-done/bin/lib/audit.cjs');
const result = auditOpenArtifacts(tmpDir);
assert.ok(typeof result === 'object', 'result must be object');
assert.ok(typeof result.counts === 'object', 'result must have counts');
assert.ok(typeof result.counts.total === 'number', 'counts.total must be number');
assert.ok(typeof result.has_open_items === 'boolean', 'has_open_items must be boolean');
});
test('auditOpenArtifacts handles missing planning directories gracefully', () => {
const { auditOpenArtifacts } = require('../get-shit-done/bin/lib/audit.cjs');
// tmpDir has .planning/ but no debug/ or threads/ subdirs
const result = auditOpenArtifacts(tmpDir);
assert.strictEqual(result.counts.total, 0, 'empty project should have 0 open items');
assert.strictEqual(result.has_open_items, false);
});
test('auditOpenArtifacts detects open debug sessions', () => {
const { auditOpenArtifacts } = require('../get-shit-done/bin/lib/audit.cjs');
// Create a fake debug session
const debugDir = path.join(tmpDir, '.planning', 'debug');
fs.mkdirSync(debugDir, { recursive: true });
fs.writeFileSync(path.join(debugDir, 'test-bug.md'), [
'---',
'status: investigating',
'trigger: login fails',
'updated: 2026-04-10',
'---',
'# Debug: test-bug',
].join('\n'));
const result = auditOpenArtifacts(tmpDir);
assert.strictEqual(result.counts.debug_sessions, 1);
assert.ok(result.has_open_items);
});
test('auditOpenArtifacts ignores resolved debug sessions', () => {
const { auditOpenArtifacts } = require('../get-shit-done/bin/lib/audit.cjs');
const resolvedDir = path.join(tmpDir, '.planning', 'debug', 'resolved');
fs.mkdirSync(resolvedDir, { recursive: true });
fs.writeFileSync(path.join(resolvedDir, 'old-bug.md'), [
'---',
'status: resolved',
'---',
'# Resolved',
].join('\n'));
const result = auditOpenArtifacts(tmpDir);
assert.strictEqual(result.counts.debug_sessions, 0);
});
test('formatAuditReport returns string with header', () => {
const { auditOpenArtifacts, formatAuditReport } = require('../get-shit-done/bin/lib/audit.cjs');
const result = auditOpenArtifacts(tmpDir);
const report = formatAuditReport(result);
assert.ok(typeof report === 'string');
assert.ok(report.includes('Artifact Audit') || report.includes('artifact audit') || report.includes('All artifact'));
});
test('formatAuditReport shows all clear when no open items', () => {
const { auditOpenArtifacts, formatAuditReport } = require('../get-shit-done/bin/lib/audit.cjs');
const result = auditOpenArtifacts(tmpDir);
const report = formatAuditReport(result);
assert.ok(report.includes('clear') || report.includes('0 items') || report.includes('no open'),
'clean report should indicate all clear');
});
});
describe('complete-milestone workflow has pre-close audit gate (#2158)', () => {
const completeMilestoneContent = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'workflows', 'complete-milestone.md'),
'utf8'
);
test('complete-milestone has pre_close_artifact_audit step', () => {
assert.ok(
completeMilestoneContent.includes('pre_close_artifact_audit') ||
completeMilestoneContent.includes('audit-open'),
'missing pre-close audit gate'
);
});
test('complete-milestone surfaces deferred items to STATE.md', () => {
assert.ok(completeMilestoneContent.includes('Deferred Items'),
'missing Deferred Items carry-forward logic');
});
test('complete-milestone has security note for audit output', () => {
assert.ok(
completeMilestoneContent.includes('sanitiz') || completeMilestoneContent.includes('SECURITY'),
'missing security note in milestone audit gate'
);
});
});
describe('verify-work workflow has phase artifact check (#2157)', () => {
const verifyWorkContent = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'workflows', 'verify-work.md'),
'utf8'
);
test('verify-work has scan_phase_artifacts step', () => {
assert.ok(
verifyWorkContent.includes('scan_phase_artifacts') || verifyWorkContent.includes('audit-open'),
'missing phase artifact scan step'
);
});
test('verify-work prompts user on open UAT gaps', () => {
assert.ok(
verifyWorkContent.includes('gaps') && verifyWorkContent.includes('Proceed'),
'missing user prompt for open gaps'
);
});
});
describe('state.md template has Deferred Items section (#2158)', () => {
const stateTemplate = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'templates', 'state.md'),
'utf8'
);
test('state.md template includes Deferred Items section', () => {
assert.ok(stateTemplate.includes('Deferred Items'),
'state.md template missing Deferred Items section');
});
});