cleargate 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +11 -0
  3. package/dist/MANIFEST.json +259 -28
  4. package/dist/{chunk-OM4FAEA7.js → chunk-Q3BTSXCK.js} +69 -3
  5. package/dist/chunk-Q3BTSXCK.js.map +1 -0
  6. package/dist/cli.cjs +2621 -548
  7. package/dist/cli.cjs.map +1 -1
  8. package/dist/cli.js +2548 -560
  9. package/dist/cli.js.map +1 -1
  10. package/dist/lib/ledger.cjs +120 -0
  11. package/dist/lib/ledger.cjs.map +1 -0
  12. package/dist/lib/ledger.d.cts +64 -0
  13. package/dist/lib/ledger.d.ts +64 -0
  14. package/dist/lib/ledger.js +96 -0
  15. package/dist/lib/ledger.js.map +1 -0
  16. package/dist/templates/cleargate-planning/.claude/agents/architect.md +10 -8
  17. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-contradict.md +108 -0
  18. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-ingest.md +49 -3
  19. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-lint.md +6 -1
  20. package/dist/templates/cleargate-planning/.claude/agents/developer.md +29 -2
  21. package/dist/templates/cleargate-planning/.claude/agents/qa.md +50 -1
  22. package/dist/templates/cleargate-planning/.claude/agents/reporter.md +31 -9
  23. package/dist/templates/cleargate-planning/.claude/hooks/pre-tool-use-task.sh +148 -0
  24. package/dist/templates/cleargate-planning/.claude/hooks/session-start.sh +6 -0
  25. package/dist/templates/cleargate-planning/.claude/hooks/token-ledger.sh +314 -96
  26. package/dist/templates/cleargate-planning/.claude/settings.json +4 -0
  27. package/dist/templates/cleargate-planning/.claude/skills/sprint-execution/SKILL.md +473 -0
  28. package/dist/templates/cleargate-planning/.cleargate/config.example.yml +19 -0
  29. package/dist/templates/cleargate-planning/.cleargate/knowledge/cleargate-enforcement.md +542 -0
  30. package/dist/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +102 -428
  31. package/dist/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +31 -0
  32. package/dist/templates/cleargate-planning/.cleargate/knowledge/sprint-closeout-checklist.md +71 -0
  33. package/dist/templates/cleargate-planning/.cleargate/scripts/assert_story_files.mjs +24 -2
  34. package/dist/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +387 -27
  35. package/dist/templates/cleargate-planning/.cleargate/scripts/dedupe_frontmatter.mjs +219 -0
  36. package/dist/templates/cleargate-planning/.cleargate/scripts/lib/report-filename.mjs +54 -0
  37. package/dist/templates/cleargate-planning/.cleargate/scripts/prep_doc_refresh.mjs +378 -0
  38. package/dist/templates/cleargate-planning/.cleargate/scripts/prep_qa_context.mjs +888 -0
  39. package/dist/templates/cleargate-planning/.cleargate/scripts/sprint_trends.mjs +71 -0
  40. package/dist/templates/cleargate-planning/.cleargate/scripts/suggest_improvements.mjs +355 -13
  41. package/dist/templates/cleargate-planning/.cleargate/scripts/test/test_flashcard_gate.sh +20 -20
  42. package/dist/templates/cleargate-planning/.cleargate/scripts/test/test_prep_qa_context.sh +482 -0
  43. package/dist/templates/cleargate-planning/.cleargate/scripts/write_dispatch.sh +125 -0
  44. package/dist/templates/cleargate-planning/.cleargate/templates/Bug.md +33 -10
  45. package/dist/templates/cleargate-planning/.cleargate/templates/CR.md +41 -10
  46. package/dist/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +48 -14
  47. package/dist/templates/cleargate-planning/.cleargate/templates/epic.md +46 -12
  48. package/dist/templates/cleargate-planning/.cleargate/templates/hotfix.md +51 -1
  49. package/dist/templates/cleargate-planning/.cleargate/templates/initiative.md +98 -29
  50. package/dist/templates/cleargate-planning/.cleargate/templates/proposal.md +26 -13
  51. package/dist/templates/cleargate-planning/.cleargate/templates/sprint_report.md +23 -4
  52. package/dist/templates/cleargate-planning/.cleargate/templates/story.md +64 -12
  53. package/dist/templates/cleargate-planning/CLAUDE.md +28 -10
  54. package/dist/templates/cleargate-planning/MANIFEST.json +259 -28
  55. package/dist/{whoami-CX7CXJD5.js → whoami-W4U6DPVG.js} +17 -17
  56. package/dist/whoami-W4U6DPVG.js.map +1 -0
  57. package/package.json +13 -2
  58. package/templates/cleargate-planning/.claude/agents/architect.md +10 -8
  59. package/templates/cleargate-planning/.claude/agents/cleargate-wiki-contradict.md +108 -0
  60. package/templates/cleargate-planning/.claude/agents/cleargate-wiki-ingest.md +49 -3
  61. package/templates/cleargate-planning/.claude/agents/cleargate-wiki-lint.md +6 -1
  62. package/templates/cleargate-planning/.claude/agents/developer.md +29 -2
  63. package/templates/cleargate-planning/.claude/agents/qa.md +50 -1
  64. package/templates/cleargate-planning/.claude/agents/reporter.md +31 -9
  65. package/templates/cleargate-planning/.claude/hooks/pre-tool-use-task.sh +148 -0
  66. package/templates/cleargate-planning/.claude/hooks/session-start.sh +6 -0
  67. package/templates/cleargate-planning/.claude/hooks/token-ledger.sh +314 -96
  68. package/templates/cleargate-planning/.claude/settings.json +4 -0
  69. package/templates/cleargate-planning/.claude/skills/sprint-execution/SKILL.md +473 -0
  70. package/templates/cleargate-planning/.cleargate/config.example.yml +19 -0
  71. package/templates/cleargate-planning/.cleargate/knowledge/cleargate-enforcement.md +542 -0
  72. package/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +102 -428
  73. package/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +31 -0
  74. package/templates/cleargate-planning/.cleargate/knowledge/sprint-closeout-checklist.md +71 -0
  75. package/templates/cleargate-planning/.cleargate/scripts/assert_story_files.mjs +24 -2
  76. package/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +387 -27
  77. package/templates/cleargate-planning/.cleargate/scripts/dedupe_frontmatter.mjs +219 -0
  78. package/templates/cleargate-planning/.cleargate/scripts/lib/report-filename.mjs +54 -0
  79. package/templates/cleargate-planning/.cleargate/scripts/prep_doc_refresh.mjs +378 -0
  80. package/templates/cleargate-planning/.cleargate/scripts/prep_qa_context.mjs +888 -0
  81. package/templates/cleargate-planning/.cleargate/scripts/sprint_trends.mjs +71 -0
  82. package/templates/cleargate-planning/.cleargate/scripts/suggest_improvements.mjs +355 -13
  83. package/templates/cleargate-planning/.cleargate/scripts/test/test_flashcard_gate.sh +20 -20
  84. package/templates/cleargate-planning/.cleargate/scripts/test/test_prep_qa_context.sh +482 -0
  85. package/templates/cleargate-planning/.cleargate/scripts/write_dispatch.sh +125 -0
  86. package/templates/cleargate-planning/.cleargate/templates/Bug.md +33 -10
  87. package/templates/cleargate-planning/.cleargate/templates/CR.md +41 -10
  88. package/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +48 -14
  89. package/templates/cleargate-planning/.cleargate/templates/epic.md +46 -12
  90. package/templates/cleargate-planning/.cleargate/templates/hotfix.md +51 -1
  91. package/templates/cleargate-planning/.cleargate/templates/initiative.md +98 -29
  92. package/templates/cleargate-planning/.cleargate/templates/sprint_report.md +23 -4
  93. package/templates/cleargate-planning/.cleargate/templates/story.md +64 -12
  94. package/templates/cleargate-planning/CLAUDE.md +28 -10
  95. package/templates/cleargate-planning/MANIFEST.json +259 -28
  96. package/dist/chunk-OM4FAEA7.js.map +0 -1
  97. package/dist/whoami-CX7CXJD5.js.map +0 -1
  98. package/templates/cleargate-planning/.cleargate/templates/proposal.md +0 -61
