feat: add /gsd:forensics for post-mortem workflow investigation (#1303)

New command that investigates failed or stuck GSD workflows by analyzing
git history, .planning/ artifacts, and file system state. Detects 6
anomaly types: stuck loops, missing artifacts, abandoned work, crash/
interruption, scope drift, and test regressions.

Inspired by gsd-build/gsd-2's forensics feature, adapted for gsd-1's
markdown-prompt architecture. Uses heuristic (LLM-judged) analysis
against available data sources rather than structured telemetry.

- commands/gsd/forensics.md: command with success_criteria + critical_rules
- get-shit-done/workflows/forensics.md: 8-step investigation workflow
- tests/forensics.test.cjs: 21 tests (command, workflow, report, fixtures)
- tests/copilot-install.test.cjs: bump expected skill count 55→56

Generates report to .planning/forensics/, offers interactive investigation,
and optional GitHub issue creation from findings.

Closes #1303

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-03-21 17:49:41 -04:00
parent 16b917ce69
commit 319f4bd6de
4 changed files with 574 additions and 2 deletions

56
commands/gsd/forensics.md Normal file
View File

@@ -0,0 +1,56 @@
---
type: prompt
name: gsd:forensics
description: Post-mortem investigation for failed GSD workflows — analyzes git history, artifacts, and state to diagnose what went wrong
argument-hint: "[problem description]"
allowed-tools:
- Read
- Write
- Bash
- Grep
- Glob
---
<objective>
Investigate what went wrong during a GSD workflow execution. Analyzes git history, `.planning/` artifacts, and file system state to detect anomalies and generate a structured diagnostic report.
Purpose: Diagnose failed or stuck workflows so the user can understand root cause and take corrective action.
Output: Forensic report saved to `.planning/forensics/`, presented inline, with optional issue creation.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/forensics.md
</execution_context>
<context>
**Data sources:**
- `git log` (recent commits, patterns, time gaps)
- `git status` / `git diff` (uncommitted work, conflicts)
- `.planning/STATE.md` (current position, session history)
- `.planning/ROADMAP.md` (phase scope and progress)
- `.planning/phases/*/` (PLAN.md, SUMMARY.md, VERIFICATION.md, CONTEXT.md)
- `.planning/reports/SESSION_REPORT.md` (last session outcomes)
**User input:**
- Problem description: $ARGUMENTS (optional — will ask if not provided)
</context>
<process>
Read and execute the forensics workflow from @~/.claude/get-shit-done/workflows/forensics.md end-to-end.
</process>
<success_criteria>
- Evidence gathered from all available data sources
- At least 4 anomaly types checked (stuck loop, missing artifacts, abandoned work, crash/interruption)
- Structured forensic report written to `.planning/forensics/report-{timestamp}.md`
- Report presented inline with findings, anomalies, and recommendations
- Interactive investigation offered for deeper analysis
- GitHub issue creation offered if actionable findings exist
</success_criteria>
<critical_rules>
- **Read-only investigation:** Do not modify any project files during forensics. Only write the report.
- **Redact sensitive data:** Strip absolute paths, API keys, tokens from reports and issues.
- **Ground findings in evidence:** Every anomaly must cite specific commits, files, or state data.
- **No speculation without evidence:** If data is insufficient, say so — do not fabricate root causes.
</critical_rules>

View File

@@ -0,0 +1,258 @@
# Forensics Workflow
Post-mortem investigation for failed or stuck GSD workflows. Analyzes git history,
`.planning/` artifacts, and file system state to detect anomalies and generate a
structured diagnostic report.
**Principle:** This is a read-only investigation. Do not modify project files.
Only write the forensic report.
---
## Step 1: Get Problem Description
```bash
PROBLEM="$ARGUMENTS"
```
If `$ARGUMENTS` is empty, ask the user:
> "What went wrong? Describe the issue — e.g., 'autonomous mode got stuck on phase 3',
> 'execute-phase failed silently', 'costs seem unusually high'."
Record the problem description for the report.
## Step 2: Gather Evidence
Collect data from all available sources. Missing sources are fine — adapt to what exists.
### 2a. Git History
```bash
# Recent commits (last 30)
git log --oneline -30
# Commits with timestamps for gap analysis
git log --format="%H %ai %s" -30
# Files changed in recent commits (detect repeated edits)
git log --name-only --format="" -20 | sort | uniq -c | sort -rn | head -20
# Uncommitted work
git status --short
git diff --stat
```
Record:
- Commit timeline (dates, messages, frequency)
- Most-edited files (potential stuck-loop indicator)
- Uncommitted changes (potential crash/interruption indicator)
### 2b. Planning State
Read these files if they exist:
- `.planning/STATE.md` — current milestone, phase, progress, blockers, last session
- `.planning/ROADMAP.md` — phase list with status
- `.planning/config.json` — workflow configuration
Extract:
- Current phase and its status
- Last recorded session stop point
- Any blockers or flags
### 2c. Phase Artifacts
For each phase directory in `.planning/phases/*/`:
```bash
ls .planning/phases/*/
```
For each phase, check which artifacts exist:
- `{padded}-PLAN.md` or `{padded}-PLAN-*.md` (execution plans)
- `{padded}-SUMMARY.md` (completion summary)
- `{padded}-VERIFICATION.md` (quality verification)
- `{padded}-CONTEXT.md` (design decisions)
- `{padded}-RESEARCH.md` (pre-planning research)
Track: which phases have complete artifact sets vs gaps.
### 2d. Session Reports
Read `.planning/reports/SESSION_REPORT.md` if it exists — extract last session outcomes,
work completed, token estimates.
### 2e. Git Worktree State
```bash
git worktree list
```
Check for orphaned worktrees (from crashed agents).
## Step 3: Detect Anomalies
Evaluate the gathered evidence against these anomaly patterns:
### Stuck Loop Detection
**Signal:** Same file appears in 3+ consecutive commits within a short time window.
```bash
# Look for files committed repeatedly in sequence
git log --name-only --format="---COMMIT---" -20
```
Parse commit boundaries. If any file appears in 3+ consecutive commits, flag as:
- **Confidence HIGH** if the commit messages are similar (e.g., "fix:", "fix:", "fix:" on same file)
- **Confidence MEDIUM** if the file appears frequently but commit messages vary
### Missing Artifact Detection
**Signal:** Phase appears complete (has commits, is past in roadmap) but lacks expected artifacts.
For each phase that should be complete:
- PLAN.md missing → planning step was skipped
- SUMMARY.md missing → phase was not properly closed
- VERIFICATION.md missing → quality check was skipped
### Abandoned Work Detection
**Signal:** Large gap between last commit and current time, with STATE.md showing mid-execution.
```bash
# Time since last commit
git log -1 --format="%ai"
```
If STATE.md shows an active phase but the last commit is >2 hours old and there are
uncommitted changes, flag as potential abandonment or crash.
### Crash/Interruption Detection
**Signal:** Uncommitted changes + STATE.md shows mid-execution + orphaned worktrees.
Combine:
- `git status` shows modified/staged files
- STATE.md has an active execution entry
- `git worktree list` shows worktrees beyond the main one
### Scope Drift Detection
**Signal:** Recent commits touch files outside the current phase's expected scope.
Read the current phase PLAN.md to determine expected file paths. Compare against
files actually modified in recent commits. Flag any files that are clearly outside
the phase's domain.
### Test Regression Detection
**Signal:** Commit messages containing "fix test", "revert", or re-commits of test files.
```bash
git log --oneline -20 | grep -iE "fix test|revert|broken|regression|fail"
```
## Step 4: Generate Report
Create the forensics directory if needed:
```bash
mkdir -p .planning/forensics
```
Write to `.planning/forensics/report-$(date +%Y%m%d-%H%M%S).md`:
```markdown
# Forensic Report
**Generated:** {ISO timestamp}
**Problem:** {user's description}
---
## Evidence Summary
### Git Activity
- **Last commit:** {date} — "{message}"
- **Commits (last 30):** {count}
- **Time span:** {earliest} → {latest}
- **Uncommitted changes:** {yes/no — list if yes}
- **Active worktrees:** {count — list if >1}
### Planning State
- **Current milestone:** {version or "none"}
- **Current phase:** {number — name — status}
- **Last session:** {stopped_at from STATE.md}
- **Blockers:** {any flags from STATE.md}
### Artifact Completeness
| Phase | PLAN | CONTEXT | RESEARCH | SUMMARY | VERIFICATION |
|-------|------|---------|----------|---------|-------------|
{for each phase: name | ✅/❌ per artifact}
## Anomalies Detected
### {Anomaly Type} — {Confidence: HIGH/MEDIUM/LOW}
**Evidence:** {specific commits, files, or state data}
**Interpretation:** {what this likely means}
{repeat for each anomaly found}
## Root Cause Hypothesis
Based on the evidence above, the most likely explanation is:
{1-3 sentence hypothesis grounded in the anomalies}
## Recommended Actions
1. {Specific, actionable remediation step}
2. {Another step if applicable}
3. {Recovery command if applicable — e.g., `/gsd:resume-work`, `/gsd:execute-phase N`}
---
*Report generated by `/gsd:forensics`. All paths redacted for portability.*
```
**Redaction rules:**
- Replace absolute paths with relative paths (strip `$HOME` prefix)
- Remove any API keys, tokens, or credentials found in git diff output
- Truncate large diffs to first 50 lines
## Step 5: Present Report
Display the full forensic report inline.
## Step 6: Offer Interactive Investigation
> "Report saved to `.planning/forensics/report-{timestamp}.md`.
>
> I can dig deeper into any finding. Want me to:
> - Trace a specific anomaly to its root cause?
> - Read specific files referenced in the evidence?
> - Check if a similar issue has been reported before?"
If the user asks follow-up questions, answer from the evidence already gathered.
Read additional files only if specifically needed.
## Step 7: Offer Issue Creation
If actionable anomalies were found (HIGH or MEDIUM confidence):
> "Want me to create a GitHub issue for this? I'll format the findings and redact paths."
If confirmed:
```bash
gh issue create \
--title "bug: {concise description from anomaly}" \
--label "bug" \
--body "{formatted findings from report}"
```
## Step 8: Update STATE.md
```bash
gsd-tools.cjs state record-session \
--stopped-at "Forensic investigation complete" \
--resume-file ".planning/forensics/report-{timestamp}.md"
```

View File

@@ -620,7 +620,7 @@ describe('copyCommandsAsCopilotSkills', () => {
// Count gsd-* directories — should be 31
const dirs = fs.readdirSync(tempDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.strictEqual(dirs.length, 55, `expected 55 skill folders, got ${dirs.length}`);
assert.strictEqual(dirs.length, 56, `expected 56 skill folders, got ${dirs.length}`);
} finally {
fs.rmSync(tempDir, { recursive: true });
}
@@ -1114,7 +1114,7 @@ const { execFileSync } = require('child_process');
const crypto = require('crypto');
const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js');
const EXPECTED_SKILLS = 55;
const EXPECTED_SKILLS = 56;
const EXPECTED_AGENTS = 18;
function runCopilotInstall(cwd) {

258
tests/forensics.test.cjs Normal file
View File

@@ -0,0 +1,258 @@
/**
* GSD Forensics Tests
*
* Validates the forensics command and workflow files exist,
* follow expected patterns, and cover all anomaly detection types.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const repoRoot = path.resolve(__dirname, '..');
const commandPath = path.join(repoRoot, 'commands', 'gsd', 'forensics.md');
const workflowPath = path.join(repoRoot, 'get-shit-done', 'workflows', 'forensics.md');
describe('forensics command', () => {
test('command file exists', () => {
assert.ok(fs.existsSync(commandPath), 'commands/gsd/forensics.md should exist');
});
test('command has correct frontmatter', () => {
const content = fs.readFileSync(commandPath, 'utf-8');
assert.ok(content.includes('name: gsd:forensics'), 'should have correct command name');
assert.ok(content.includes('type: prompt'), 'should have type: prompt');
assert.ok(content.includes('argument-hint'), 'should have argument-hint');
});
test('command references workflow in execution_context', () => {
const content = fs.readFileSync(commandPath, 'utf-8');
assert.ok(
content.includes('workflows/forensics.md'),
'should reference the forensics workflow'
);
});
test('command has success_criteria section', () => {
const content = fs.readFileSync(commandPath, 'utf-8');
assert.ok(content.includes('<success_criteria>'), 'should have success_criteria');
});
test('command has critical_rules section', () => {
const content = fs.readFileSync(commandPath, 'utf-8');
assert.ok(content.includes('<critical_rules>'), 'should have critical_rules');
});
test('command enforces read-only investigation', () => {
const content = fs.readFileSync(commandPath, 'utf-8');
assert.ok(
content.toLowerCase().includes('read-only') || content.toLowerCase().includes('do not modify'),
'should enforce read-only investigation'
);
});
test('command requires evidence-grounded findings', () => {
const content = fs.readFileSync(commandPath, 'utf-8');
assert.ok(
content.includes('Ground findings') || content.includes('cite specific'),
'should require evidence-grounded analysis'
);
});
});
describe('forensics workflow', () => {
test('workflow file exists', () => {
assert.ok(fs.existsSync(workflowPath), 'workflows/forensics.md should exist');
});
test('workflow gathers evidence from all data sources', () => {
const content = fs.readFileSync(workflowPath, 'utf-8');
const sources = [
'git log',
'git status',
'STATE.md',
'ROADMAP.md',
'PLAN.md',
'SUMMARY.md',
'VERIFICATION.md',
'SESSION_REPORT',
'worktree',
];
for (const source of sources) {
assert.ok(
content.includes(source),
`workflow should reference data source: ${source}`
);
}
});
test('workflow detects all 6 anomaly types', () => {
const content = fs.readFileSync(workflowPath, 'utf-8');
const anomalies = [
'Stuck Loop',
'Missing Artifact',
'Abandoned Work',
'Crash',
'Scope Drift',
'Test Regression',
];
for (const anomaly of anomalies) {
assert.ok(
content.includes(anomaly),
`workflow should detect anomaly: ${anomaly}`
);
}
});
test('workflow writes report to forensics directory', () => {
const content = fs.readFileSync(workflowPath, 'utf-8');
assert.ok(
content.includes('.planning/forensics/report-'),
'should write to .planning/forensics/'
);
});
test('workflow includes redaction rules', () => {
const content = fs.readFileSync(workflowPath, 'utf-8');
assert.ok(
content.includes('Redaction') || content.includes('redact'),
'should include data redaction rules'
);
});
test('workflow offers interactive investigation', () => {
const content = fs.readFileSync(workflowPath, 'utf-8');
assert.ok(
content.includes('dig deeper') || content.includes('Interactive'),
'should offer interactive follow-up'
);
});
test('workflow offers GitHub issue creation', () => {
const content = fs.readFileSync(workflowPath, 'utf-8');
assert.ok(
content.includes('gh issue create'),
'should offer to create GitHub issue from findings'
);
});
test('workflow updates STATE.md', () => {
const content = fs.readFileSync(workflowPath, 'utf-8');
assert.ok(
content.includes('state record-session'),
'should update STATE.md via gsd-tools'
);
});
test('workflow has confidence levels for anomalies', () => {
const content = fs.readFileSync(workflowPath, 'utf-8');
assert.ok(
content.includes('HIGH') && content.includes('MEDIUM') && content.includes('LOW'),
'anomalies should have confidence levels'
);
});
});
describe('forensics report structure', () => {
test('report template has all required sections', () => {
const content = fs.readFileSync(workflowPath, 'utf-8');
const sections = [
'Evidence Summary',
'Git Activity',
'Planning State',
'Artifact Completeness',
'Anomalies Detected',
'Root Cause Hypothesis',
'Recommended Actions',
];
for (const section of sections) {
assert.ok(
content.includes(section),
`report should include section: "${section}"`
);
}
});
test('report includes artifact completeness table', () => {
const content = fs.readFileSync(workflowPath, 'utf-8');
assert.ok(
content.includes('PLAN') && content.includes('CONTEXT') && content.includes('RESEARCH') &&
content.includes('SUMMARY') && content.includes('VERIFICATION'),
'artifact table should check all 5 artifact types'
);
});
});
describe('forensics fixture-based tests', () => {
let tmpDir;
function setup() {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-forensics-test-'));
}
function teardown() {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
}
test('detects missing artifacts in phase structure', () => {
setup();
try {
// Phase 1: complete
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-PLAN-A.md'), 'plan');
fs.writeFileSync(path.join(phase1, '01-SUMMARY.md'), 'summary');
fs.writeFileSync(path.join(phase1, '01-VERIFICATION.md'), 'verification');
// Phase 2: missing SUMMARY and VERIFICATION (anomaly)
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-core');
fs.mkdirSync(phase2, { recursive: true });
fs.writeFileSync(path.join(phase2, '02-PLAN-A.md'), 'plan');
// Verify detection
const p1Files = fs.readdirSync(phase1);
const p2Files = fs.readdirSync(phase2);
assert.ok(p1Files.some(f => f.includes('SUMMARY')), 'phase 1 has SUMMARY');
assert.ok(p1Files.some(f => f.includes('VERIFICATION')), 'phase 1 has VERIFICATION');
assert.ok(!p2Files.some(f => f.includes('SUMMARY')), 'phase 2 missing SUMMARY (anomaly)');
assert.ok(!p2Files.some(f => f.includes('VERIFICATION')), 'phase 2 missing VERIFICATION (anomaly)');
} finally {
teardown();
}
});
test('forensics report directory can be created', () => {
setup();
try {
const forensicsDir = path.join(tmpDir, '.planning', 'forensics');
fs.mkdirSync(forensicsDir, { recursive: true });
const reportPath = path.join(forensicsDir, 'report-20260321-150000.md');
fs.writeFileSync(reportPath, '# Forensic Report\n');
assert.ok(fs.existsSync(reportPath), 'report file should be created');
const content = fs.readFileSync(reportPath, 'utf-8');
assert.ok(content.includes('Forensic Report'), 'report should have header');
} finally {
teardown();
}
});
test('handles project with no .planning directory', () => {
setup();
try {
// No .planning/ at all
const planningExists = fs.existsSync(path.join(tmpDir, '.planning'));
assert.strictEqual(planningExists, false, 'no .planning/ should exist');
// Forensics should still work with git data
const forensicsDir = path.join(tmpDir, '.planning', 'forensics');
fs.mkdirSync(forensicsDir, { recursive: true });
assert.ok(fs.existsSync(forensicsDir), 'forensics dir created on demand');
} finally {
teardown();
}
});
});