Enable multiple Claude Code instances to work on the same codebase
simultaneously by scoping .planning/ state into workstreams.
Core changes:
- planningDir(cwd, ws?) and planningPaths(cwd, ws?) are now workstream-aware
via GSD_WORKSTREAM env var (auto-detected from --ws flag or active-workstream file)
- All bin/lib modules use planningDir(cwd) for scoped paths (STATE.md, ROADMAP.md,
phases/, REQUIREMENTS.md) and planningRoot(cwd) for shared paths (milestones/,
PROJECT.md, config.json, codebase/)
- New workstream.cjs module: create, list, status, complete, set, get, progress
- gsd-tools.cjs: --ws flag parsing with priority chain
(--ws > GSD_WORKSTREAM env > active-workstream file > flat mode)
- Collision detection: transition.md checks for other active workstreams before
suggesting next-milestone continuation (prevents WS A from trampling WS B)
- ${GSD_WS} routing propagation across all 9 workflow files ensures workstream
scope chains automatically through the workflow lifecycle
New files:
- get-shit-done/bin/lib/workstream.cjs (CRUD + collision detection)
- get-shit-done/commands/gsd/workstreams.md (slash command)
- get-shit-done/references/workstream-flag.md (documentation)
- tests/workstream.test.cjs (20 tests covering CRUD, env var routing, --ws flag)
All 1062 tests passing (1042 existing + 20 new workstream tests).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
190 lines
6.5 KiB
JavaScript
190 lines
6.5 KiB
JavaScript
/**
|
|
* UAT Audit — Cross-phase UAT/VERIFICATION scanner
|
|
*
|
|
* Reads all *-UAT.md and *-VERIFICATION.md files across all phases.
|
|
* Extracts non-passing items. Returns structured JSON for workflow consumption.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { output, error, getMilestonePhaseFilter, planningDir, toPosixPath } = require('./core.cjs');
|
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
|
|
function cmdAuditUat(cwd, raw) {
|
|
const phasesDir = path.join(planningDir(cwd), 'phases');
|
|
if (!fs.existsSync(phasesDir)) {
|
|
error('No phases directory found in planning directory');
|
|
}
|
|
|
|
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
|
const results = [];
|
|
|
|
// Scan all phase directories
|
|
const dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory())
|
|
.map(e => e.name)
|
|
.filter(isDirInMilestone)
|
|
.sort();
|
|
|
|
for (const dir of dirs) {
|
|
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
|
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
|
const phaseDir = path.join(phasesDir, dir);
|
|
const files = fs.readdirSync(phaseDir);
|
|
|
|
// Process UAT files
|
|
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
|
|
const content = fs.readFileSync(path.join(phaseDir, file), 'utf-8');
|
|
const items = parseUatItems(content);
|
|
if (items.length > 0) {
|
|
results.push({
|
|
phase: phaseNum,
|
|
phase_dir: dir,
|
|
file,
|
|
file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, file))),
|
|
type: 'uat',
|
|
status: (extractFrontmatter(content).status || 'unknown'),
|
|
items,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Process VERIFICATION files
|
|
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
|
|
const content = fs.readFileSync(path.join(phaseDir, file), 'utf-8');
|
|
const status = extractFrontmatter(content).status || 'unknown';
|
|
if (status === 'human_needed' || status === 'gaps_found') {
|
|
const items = parseVerificationItems(content, status);
|
|
if (items.length > 0) {
|
|
results.push({
|
|
phase: phaseNum,
|
|
phase_dir: dir,
|
|
file,
|
|
file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, file))),
|
|
type: 'verification',
|
|
status,
|
|
items,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute summary
|
|
const summary = {
|
|
total_files: results.length,
|
|
total_items: results.reduce((sum, r) => sum + r.items.length, 0),
|
|
by_category: {},
|
|
by_phase: {},
|
|
};
|
|
|
|
for (const r of results) {
|
|
if (!summary.by_phase[r.phase]) summary.by_phase[r.phase] = 0;
|
|
for (const item of r.items) {
|
|
summary.by_phase[r.phase]++;
|
|
const cat = item.category || 'unknown';
|
|
summary.by_category[cat] = (summary.by_category[cat] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
output({ results, summary }, raw);
|
|
}
|
|
|
|
function parseUatItems(content) {
|
|
const items = [];
|
|
// Match test blocks: ### N. Name\nexpected: ...\nresult: ...\n
|
|
const testPattern = /###\s*(\d+)\.\s*([^\n]+)\nexpected:\s*([^\n]+)\nresult:\s*(\w+)(?:\n(?:reported|reason|blocked_by):\s*[^\n]*)?/g;
|
|
let match;
|
|
while ((match = testPattern.exec(content)) !== null) {
|
|
const [, num, name, expected, result] = match;
|
|
if (result === 'pending' || result === 'skipped' || result === 'blocked') {
|
|
// Extract optional fields — limit to current test block (up to next ### or EOF)
|
|
const afterMatch = content.slice(match.index);
|
|
const nextHeading = afterMatch.indexOf('\n###', 1);
|
|
const blockText = nextHeading > 0 ? afterMatch.slice(0, nextHeading) : afterMatch;
|
|
const reasonMatch = blockText.match(/reason:\s*(.+)/);
|
|
const blockedByMatch = blockText.match(/blocked_by:\s*(.+)/);
|
|
|
|
const item = {
|
|
test: parseInt(num, 10),
|
|
name: name.trim(),
|
|
expected: expected.trim(),
|
|
result,
|
|
category: categorizeItem(result, reasonMatch?.[1], blockedByMatch?.[1]),
|
|
};
|
|
if (reasonMatch) item.reason = reasonMatch[1].trim();
|
|
if (blockedByMatch) item.blocked_by = blockedByMatch[1].trim();
|
|
items.push(item);
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
function parseVerificationItems(content, status) {
|
|
const items = [];
|
|
if (status === 'human_needed') {
|
|
// Extract from human_verification section — look for numbered items or table rows
|
|
const hvSection = content.match(/##\s*Human Verification.*?\n([\s\S]*?)(?=\n##\s|\n---\s|$)/i);
|
|
if (hvSection) {
|
|
const lines = hvSection[1].split('\n');
|
|
for (const line of lines) {
|
|
// Match table rows: | N | description | ... |
|
|
const tableMatch = line.match(/\|\s*(\d+)\s*\|\s*([^|]+)/);
|
|
// Match bullet items: - description
|
|
const bulletMatch = line.match(/^[-*]\s+(.+)/);
|
|
// Match numbered items: 1. description
|
|
const numberedMatch = line.match(/^(\d+)\.\s+(.+)/);
|
|
|
|
if (tableMatch) {
|
|
items.push({
|
|
test: parseInt(tableMatch[1], 10),
|
|
name: tableMatch[2].trim(),
|
|
result: 'human_needed',
|
|
category: 'human_uat',
|
|
});
|
|
} else if (numberedMatch) {
|
|
items.push({
|
|
test: parseInt(numberedMatch[1], 10),
|
|
name: numberedMatch[2].trim(),
|
|
result: 'human_needed',
|
|
category: 'human_uat',
|
|
});
|
|
} else if (bulletMatch && bulletMatch[1].length > 10) {
|
|
items.push({
|
|
name: bulletMatch[1].trim(),
|
|
result: 'human_needed',
|
|
category: 'human_uat',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// gaps_found items are already handled by plan-phase --gaps pipeline
|
|
return items;
|
|
}
|
|
|
|
function categorizeItem(result, reason, blockedBy) {
|
|
if (result === 'blocked' || blockedBy) {
|
|
if (blockedBy) {
|
|
if (/server/i.test(blockedBy)) return 'server_blocked';
|
|
if (/device|physical/i.test(blockedBy)) return 'device_needed';
|
|
if (/build|release|preview/i.test(blockedBy)) return 'build_needed';
|
|
if (/third.party|twilio|stripe/i.test(blockedBy)) return 'third_party';
|
|
}
|
|
return 'blocked';
|
|
}
|
|
if (result === 'skipped') {
|
|
if (reason) {
|
|
if (/server|not running|not available/i.test(reason)) return 'server_blocked';
|
|
if (/simulator|physical|device/i.test(reason)) return 'device_needed';
|
|
if (/build|release|preview/i.test(reason)) return 'build_needed';
|
|
}
|
|
return 'skipped_unresolved';
|
|
}
|
|
if (result === 'pending') return 'pending';
|
|
if (result === 'human_needed') return 'human_uat';
|
|
return 'unknown';
|
|
}
|
|
|
|
module.exports = { cmdAuditUat };
|