@@ -0,0 +1,888 @@
1
+ #!/usr/bin/env node
2
+ // schema_version: 1 — frozen for SPRINT-19 M8
3
+ //
4
+ // prep_qa_context.mjs — QA context-bundle assembler
5
+ //
6
+ // Usage:
7
+ // node prep_qa_context.mjs <story-id> <worktree-path> [--output <path>] [--dev-handoff-json <path>]
8
+ //
9
+ // Positional args:
10
+ // <story-id> — e.g. STORY-025-04 or CR-024
11
+ // <worktree-path> — absolute path to the developer's worktree
12
+ //
13
+ // Options:
14
+ // --output <path> — override default output path
15
+ // --dev-handoff-json <path>— path to JSON file containing dev's STATUS=done handoff
16
+ //
17
+ // Env overrides:
18
+ // CLEARGATE_SPRINT_DIR — override the sprint-runs/<sprint-id> directory
19
+ // CLEARGATE_PENDING_SYNC_DIR — override .cleargate/delivery/pending-sync/
20
+ //
21
+ // Output file: <sprint-dir>/.qa-context-<story-id>.md (default)
22
+ //
23
+ // Bundle target: ≤20KB. If exceeded, warns to stderr but still writes.
24
+ //
25
+ // Schema freeze contract (M8 reads exactly v1):
26
+ // ```json
27
+ // {
28
+ // "schema_version": 1,
29
+ // "story_id": "STORY-NNN-NN",
30
+ // "sprint_id": "SPRINT-NN",
31
+ // "generated_at": "ISO-8601",
32
+ // "worktree": {
33
+ // "path": "string",
34
+ // "branch": "string",
35
+ // "head_sha": "string",
36
+ // "dev_status_block_present": "boolean"
37
+ // },
38
+ // "spec_sources": {
39
+ // "story_path": "string|null",
40
+ // "plan_path": "string|null",
41
+ // "spec_pointers": [{"section": "string", "path": "string", "line_range": "string"}]
42
+ // },
43
+ // "baseline": {
44
+ // "main_head_sha": "string",
45
+ // "baseline_unavailable": "boolean",
46
+ // "failures": [{"file": "string", "count": "integer"}]
47
+ // },
48
+ // "adjacent": {
49
+ // "touched_files": ["string"],
50
+ // "adjacent_test_files": ["string"],
51
+ // "mirror_pairs": [{"touched": "string", "mirror": "string"}]
52
+ // },
53
+ // "cross_story_map": [
54
+ // {"story_id": "string", "branch": "string", "head_sha": "string", "shared_files": ["string"]}
55
+ // ],
56
+ // "flashcard_slice": {
57
+ // "tags_inferred": ["string"],
58
+ // "entries": ["string"]
59
+ // },
60
+ // "lane": {
61
+ // "value": "fast|standard|runtime",
62
+ // "source": "state.json|default|not-yet-runtime-aware"
63
+ // },
64
+ // "dev_handoff": {
65
+ // "format": "structured|legacy|absent",
66
+ // "status": "done|blocked|null",
67
+ // "commit": "string|null",
68
+ // "typecheck": "pass|fail|null",
69
+ // "tests": "string|null",
70
+ // "files_changed": ["string"],
71
+ // "notes": "string|null",
72
+ // "r_coverage": [{"r_id": "string", "covered": "boolean", "deferred": "boolean", "clarified": "boolean"}],
73
+ // "plan_deviations": [{"what": "string", "why": "string", "orchestrator_confirmed": "boolean"}],
74
+ // "adjacent_files": ["string"],
75
+ // "flashcards_flagged": ["string"]
76
+ // }
77
+ // }
78
+ // ```
79
+ //
80
+ // Exit codes:
81
+ // 0 — success (including all R4 soft-degrades)
82
+ // 1 — hard error (worktree path doesn't exist, git command fails unrecoverably)
83
+ // 2 — usage error (missing required args)
84
+ //
85
+ // M8 hand-off: M8 (CR-024 S2) consumes this schema. M2 freezes; M8 wires.
86
+
87
+ import fs from 'node:fs';
88
+ import path from 'node:path';
89
+ import { execSync } from 'node:child_process';
90
+ import { fileURLToPath } from 'node:url';
91
+ import { VALID_STATES, TERMINAL_STATES } from './constants.mjs';
92
+
93
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
94
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
95
+
96
+ // ── Env overrides ─────────────────────────────────────────────────────────────
97
+
98
+ function resolveSprintDir(sprintId) {
99
+ return process.env.CLEARGATE_SPRINT_DIR
100
+ ? path.resolve(process.env.CLEARGATE_SPRINT_DIR)
101
+ : path.join(REPO_ROOT, '.cleargate', 'sprint-runs', sprintId);
102
+ }
103
+
104
+ function resolvePendingSyncDir() {
105
+ return process.env.CLEARGATE_PENDING_SYNC_DIR
106
+ ? path.resolve(process.env.CLEARGATE_PENDING_SYNC_DIR)
107
+ : path.join(REPO_ROOT, '.cleargate', 'delivery', 'pending-sync');
108
+ }
109
+
110
+ function resolveArchiveDir() {
111
+ return path.join(REPO_ROOT, '.cleargate', 'delivery', 'archive');
112
+ }
113
+
114
+ // ── Atomic write ──────────────────────────────────────────────────────────────
115
+
116
+ function atomicWrite(filePath, content) {
117
+ const tmpFile = `${filePath}.tmp.${process.pid}`;
118
+ fs.writeFileSync(tmpFile, content, 'utf8');
119
+ fs.renameSync(tmpFile, filePath);
120
+ }
121
+
122
+ // ── Frontmatter parser ────────────────────────────────────────────────────────
123
+
124
+ function parseFrontmatter(content) {
125
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
126
+ if (!fmMatch) return {};
127
+ const fields = {};
128
+ for (const line of fmMatch[1].split('\n')) {
129
+ const m = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):\s*"?([^"]*)"?\s*$/);
130
+ if (m) {
131
+ const val = m[2].trim();
132
+ fields[m[1]] = val === 'null' || val === '' ? null : val;
133
+ }
134
+ }
135
+ return fields;
136
+ }
137
+
138
+ // ── Git helpers ───────────────────────────────────────────────────────────────
139
+
140
+ function gitExec(args, cwd) {
141
+ try {
142
+ return execSync(`git -C "${cwd}" ${args}`, {
143
+ stdio: ['ignore', 'pipe', 'ignore'],
144
+ encoding: 'utf8',
145
+ }).trim();
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ // ── Sprint ID derivation ──────────────────────────────────────────────────────
152
+
153
+ function deriveSprintId() {
154
+ if (process.env.CLEARGATE_SPRINT_DIR) {
155
+ return path.basename(path.resolve(process.env.CLEARGATE_SPRINT_DIR));
156
+ }
157
+ const activePath = path.join(REPO_ROOT, '.cleargate', 'sprint-runs', '.active');
158
+ if (fs.existsSync(activePath)) {
159
+ return fs.readFileSync(activePath, 'utf8').trim();
160
+ }
161
+ return 'SPRINT-UNKNOWN';
162
+ }
163
+
164
+ // ── Section 1: Worktree + Commit ──────────────────────────────────────────────
165
+
166
+ function buildWorktreeSection(storyId, worktreePath, devHandoffJson) {
167
+ const branch = (() => {
168
+ const raw = gitExec('symbolic-ref HEAD', worktreePath);
169
+ if (!raw) return 'unknown';
170
+ return raw.replace(/^refs\/heads\//, '');
171
+ })();
172
+
173
+ const headSha = gitExec('rev-parse HEAD', worktreePath) || 'unknown';
174
+
175
+ // Check if STATUS=done block is present
176
+ let devStatusBlockPresent = false;
177
+ if (devHandoffJson) {
178
+ try {
179
+ const handoff = JSON.parse(fs.readFileSync(devHandoffJson, 'utf8'));
180
+ devStatusBlockPresent = !!(handoff.status && /done|blocked/i.test(handoff.status));
181
+ } catch {
182
+ // fallthrough
183
+ }
184
+ }
185
+ if (!devStatusBlockPresent && headSha !== 'unknown') {
186
+ const commitMsg = gitExec(`log -1 --format=%B ${headSha}`, worktreePath);
187
+ if (commitMsg && /STATUS:\s*(done|blocked)/i.test(commitMsg)) {
188
+ devStatusBlockPresent = true;
189
+ }
190
+ }
191
+
192
+ return {
193
+ path: worktreePath,
194
+ branch,
195
+ head_sha: headSha,
196
+ dev_status_block_present: devStatusBlockPresent,
197
+ };
198
+ }
199
+
200
+ // ── Section 2: Spec Sources ───────────────────────────────────────────────────
201
+
202
+ function buildSpecSources(storyId, sprintId, sprintDir) {
203
+ // Find story file
204
+ let storyPath = null;
205
+ const pendingSync = resolvePendingSyncDir();
206
+ const archive = resolveArchiveDir();
207
+ for (const dir of [pendingSync, archive]) {
208
+ if (!fs.existsSync(dir)) continue;
209
+ const files = fs.readdirSync(dir).filter(
210
+ f => f.startsWith(storyId + '_') && f.endsWith('.md')
211
+ );
212
+ if (files.length > 0) {
213
+ storyPath = path.join(dir, files[0]);
214
+ break;
215
+ }
216
+ // Also try: file that contains storyId anywhere before the first _ delimiter
217
+ // Pattern: <storyId>_* (already covered above)
218
+ }
219
+
220
+ // Find plan file — look in sprintDir/plans/ for any M*.md containing ## storyId heading
221
+ let planPath = null;
222
+ const specPointers = [];
223
+ const plansDir = path.join(sprintDir, 'plans');
224
+ if (fs.existsSync(plansDir)) {
225
+ const planFiles = fs.readdirSync(plansDir)
226
+ .filter(f => /^M\d+\.md$/.test(f))
227
+ .sort();
228
+ for (const pf of planFiles) {
229
+ const pfPath = path.join(plansDir, pf);
230
+ const content = fs.readFileSync(pfPath, 'utf8');
231
+ const lines = content.split('\n');
232
+ // Find heading with the story ID
233
+ for (let i = 0; i < lines.length; i++) {
234
+ if (/^#{2,4}\s/.test(lines[i]) && lines[i].includes(storyId)) {
235
+ planPath = pfPath;
236
+ // Find end of section
237
+ let endLine = i + 1;
238
+ while (endLine < lines.length && !/^#{2,4}\s/.test(lines[endLine])) {
239
+ endLine++;
240
+ }
241
+ specPointers.push({
242
+ section: 'Per-story blueprint',
243
+ path: pfPath,
244
+ line_range: `${i + 1}-${endLine}`,
245
+ });
246
+ break;
247
+ }
248
+ }
249
+ if (planPath) break;
250
+ }
251
+ }
252
+
253
+ return {
254
+ story_path: storyPath,
255
+ plan_path: planPath,
256
+ spec_pointers: specPointers,
257
+ };
258
+ }
259
+
260
+ // ── Section 3: Baseline ───────────────────────────────────────────────────────
261
+
262
+ function buildBaseline(sprintDir, worktreePath) {
263
+ const mainHeadSha = gitExec('rev-parse main', worktreePath) || 'unknown';
264
+
265
+ const baselinePath = path.join(sprintDir, '.baseline-failures.json');
266
+ if (!fs.existsSync(baselinePath)) {
267
+ return {
268
+ main_head_sha: mainHeadSha,
269
+ baseline_unavailable: true,
270
+ failures: [],
271
+ };
272
+ }
273
+
274
+ try {
275
+ const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
276
+ const failures = Array.isArray(parsed) ? parsed : (parsed.failures || []);
277
+ return {
278
+ main_head_sha: mainHeadSha,
279
+ baseline_unavailable: false,
280
+ failures,
281
+ };
282
+ } catch {
283
+ return {
284
+ main_head_sha: mainHeadSha,
285
+ baseline_unavailable: true,
286
+ failures: [],
287
+ };
288
+ }
289
+ }
290
+
291
+ // ── Section 4: Adjacent Files ─────────────────────────────────────────────────
292
+
293
+ function inferMirrorPairs(touchedFiles) {
294
+ const pairs = [];
295
+ const mirrorMap = [
296
+ ['.cleargate/scripts/', 'cleargate-planning/.cleargate/scripts/'],
297
+ ['cleargate-planning/.cleargate/scripts/', '.cleargate/scripts/'],
298
+ ['.claude/agents/', 'cleargate-planning/.claude/agents/'],
299
+ ['cleargate-planning/.claude/agents/', '.claude/agents/'],
300
+ ['.cleargate/templates/', 'cleargate-planning/.cleargate/templates/'],
301
+ ['cleargate-planning/.cleargate/templates/', '.cleargate/templates/'],
302
+ ];
303
+
304
+ for (const touchedFile of touchedFiles) {
305
+ for (const [prefix, mirrorPrefix] of mirrorMap) {
306
+ if (touchedFile.startsWith(prefix)) {
307
+ const relative = touchedFile.slice(prefix.length);
308
+ const mirrorPath = mirrorPrefix + relative;
309
+ // Only emit pair if the mirror actually exists
310
+ const fullMirrorPath = path.join(REPO_ROOT, mirrorPath);
311
+ if (fs.existsSync(fullMirrorPath)) {
312
+ pairs.push({ touched: touchedFile, mirror: mirrorPath });
313
+ break;
314
+ }
315
+ }
316
+ }
317
+ }
318
+
319
+ return pairs;
320
+ }
321
+
322
+ function buildAdjacent(worktreePath) {
323
+ // Get touched files from git diff
324
+ let touchedFiles = [];
325
+ const diffOutput = gitExec('diff --name-only main..HEAD', worktreePath);
326
+ if (diffOutput) {
327
+ touchedFiles = diffOutput.split('\n').filter(Boolean);
328
+ } else {
329
+ process.stderr.write(
330
+ 'Warning: git diff --name-only main..HEAD failed — touched_files will be empty\n'
331
+ );
332
+ }
333
+
334
+ // Find adjacent test files
335
+ const adjacentTestFiles = [];
336
+ const seen = new Set();
337
+ for (const f of touchedFiles) {
338
+ const dir = path.dirname(f);
339
+ const fullDir = path.join(REPO_ROOT, dir);
340
+ if (!fs.existsSync(fullDir)) continue;
341
+ try {
342
+ const siblings = fs.readdirSync(fullDir);
343
+ for (const sibling of siblings) {
344
+ if (
345
+ (sibling.endsWith('.test.ts') || sibling.endsWith('.test.sh') ||
346
+ sibling.startsWith('test_') && sibling.endsWith('.sh')) &&
347
+ !seen.has(sibling)
348
+ ) {
349
+ const relPath = path.join(dir, sibling);
350
+ if (!seen.has(relPath)) {
351
+ seen.add(relPath);
352
+ adjacentTestFiles.push(relPath);
353
+ }
354
+ }
355
+ }
356
+ } catch {
357
+ // non-fatal
358
+ }
359
+ }
360
+
361
+ const mirrorPairs = inferMirrorPairs(touchedFiles);
362
+
363
+ return {
364
+ touched_files: touchedFiles,
365
+ adjacent_test_files: adjacentTestFiles,
366
+ mirror_pairs: mirrorPairs,
367
+ };
368
+ }
369
+
370
+ // ── Section 5: Cross-Story Map ────────────────────────────────────────────────
371
+
372
+ const IN_FLIGHT_STATES = new Set(
373
+ VALID_STATES.filter(s => !TERMINAL_STATES.includes(s))
374
+ );
375
+
376
+ function buildCrossStoryMap(sprintDir, touchedFiles, currentStoryId) {
377
+ const stateFile = path.join(sprintDir, 'state.json');
378
+ if (!fs.existsSync(stateFile)) return [];
379
+
380
+ let state;
381
+ try {
382
+ state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
383
+ } catch {
384
+ return [];
385
+ }
386
+
387
+ const stories = state.stories || {};
388
+ const crossMap = [];
389
+
390
+ for (const [sid, entry] of Object.entries(stories)) {
391
+ if (sid === currentStoryId) continue;
392
+ if (!IN_FLIGHT_STATES.has(entry.state)) continue;
393
+
394
+ const branch = `story/${sid}`;
395
+ const headSha = gitExec(`rev-parse ${branch}`, REPO_ROOT) || 'unknown';
396
+
397
+ let storyTouched = [];
398
+ const storyDiff = gitExec(`diff --name-only main..${branch}`, REPO_ROOT);
399
+ if (storyDiff) {
400
+ storyTouched = storyDiff.split('\n').filter(Boolean);
401
+ }
402
+
403
+ const shared = touchedFiles.filter(f => storyTouched.includes(f));
404
+ if (shared.length > 0) {
405
+ crossMap.push({
406
+ story_id: sid,
407
+ branch,
408
+ head_sha: headSha,
409
+ shared_files: shared,
410
+ });
411
+ }
412
+
413
+ // Cap at 5 stories
414
+ if (crossMap.length >= 5) break;
415
+ }
416
+
417
+ return crossMap;
418
+ }
419
+
420
+ // ── Section 6: Flashcard Slice ────────────────────────────────────────────────
421
+
422
+ const TAG_PREFIX_TABLE = [
423
+ { prefix: 'cleargate-cli/src/commands/', tags: ['#cli', '#commander'] },
424
+ { prefix: 'cleargate-cli/src/auth/', tags: ['#auth'] },
425
+ { prefix: 'cleargate-cli/src/', tags: ['#cli'] },
426
+ { prefix: 'mcp/src/auth/', tags: ['#auth', '#mcp'] },
427
+ { prefix: 'mcp/src/db/', tags: ['#schema', '#migrations', '#mcp'] },
428
+ { prefix: 'mcp/src/', tags: ['#mcp', '#fastify'] },
429
+ { prefix: '.cleargate/scripts/', tags: ['#scripts', '#test-harness'] },
430
+ { prefix: '.claude/agents/', tags: ['#agents'] },
431
+ { prefix: '.cleargate/knowledge/', tags: ['#protocol', '#wiki'] },
432
+ ];
433
+
434
+ function inferTagsFromPaths(touchedFiles) {
435
+ const tagSet = new Set();
436
+ for (const f of touchedFiles) {
437
+ let matched = false;
438
+ for (const { prefix, tags } of TAG_PREFIX_TABLE) {
439
+ if (f.startsWith(prefix)) {
440
+ for (const t of tags) tagSet.add(t);
441
+ matched = true;
442
+ break;
443
+ }
444
+ }
445
+ if (!matched) tagSet.add('#general');
446
+ }
447
+ return Array.from(tagSet);
448
+ }
449
+
450
+ function buildFlashcardSlice(touchedFiles) {
451
+ const tagsInferred = inferTagsFromPaths(touchedFiles);
452
+
453
+ const flashcardFile = path.join(REPO_ROOT, '.cleargate', 'FLASHCARD.md');
454
+ if (!fs.existsSync(flashcardFile)) {
455
+ return {
456
+ tags_inferred: tagsInferred,
457
+ entries: [],
458
+ };
459
+ }
460
+
461
+ const content = fs.readFileSync(flashcardFile, 'utf8');
462
+ const lines = content.split('\n').filter(l => /^\d{4}-\d{2}-\d{2}\s+·/.test(l));
463
+
464
+ // Build grep pattern from tags
465
+ const tagPattern = tagsInferred.join('|');
466
+ const re = new RegExp(tagPattern.replace(/#/g, '#'), 'i');
467
+ const matching = lines.filter(l => re.test(l)).slice(0, 20);
468
+
469
+ return {
470
+ tags_inferred: tagsInferred,
471
+ entries: matching,
472
+ };
473
+ }
474
+
475
+ // ── Section 7: Lane ───────────────────────────────────────────────────────────
476
+
477
+ function buildLane(storyId, sprintDir, touchedFiles) {
478
+ const stateFile = path.join(sprintDir, 'state.json');
479
+ if (fs.existsSync(stateFile)) {
480
+ try {
481
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
482
+ const storyEntry = (state.stories || {})[storyId];
483
+ if (storyEntry && storyEntry.lane !== undefined) {
484
+ const laneValue = storyEntry.lane;
485
+ // Heuristic: flag not-yet-runtime-aware if standard + touches CLI commands
486
+ const touchesCli = touchedFiles.some(f => f.startsWith('cleargate-cli/src/commands/'));
487
+ const source =
488
+ laneValue === 'standard' && touchesCli
489
+ ? 'not-yet-runtime-aware'
490
+ : 'state.json';
491
+ return { value: laneValue, source };
492
+ }
493
+ } catch {
494
+ // fallthrough
495
+ }
496
+ }
497
+
498
+ return { value: 'standard', source: 'default' };
499
+ }
500
+
501
+ // ── Section 8: Dev Handoff ────────────────────────────────────────────────────
502
+
503
+ function parseStructuredStatusBlock(text) {
504
+ if (!text) return null;
505
+
506
+ const statusMatch = text.match(/STATUS:\s*(done|blocked)/i);
507
+ const commitMatch = text.match(/COMMIT:\s*([^\n]+)/i);
508
+ const typecheckMatch = text.match(/TYPECHECK:\s*(pass|fail)/i);
509
+ const testsMatch = text.match(/TESTS:\s*([^\n]+)/i);
510
+ const filesChangedMatch = text.match(/FILES_CHANGED:\s*([^\n]+)/i);
511
+ const notesMatch = text.match(/NOTES:\s*([\s\S]*?)(?=\nFLASHCARDS_FLAGGED:|$)/i);
512
+ const flashcardsMatch = text.match(/flashcards_flagged:([\s\S]*?)(?=\n[A-Z_]+:|$)/i);
513
+
514
+ if (!statusMatch) return null;
515
+
516
+ // Structured = has r_coverage:/plan_deviations:/adjacent_files: as field keys (with colon)
517
+ const hasStructuredFields =
518
+ /r_coverage\s*:/.test(text) ||
519
+ /plan_deviations\s*:/.test(text) ||
520
+ /adjacent_files\s*:/.test(text);
521
+
522
+ const filesChanged = filesChangedMatch
523
+ ? filesChangedMatch[1].trim().split(/\s*,\s*|\s+/).filter(Boolean)
524
+ : [];
525
+
526
+ const flashcardsFlagged = flashcardsMatch
527
+ ? flashcardsMatch[1].trim().split('\n').map(l => l.replace(/^\s*-\s*"?/, '').replace(/"?\s*$/, '')).filter(Boolean)
528
+ : [];
529
+
530
+ return {
531
+ format: hasStructuredFields ? 'structured' : 'legacy',
532
+ status: (statusMatch[1] || '').toLowerCase(),
533
+ commit: commitMatch ? commitMatch[1].trim() : null,
534
+ typecheck: typecheckMatch ? typecheckMatch[1].toLowerCase() : null,
535
+ tests: testsMatch ? testsMatch[1].trim() : null,
536
+ files_changed: filesChanged,
537
+ notes: notesMatch ? notesMatch[1].trim() : null,
538
+ r_coverage: [],
539
+ plan_deviations: [],
540
+ adjacent_files: [],
541
+ flashcards_flagged: flashcardsFlagged,
542
+ };
543
+ }
544
+
545
+ function buildDevHandoff(worktreePath, devHandoffJsonPath) {
546
+ // Try --dev-handoff-json first
547
+ if (devHandoffJsonPath && fs.existsSync(devHandoffJsonPath)) {
548
+ try {
549
+ const raw = JSON.parse(fs.readFileSync(devHandoffJsonPath, 'utf8'));
550
+ // If it already has 'format' field, treat as pre-parsed
551
+ if (raw.format) return raw;
552
+ // Otherwise treat JSON as the full text to parse
553
+ const text = JSON.stringify(raw);
554
+ const parsed = parseStructuredStatusBlock(text);
555
+ if (parsed) return parsed;
556
+ } catch {
557
+ // fallthrough
558
+ }
559
+ }
560
+
561
+ // Try to extract from commit message
562
+ const headSha = gitExec('rev-parse HEAD', worktreePath);
563
+ if (headSha) {
564
+ const commitMsg = gitExec(`log -1 --format=%B ${headSha}`, worktreePath);
565
+ if (commitMsg) {
566
+ const parsed = parseStructuredStatusBlock(commitMsg);
567
+ if (parsed) return parsed;
568
+ }
569
+ }
570
+
571
+ return {
572
+ format: 'absent',
573
+ status: null,
574
+ commit: null,
575
+ typecheck: null,
576
+ tests: null,
577
+ files_changed: [],
578
+ notes: null,
579
+ r_coverage: [],
580
+ plan_deviations: [],
581
+ adjacent_files: [],
582
+ flashcards_flagged: [],
583
+ };
584
+ }
585
+
586
+ // ── Prose section builders ────────────────────────────────────────────────────
587
+
588
+ function proseWorktree(w) {
589
+ return [
590
+ '## Worktree + Commit',
591
+ '',
592
+ `- **Path:** \`${w.path}\``,
593
+ `- **Branch:** \`${w.branch}\``,
594
+ `- **HEAD SHA:** \`${w.head_sha}\``,
595
+ `- **Dev STATUS block present:** ${w.dev_status_block_present}`,
596
+ '',
597
+ ].join('\n');
598
+ }
599
+
600
+ function proseSpecSources(s) {
601
+ const lines = ['## Spec Sources', ''];
602
+ if (s.story_path) {
603
+ lines.push(`- **Story file:** \`${s.story_path}\``);
604
+ } else {
605
+ lines.push('_Story file not found for this story._');
606
+ }
607
+ if (s.plan_path) {
608
+ lines.push(`- **Plan file:** \`${s.plan_path}\``);
609
+ } else {
610
+ lines.push('_Plan file not found (fast-lane or unplanned story)._');
611
+ }
612
+ if (s.spec_pointers.length > 0) {
613
+ lines.push('');
614
+ lines.push('**Spec pointers:**');
615
+ for (const sp of s.spec_pointers) {
616
+ lines.push(`- ${sp.section}: \`${sp.path}\` lines ${sp.line_range}`);
617
+ }
618
+ }
619
+ lines.push('');
620
+ return lines.join('\n');
621
+ }
622
+
623
+ function proseBaseline(b) {
624
+ const lines = ['## Baseline', ''];
625
+ lines.push(`- **main HEAD SHA:** \`${b.main_head_sha}\``);
626
+ if (b.baseline_unavailable) {
627
+ lines.push('- **Baseline cache:** unavailable');
628
+ lines.push('');
629
+ lines.push('_Baseline cache stale or absent — recompute via `cleargate gate test` on main._');
630
+ } else {
631
+ lines.push(`- **Baseline cache:** available (${b.failures.length} known failure(s))`);
632
+ for (const f of b.failures) {
633
+ lines.push(` - \`${f.file}\`: ${f.count} failure(s)`);
634
+ }
635
+ }
636
+ lines.push('');
637
+ return lines.join('\n');
638
+ }
639
+
640
+ function proseAdjacent(a) {
641
+ const lines = ['## Adjacent Files', ''];
642
+ lines.push(`**Touched files (${a.touched_files.length}):**`);
643
+ if (a.touched_files.length === 0) {
644
+ lines.push('_(none — diff vs main empty or unavailable)_');
645
+ } else {
646
+ for (const f of a.touched_files) lines.push(`- \`${f}\``);
647
+ }
648
+ lines.push('');
649
+ lines.push(`**Adjacent test files (${a.adjacent_test_files.length}):**`);
650
+ if (a.adjacent_test_files.length === 0) {
651
+ lines.push('_(none found)_');
652
+ } else {
653
+ for (const f of a.adjacent_test_files) lines.push(`- \`${f}\``);
654
+ }
655
+ lines.push('');
656
+ if (a.mirror_pairs.length > 0) {
657
+ lines.push('**Mirror pairs:**');
658
+ for (const mp of a.mirror_pairs) {
659
+ lines.push(`- \`${mp.touched}\` ↔ \`${mp.mirror}\``);
660
+ }
661
+ lines.push('');
662
+ }
663
+ return lines.join('\n');
664
+ }
665
+
666
+ function proseCrossStoryMap(csList) {
667
+ const lines = ['## Cross-Story Map', ''];
668
+ if (csList.length === 0) {
669
+ lines.push('_No in-flight stories share files with this story._');
670
+ } else {
671
+ for (const cs of csList) {
672
+ lines.push(`### ${cs.story_id} (\`${cs.branch}\` @ \`${cs.head_sha.slice(0, 8)}\`)`);
673
+ lines.push('**Shared files:**');
674
+ for (const f of cs.shared_files) lines.push(`- \`${f}\``);
675
+ lines.push('');
676
+ }
677
+ }
678
+ lines.push('');
679
+ return lines.join('\n');
680
+ }
681
+
682
+ function proseFlashcardSlice(fc) {
683
+ const lines = ['## Flashcard Slice', ''];
684
+ lines.push(`**Tags inferred from touched paths:** ${fc.tags_inferred.join(', ') || '(none)'}`);
685
+ lines.push('');
686
+ if (fc.entries.length === 0) {
687
+ lines.push('_No matching flashcard entries for inferred tags._');
688
+ } else {
689
+ lines.push(`**Matching entries (${fc.entries.length}, capped at 20):**`);
690
+ lines.push('');
691
+ for (const e of fc.entries) lines.push(e);
692
+ }
693
+ lines.push('');
694
+ return lines.join('\n');
695
+ }
696
+
697
+ function proseLane(l) {
698
+ const lines = ['## Lane', ''];
699
+ lines.push(`- **Value:** \`${l.value}\``);
700
+ lines.push(`- **Source:** \`${l.source}\``);
701
+ if (l.source === 'not-yet-runtime-aware') {
702
+ lines.push('');
703
+ lines.push(
704
+ '_Heuristic: story lane is `standard` but touches CLI command files — ' +
705
+ 'QA may want to apply `runtime` playbook depth. See CR-024 M8 for lane-playbook dispatch._'
706
+ );
707
+ }
708
+ lines.push('');
709
+ return lines.join('\n');
710
+ }
711
+
712
+ function proseDevHandoff(dh) {
713
+ const lines = ['## Dev Handoff', ''];
714
+ if (dh.format === 'absent') {
715
+ lines.push('_No dev handoff found in commit message or `--dev-handoff-json`. Context limited._');
716
+ } else if (dh.format === 'legacy') {
717
+ lines.push(
718
+ '_SCHEMA_INCOMPLETE — context limited; old-format STATUS=done found, ' +
719
+ 'no `r_coverage`/`plan_deviations`/`adjacent_files`._'
720
+ );
721
+ lines.push('');
722
+ lines.push(`- **Status:** ${dh.status}`);
723
+ lines.push(`- **Commit:** ${dh.commit || '(not found)'}`);
724
+ lines.push(`- **Typecheck:** ${dh.typecheck || '(not found)'}`);
725
+ lines.push(`- **Tests:** ${dh.tests || '(not found)'}`);
726
+ if (dh.notes) {
727
+ lines.push('');
728
+ lines.push(`**Notes:** ${dh.notes}`);
729
+ }
730
+ } else {
731
+ // structured
732
+ lines.push(`- **Status:** ${dh.status}`);
733
+ lines.push(`- **Commit:** ${dh.commit || '(not found)'}`);
734
+ lines.push(`- **Typecheck:** ${dh.typecheck || '(not found)'}`);
735
+ lines.push(`- **Tests:** ${dh.tests || '(not found)'}`);
736
+ if (dh.files_changed.length > 0) {
737
+ lines.push('- **Files changed:**');
738
+ for (const f of dh.files_changed) lines.push(` - \`${f}\``);
739
+ }
740
+ if (dh.r_coverage && dh.r_coverage.length > 0) {
741
+ lines.push('');
742
+ lines.push('**R-coverage:**');
743
+ for (const r of dh.r_coverage) {
744
+ lines.push(`- ${r.r_id}: covered=${r.covered} deferred=${r.deferred} clarified=${r.clarified}`);
745
+ }
746
+ }
747
+ if (dh.plan_deviations && dh.plan_deviations.length > 0) {
748
+ lines.push('');
749
+ lines.push('**Plan deviations:**');
750
+ for (const pd of dh.plan_deviations) {
751
+ lines.push(`- **${pd.what}**: ${pd.why} (orchestrator_confirmed=${pd.orchestrator_confirmed})`);
752
+ }
753
+ }
754
+ if (dh.adjacent_files && dh.adjacent_files.length > 0) {
755
+ lines.push('');
756
+ lines.push('**Adjacent files flagged by dev:**');
757
+ for (const af of dh.adjacent_files) lines.push(`- \`${af}\``);
758
+ }
759
+ if (dh.notes) {
760
+ lines.push('');
761
+ lines.push(`**Notes:** ${dh.notes}`);
762
+ }
763
+ }
764
+ lines.push('');
765
+ return lines.join('\n');
766
+ }
767
+
768
+ // ── Main ───────────────────────────────────────────────────────────────────────
769
+
770
+ function main() {
771
+ const rawArgs = process.argv.slice(2);
772
+
773
+ // Parse args
774
+ const positionals = [];
775
+ let outputPath = null;
776
+ let devHandoffJsonPath = null;
777
+
778
+ for (let i = 0; i < rawArgs.length; i++) {
779
+ if (rawArgs[i] === '--output' && rawArgs[i + 1]) {
780
+ outputPath = rawArgs[++i];
781
+ } else if (rawArgs[i] === '--dev-handoff-json' && rawArgs[i + 1]) {
782
+ devHandoffJsonPath = rawArgs[++i];
783
+ } else if (!rawArgs[i].startsWith('--')) {
784
+ positionals.push(rawArgs[i]);
785
+ }
786
+ }
787
+
788
+ const storyId = positionals[0];
789
+ const worktreePath = positionals[1];
790
+
791
+ if (!storyId || !worktreePath) {
792
+ process.stderr.write(
793
+ 'Usage: node prep_qa_context.mjs <story-id> <worktree-path> [--output <path>] [--dev-handoff-json <path>]\n'
794
+ );
795
+ process.exit(2);
796
+ }
797
+
798
+ // Validate worktree path
799
+ if (!fs.existsSync(worktreePath)) {
800
+ process.stderr.write(`Error: worktree path does not exist: ${worktreePath}\n`);
801
+ process.exit(1);
802
+ }
803
+
804
+ const sprintId = deriveSprintId();
805
+ const sprintDir = resolveSprintDir(sprintId);
806
+
807
+ // Default output path
808
+ if (!outputPath) {
809
+ outputPath = path.join(sprintDir, `.qa-context-${storyId}.md`);
810
+ }
811
+
812
+ // Ensure sprint dir exists for output
813
+ if (!fs.existsSync(sprintDir)) {
814
+ try {
815
+ fs.mkdirSync(sprintDir, { recursive: true });
816
+ } catch (err) {
817
+ process.stderr.write(`Warning: could not create sprint dir ${sprintDir}: ${err.message}\n`);
818
+ }
819
+ }
820
+
821
+ // Build all sections
822
+ const worktreeData = buildWorktreeSection(storyId, worktreePath, devHandoffJsonPath);
823
+ const specSources = buildSpecSources(storyId, sprintId, sprintDir);
824
+ const baseline = buildBaseline(sprintDir, worktreePath);
825
+ const adjacent = buildAdjacent(worktreePath);
826
+ const crossStoryMap = buildCrossStoryMap(sprintDir, adjacent.touched_files, storyId);
827
+ const flashcardSlice = buildFlashcardSlice(adjacent.touched_files);
828
+ const lane = buildLane(storyId, sprintDir, adjacent.touched_files);
829
+ const devHandoff = buildDevHandoff(worktreePath, devHandoffJsonPath);
830
+
831
+ // Build machine-readable JSON block
832
+ const jsonPayload = {
833
+ schema_version: 1,
834
+ story_id: storyId,
835
+ sprint_id: sprintId,
836
+ generated_at: new Date().toISOString(),
837
+ worktree: worktreeData,
838
+ spec_sources: specSources,
839
+ baseline,
840
+ adjacent,
841
+ cross_story_map: crossStoryMap,
842
+ flashcard_slice: flashcardSlice,
843
+ lane,
844
+ dev_handoff: devHandoff,
845
+ };
846
+
847
+ // Build bundle
848
+ const bundleParts = [
849
+ `# QA Context Pack — ${storyId}\n`,
850
+ `_Generated: ${jsonPayload.generated_at}_\n`,
851
+ `_Sprint: ${sprintId}_\n`,
852
+ '---\n',
853
+ '```json',
854
+ JSON.stringify(jsonPayload, null, 2),
855
+ '```\n',
856
+ '---\n',
857
+ proseWorktree(worktreeData),
858
+ proseSpecSources(specSources),
859
+ proseBaseline(baseline),
860
+ proseAdjacent(adjacent),
861
+ proseCrossStoryMap(crossStoryMap),
862
+ proseFlashcardSlice(flashcardSlice),
863
+ proseLane(lane),
864
+ proseDevHandoff(devHandoff),
865
+ ];
866
+
867
+ const bundle = bundleParts.join('\n');
868
+ const bundleBytes = Buffer.byteLength(bundle, 'utf8');
869
+
870
+ // Write bundle (always write, even if oversized — R4)
871
+ const outputDir = path.dirname(outputPath);
872
+ if (!fs.existsSync(outputDir)) {
873
+ fs.mkdirSync(outputDir, { recursive: true });
874
+ }
875
+ atomicWrite(outputPath, bundle);
876
+
877
+ const kb = (bundleBytes / 1024).toFixed(1);
878
+ if (bundleBytes > 20480) {
879
+ process.stderr.write(
880
+ `Warning: bundle exceeds 20KB target (${kb}KB) — QA context pack is oversized\n`
881
+ );
882
+ }
883
+
884
+ process.stdout.write(`QA context pack ready: ${kb}KB at ${outputPath}\n`);
885
+ process.exit(0);
886
+ }
887
+
888
+ main();