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,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sprint_trends.mjs — Sprint trends stub (full implementation deferred to CR-027)
4
+ *
5
+ * Usage: node sprint_trends.mjs <sprint-id>
6
+ *
7
+ * Counts sibling Completed sprints and appends a placeholder Trends section
8
+ * to the current sprint's improvement-suggestions.md.
9
+ *
10
+ * Test seams:
11
+ * CLEARGATE_SPRINT_DIR=<path> — override sprint dir resolution
12
+ * CLEARGATE_SPRINT_RUNS_DIR=<path> — override .cleargate/sprint-runs/ root for sibling counting
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
21
+
22
+ function main() {
23
+ const sprintId = process.argv[2];
24
+ if (!sprintId) {
25
+ process.stderr.write('Usage: node sprint_trends.mjs <sprint-id>\n');
26
+ process.exit(2);
27
+ }
28
+
29
+ const sprintDir = process.env.CLEARGATE_SPRINT_DIR
30
+ ? path.resolve(process.env.CLEARGATE_SPRINT_DIR)
31
+ : path.join(REPO_ROOT, '.cleargate', 'sprint-runs', sprintId);
32
+
33
+ const sprintRunsDir = process.env.CLEARGATE_SPRINT_RUNS_DIR
34
+ ? path.resolve(process.env.CLEARGATE_SPRINT_RUNS_DIR)
35
+ : path.dirname(sprintDir);
36
+
37
+ // Count sibling sprint dirs whose state.json has sprint_status === 'Completed'
38
+ let completedCount = 0;
39
+ if (fs.existsSync(sprintRunsDir)) {
40
+ for (const entry of fs.readdirSync(sprintRunsDir)) {
41
+ if (entry === path.basename(sprintDir)) continue;
42
+ const siblingState = path.join(sprintRunsDir, entry, 'state.json');
43
+ if (!fs.existsSync(siblingState)) continue;
44
+ try {
45
+ const state = JSON.parse(fs.readFileSync(siblingState, 'utf8'));
46
+ if (state.sprint_status === 'Completed') completedCount++;
47
+ } catch { /* skip malformed state.json */ }
48
+ }
49
+ }
50
+
51
+ const suggestionsFile = path.join(sprintDir, 'improvement-suggestions.md');
52
+ const trendsSection = `\n## Trends\n\nTrends: ${completedCount} closed sprints visible — full analysis deferred to CR-027.\n`;
53
+
54
+ if (!fs.existsSync(suggestionsFile)) {
55
+ const header = `# Improvement Suggestions — ${sprintId}\n\n`;
56
+ const tmpFile = `${suggestionsFile}.tmp.${process.pid}`;
57
+ fs.writeFileSync(tmpFile, header + trendsSection, 'utf8');
58
+ fs.renameSync(tmpFile, suggestionsFile);
59
+ } else {
60
+ const existing = fs.readFileSync(suggestionsFile, 'utf8');
61
+ const tmpFile = `${suggestionsFile}.tmp.${process.pid}`;
62
+ fs.writeFileSync(tmpFile, existing.trimEnd() + trendsSection, 'utf8');
63
+ fs.renameSync(tmpFile, suggestionsFile);
64
+ }
65
+
66
+ process.stdout.write(
67
+ `sprint_trends: stub — full implementation deferred to CR-027 (counted ${completedCount} closed sprints).\n`
68
+ );
69
+ }
70
+
71
+ main();
@@ -3,6 +3,8 @@
3
3
  * suggest_improvements.mjs — Generate stable improvement suggestions from REPORT.md
4
4
  *
5
5
  * Usage: node suggest_improvements.mjs <sprint-id>
6
+ * node suggest_improvements.mjs <sprint-id> --skill-candidates
7
+ * node suggest_improvements.mjs <sprint-id> --flashcard-cleanup
6
8
  *
7
9
  * Reads:
8
10
  * - .cleargate/sprint-runs/<id>/REPORT.md §5 Framework Self-Assessment tables
@@ -10,7 +12,9 @@
10
12
  *
11
13
  * Emits:
12
14
  * - .cleargate/sprint-runs/<id>/improvement-suggestions.md
13
- * with stable SUG-<sprint>-<n> IDs
15
+ * with stable SUG-<sprint>-<n> IDs (default mode)
16
+ * or appends ## Skill Creation Candidates (--skill-candidates mode)
17
+ * or appends ## FLASHCARD Cleanup Candidates (--flashcard-cleanup mode)
14
18
  *
15
19
  * Append-only idempotency (R5):
16
20
  * - IDs are derived from a stable hash of (category, title) tuple
@@ -19,12 +23,18 @@
19
23
  *
20
24
  * Note: "section" as used in §5 table extraction refers to the Framework Self-Assessment
21
25
  * subsections: Templates, Handoffs, Skills, Process, Tooling.
26
+ *
27
+ * Test seams (CR-022 M6):
28
+ * CLEARGATE_FLASHCARD_PATH=<path> — override .cleargate/FLASHCARD.md path
29
+ * CLEARGATE_FLASHCARD_LOOKBACK=<N> — override 3-sprint lookback window (default 3)
30
+ * CLEARGATE_SPRINT_RUNS_DIR=<path> — override .cleargate/sprint-runs/ root for sibling lookups
22
31
  */
23
32
 
24
33
  import fs from 'node:fs';
25
34
  import path from 'node:path';
26
35
  import { fileURLToPath } from 'node:url';
27
36
  import { createHash } from 'node:crypto';
37
+ import { reportFilename } from './lib/report-filename.mjs';
28
38
 
29
39
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
40
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
@@ -91,15 +101,20 @@ function parseSelfAssessment(content) {
91
101
  }
92
102
 
93
103
  /**
94
- * Parse existing improvement-suggestions.md to extract already-captured SUG IDs.
104
+ * Parse existing improvement-suggestions.md to extract already-captured SUG and CAND IDs.
95
105
  * @param {string} content
96
- * @returns {Set<string>} Set of SUG IDs
106
+ * @returns {Set<string>} Set of SUG/CAND IDs
97
107
  */
98
108
  function parseExistingIds(content) {
99
109
  const ids = new Set();
100
- // Match SUG-<sprint>-<n> patterns
101
- const matches = content.matchAll(/SUG-[A-Z0-9-]+-\d+/g);
102
- for (const m of matches) {
110
+ // Match SUG-<sprint>-<n> patterns (default mode)
111
+ const sugMatches = content.matchAll(/SUG-[A-Z0-9-]+-\d+/g);
112
+ for (const m of sugMatches) {
113
+ ids.add(m[0]);
114
+ }
115
+ // Match CAND-<sprint>-[SF]<n> patterns (--skill-candidates / --flashcard-cleanup modes)
116
+ const candMatches = content.matchAll(/CAND-[A-Z0-9-]+-[SF]\d+/g);
117
+ for (const m of candMatches) {
103
118
  ids.add(m[0]);
104
119
  }
105
120
  return ids;
@@ -116,14 +131,328 @@ function atomicWrite(filePath, content) {
116
131
  fs.renameSync(tmpFile, filePath);
117
132
  }
118
133
 
134
+ /**
135
+ * Scan token-ledger.jsonl and FLASHCARD.md for skill creation candidates.
136
+ * Appends or replaces the "## Skill Creation Candidates" section in improvement-suggestions.md.
137
+ * @param {string} sprintId
138
+ * @param {string} sprintDir
139
+ * @param {string} suggestionsFile
140
+ */
141
+ function scanSkillCandidates(sprintId, sprintDir, suggestionsFile) {
142
+ const flashcardPath = process.env.CLEARGATE_FLASHCARD_PATH
143
+ ? path.resolve(process.env.CLEARGATE_FLASHCARD_PATH)
144
+ : path.join(REPO_ROOT, '.cleargate', 'FLASHCARD.md');
145
+ const ledgerPath = path.join(sprintDir, 'token-ledger.jsonl');
146
+
147
+ // Count repeated (work_item_id, agent_type) tuples from token-ledger.jsonl
148
+ /** @type {Map<string, number>} */
149
+ const tupleCounts = new Map();
150
+ if (fs.existsSync(ledgerPath)) {
151
+ const lines = fs.readFileSync(ledgerPath, 'utf8').split('\n').filter(l => l.trim());
152
+ for (const line of lines) {
153
+ try {
154
+ const entry = JSON.parse(line);
155
+ if (entry.work_item_id && entry.agent_type) {
156
+ const key = `${entry.work_item_id}|${entry.agent_type}`;
157
+ tupleCounts.set(key, (tupleCounts.get(key) ?? 0) + 1);
158
+ }
159
+ } catch { /* skip malformed lines */ }
160
+ }
161
+ }
162
+
163
+ // Find tuples repeated ≥3×
164
+ const repeatedTuples = [...tupleCounts.entries()].filter(([, count]) => count >= 3);
165
+
166
+ // Grep FLASHCARD.md for "also do" patterns
167
+ const alsoDoMatches = [];
168
+ if (fs.existsSync(flashcardPath)) {
169
+ const fcContent = fs.readFileSync(flashcardPath, 'utf8');
170
+ const lines = fcContent.split('\n');
171
+ for (const line of lines) {
172
+ if (/remember to also|also do X|also need to/i.test(line)) {
173
+ alsoDoMatches.push(line.trim());
174
+ }
175
+ }
176
+ }
177
+
178
+ // Read existing suggestions file content
179
+ let existingContent = fs.existsSync(suggestionsFile)
180
+ ? fs.readFileSync(suggestionsFile, 'utf8')
181
+ : `# Improvement Suggestions — ${sprintId}\n\nGenerated by \`suggest_improvements.mjs\`. Append-only; IDs are stable.\nVocabulary: Templates | Handoffs | Skills | Process | Tooling\n\n---\n\n`;
182
+
183
+ // Build the candidates
184
+ const candidates = [];
185
+ let candN = 1;
186
+ for (const [key] of repeatedTuples) {
187
+ const [workItemId, agentType] = key.split('|');
188
+ const candId = `CAND-${sprintId}-S${String(candN).padStart(2, '0')}`;
189
+ const hashKey = `skill|${key}`;
190
+ const hash = stableHash(hashKey);
191
+ if (!existingContent.includes(`<!-- hash:${hash} -->`)) {
192
+ candidates.push({ candId, hash, workItemId, agentType, source: 'ledger' });
193
+ candN++;
194
+ }
195
+ }
196
+ for (const line of alsoDoMatches) {
197
+ const candId = `CAND-${sprintId}-S${String(candN).padStart(2, '0')}`;
198
+ const hashKey = `skill|flashcard|${line.slice(0, 60)}`;
199
+ const hash = stableHash(hashKey);
200
+ if (!existingContent.includes(`<!-- hash:${hash} -->`)) {
201
+ candidates.push({ candId, hash, workItemId: null, agentType: null, source: 'flashcard', line });
202
+ candN++;
203
+ }
204
+ }
205
+
206
+ // Build the section content
207
+ const sectionLines = [
208
+ '## Skill Creation Candidates',
209
+ '',
210
+ '<!-- generated-by: suggest_improvements.mjs --skill-candidates -->',
211
+ '',
212
+ ];
213
+
214
+ if (candidates.length === 0) {
215
+ sectionLines.push('_No candidates detected this sprint._');
216
+ sectionLines.push('');
217
+ } else {
218
+ for (const c of candidates) {
219
+ sectionLines.push(`### ${c.candId}: ${c.source === 'ledger' ? `${c.workItemId} × ${c.agentType}` : 'flashcard pattern'}`);
220
+ sectionLines.push(`<!-- hash:${c.hash} -->`);
221
+ sectionLines.push('');
222
+ if (c.source === 'ledger') {
223
+ sectionLines.push(`**Pattern detected:** ${c.workItemId} × ${c.agentType} repeated ≥3× in token-ledger`);
224
+ } else {
225
+ sectionLines.push(`**Pattern detected:** "also do" pattern in FLASHCARD.md`);
226
+ sectionLines.push(`**Source line:** \`${c.line}\``);
227
+ }
228
+ sectionLines.push(`**Proposed skill:** \`.claude/skills/<slug>/SKILL.md\``);
229
+ sectionLines.push('');
230
+ sectionLines.push('---');
231
+ sectionLines.push('');
232
+ }
233
+ }
234
+
235
+ const sectionContent = sectionLines.join('\n');
236
+ // Anchored heading regex — strict match, no trailing text
237
+ const headingRe = /^## Skill Creation Candidates$/m;
238
+
239
+ let finalContent;
240
+ if (headingRe.test(existingContent)) {
241
+ // Replace existing section (splice out old, append new)
242
+ const nextHeadingRe = /^## /m;
243
+ const headingIdx = existingContent.search(headingRe);
244
+ const afterHeading = existingContent.slice(headingIdx + existingContent.match(headingRe)[0].length);
245
+ const nextMatch = afterHeading.search(/^## /m);
246
+ if (nextMatch === -1) {
247
+ finalContent = existingContent.slice(0, headingIdx).trimEnd() + '\n\n' + sectionContent;
248
+ } else {
249
+ finalContent = existingContent.slice(0, headingIdx).trimEnd() + '\n\n' + sectionContent + '\n' + afterHeading.slice(nextMatch);
250
+ }
251
+ void nextHeadingRe;
252
+ } else {
253
+ finalContent = existingContent.trimEnd() + '\n\n' + sectionContent;
254
+ }
255
+
256
+ atomicWrite(suggestionsFile, finalContent);
257
+ process.stdout.write(`suggest_improvements: skill-candidates section written to ${suggestionsFile}\n`);
258
+ }
259
+
260
+ /**
261
+ * Scan FLASHCARD.md for cleanup candidates (stale / superseded / resolved).
262
+ * Appends or replaces the "## FLASHCARD Cleanup Candidates" section in improvement-suggestions.md.
263
+ * @param {string} sprintId
264
+ * @param {string} sprintDir
265
+ * @param {string} suggestionsFile
266
+ */
267
+ function scanFlashcardCleanup(sprintId, sprintDir, suggestionsFile) {
268
+ const flashcardPath = process.env.CLEARGATE_FLASHCARD_PATH
269
+ ? path.resolve(process.env.CLEARGATE_FLASHCARD_PATH)
270
+ : path.join(REPO_ROOT, '.cleargate', 'FLASHCARD.md');
271
+
272
+ const lookback = parseInt(process.env.CLEARGATE_FLASHCARD_LOOKBACK ?? '3', 10);
273
+ const sprintRunsDir = process.env.CLEARGATE_SPRINT_RUNS_DIR
274
+ ? path.resolve(process.env.CLEARGATE_SPRINT_RUNS_DIR)
275
+ : path.dirname(sprintDir);
276
+
277
+ if (!fs.existsSync(flashcardPath)) {
278
+ process.stderr.write(`suggest_improvements --flashcard-cleanup: FLASHCARD.md not found at ${flashcardPath}, skipping\n`);
279
+ return;
280
+ }
281
+
282
+ const fcContent = fs.readFileSync(flashcardPath, 'utf8');
283
+ // Parse entries: YYYY-MM-DD · #tag1 #tag2 · lesson
284
+ const entryRe = /^(\d{4}-\d{2}-\d{2})\s+·\s+(.*?)\s+·\s+(.+)$/;
285
+ const entries = fcContent.split('\n').filter(l => entryRe.test(l.trim()));
286
+
287
+ // Determine lookback sprint dirs (numerical extraction from sprintId)
288
+ const numMatch = sprintId.match(/(\d+)$/);
289
+ const currentNum = numMatch ? parseInt(numMatch[1], 10) : null;
290
+ const sprintPrefix = numMatch ? sprintId.replace(/\d+$/, '') : null;
291
+
292
+ // Collect prior sprint REPORT.md content for "resolved" detection
293
+ const priorReportContent = [];
294
+ if (currentNum !== null && sprintPrefix !== null) {
295
+ for (let i = 1; i <= lookback; i++) {
296
+ const priorId = `${sprintPrefix}${currentNum - i}`;
297
+ const priorDir = path.join(sprintRunsDir, priorId);
298
+ if (!fs.existsSync(priorDir)) continue;
299
+ try {
300
+ const rFile = reportFilename(priorDir, priorId, { forRead: true });
301
+ if (fs.existsSync(rFile)) {
302
+ priorReportContent.push(fs.readFileSync(rFile, 'utf8'));
303
+ }
304
+ } catch { /* skip missing */ }
305
+ }
306
+ }
307
+
308
+ // Collect prior sprint dirs for stale detection
309
+ const priorSprintDirs = [];
310
+ if (currentNum !== null && sprintPrefix !== null) {
311
+ for (let i = 1; i <= lookback; i++) {
312
+ const priorId = `${sprintPrefix}${currentNum - i}`;
313
+ const priorDir = path.join(sprintRunsDir, priorId);
314
+ if (fs.existsSync(priorDir)) priorSprintDirs.push(priorDir);
315
+ }
316
+ }
317
+
318
+ /** @type {{ entry: string, date: string, tags: string, lesson: string, category: 'stale'|'superseded'|'resolved', reason: string }[]} */
319
+ const candidates = [];
320
+ const processedHashes = new Set();
321
+
322
+ for (const rawEntry of entries) {
323
+ const m = rawEntry.trim().match(entryRe);
324
+ if (!m) continue;
325
+ const [, date, tags, lesson] = m;
326
+ const entry = rawEntry.trim();
327
+ const hashKey = `flashcard|${entry.slice(0, 80)}`;
328
+ const hash = stableHash(hashKey);
329
+ if (processedHashes.has(hash)) continue;
330
+ processedHashes.add(hash);
331
+
332
+ // Extract first keyword from lesson (first word-run, stripping punctuation)
333
+ const keyword = lesson.split(/\s+/)[0].replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase();
334
+ if (!keyword) continue;
335
+
336
+ // Check "resolved": keyword appears in §6 Tooling "Resolved" of a prior REPORT.md
337
+ let resolved = false;
338
+ for (const rc of priorReportContent) {
339
+ const resolvedSection = rc.match(/##\s*§6[^\n]*\n([\s\S]*?)(?=##\s*§|$)/);
340
+ if (resolvedSection && resolvedSection[1].toLowerCase().includes(keyword)) {
341
+ resolved = true;
342
+ break;
343
+ }
344
+ }
345
+ if (resolved) {
346
+ candidates.push({ entry, date, tags, lesson, category: 'resolved', reason: 'keyword found in a prior §6 Tooling section' });
347
+ continue;
348
+ }
349
+
350
+ // Check "stale": zero grep hits for keyword across prior sprint dirs
351
+ if (priorSprintDirs.length > 0) {
352
+ let hitCount = 0;
353
+ for (const priorDir of priorSprintDirs) {
354
+ try {
355
+ const files = fs.readdirSync(priorDir);
356
+ for (const f of files) {
357
+ const fPath = path.join(priorDir, f);
358
+ if (fs.statSync(fPath).isFile()) {
359
+ const content = fs.readFileSync(fPath, 'utf8');
360
+ if (content.toLowerCase().includes(keyword)) { hitCount++; break; }
361
+ }
362
+ }
363
+ } catch { /* skip unreadable dirs */ }
364
+ }
365
+ if (hitCount === 0) {
366
+ candidates.push({ entry, date, tags, lesson, category: 'stale', reason: `stale: zero grep hits across last ${priorSprintDirs.length} sprint dir(s)` });
367
+ continue;
368
+ }
369
+ }
370
+ }
371
+
372
+ // Read existing suggestions file content
373
+ let existingContent = fs.existsSync(suggestionsFile)
374
+ ? fs.readFileSync(suggestionsFile, 'utf8')
375
+ : `# Improvement Suggestions — ${sprintId}\n\nGenerated by \`suggest_improvements.mjs\`. Append-only; IDs are stable.\nVocabulary: Templates | Handoffs | Skills | Process | Tooling\n\n---\n\n`;
376
+
377
+ // Build section
378
+ const sectionLines = [
379
+ '## FLASHCARD Cleanup Candidates',
380
+ '',
381
+ '<!-- generated-by: suggest_improvements.mjs --flashcard-cleanup -->',
382
+ '',
383
+ ];
384
+
385
+ // Filter out already-captured candidates
386
+ const newCandidates = candidates.filter(c => {
387
+ const hash = stableHash(`flashcard|${c.entry.slice(0, 80)}`);
388
+ return !existingContent.includes(`<!-- hash:${hash} -->`);
389
+ });
390
+
391
+ if (newCandidates.length === 0) {
392
+ sectionLines.push('_No candidates detected this sprint._');
393
+ sectionLines.push('');
394
+ } else {
395
+ newCandidates.forEach((c, i) => {
396
+ const candId = `CAND-${sprintId}-F${String(i + 1).padStart(2, '0')}`;
397
+ const hash = stableHash(`flashcard|${c.entry.slice(0, 80)}`);
398
+ sectionLines.push(`### ${candId}: ${c.lesson.slice(0, 60)}`);
399
+ sectionLines.push(`<!-- hash:${hash} -->`);
400
+ sectionLines.push('');
401
+ sectionLines.push(`**Category:** ${c.category}`);
402
+ sectionLines.push(`**Reason:** ${c.reason}`);
403
+ sectionLines.push(`**Original entry:** \`${c.entry}\``);
404
+ sectionLines.push('**Suggested action:** approve to remove via `cleargate flashcard prune` (run /improve)');
405
+ sectionLines.push('');
406
+ sectionLines.push('---');
407
+ sectionLines.push('');
408
+ });
409
+ }
410
+
411
+ const sectionContent = sectionLines.join('\n');
412
+ // Anchored heading regex — strict match, no trailing text
413
+ const headingRe = /^## FLASHCARD Cleanup Candidates$/m;
414
+
415
+ let finalContent;
416
+ if (headingRe.test(existingContent)) {
417
+ const headingIdx = existingContent.search(headingRe);
418
+ const afterHeading = existingContent.slice(headingIdx + existingContent.match(headingRe)[0].length);
419
+ const nextMatch = afterHeading.search(/^## /m);
420
+ if (nextMatch === -1) {
421
+ finalContent = existingContent.slice(0, headingIdx).trimEnd() + '\n\n' + sectionContent;
422
+ } else {
423
+ finalContent = existingContent.slice(0, headingIdx).trimEnd() + '\n\n' + sectionContent + '\n' + afterHeading.slice(nextMatch);
424
+ }
425
+ } else {
426
+ finalContent = existingContent.trimEnd() + '\n\n' + sectionContent;
427
+ }
428
+
429
+ atomicWrite(suggestionsFile, finalContent);
430
+ process.stdout.write(`suggest_improvements: flashcard-cleanup section written to ${suggestionsFile}\n`);
431
+ }
432
+
119
433
  function main() {
120
434
  const args = process.argv.slice(2);
121
435
  if (args.length < 1) {
122
- process.stderr.write('Usage: node suggest_improvements.mjs <sprint-id>\n');
436
+ process.stderr.write('Usage: node suggest_improvements.mjs <sprint-id> [--skill-candidates | --flashcard-cleanup]\n');
437
+ process.exit(2);
438
+ }
439
+
440
+ // Flag-aware parse: detect flags first, then extract sprintId from positional args.
441
+ // Sprint IDs match ^SPRINT-\d+$ so they never start with '--'; no collision possible.
442
+ const skillCandidatesMode = args.includes('--skill-candidates');
443
+ const flashcardCleanupMode = args.includes('--flashcard-cleanup');
444
+
445
+ if (skillCandidatesMode && flashcardCleanupMode) {
446
+ process.stderr.write('Error: --skill-candidates and --flashcard-cleanup are mutually exclusive\n');
447
+ process.exit(2);
448
+ }
449
+
450
+ const sprintId = args.find(a => !a.startsWith('--'));
451
+ if (!sprintId) {
452
+ process.stderr.write('Usage: node suggest_improvements.mjs <sprint-id> [--skill-candidates | --flashcard-cleanup]\n');
123
453
  process.exit(2);
124
454
  }
125
455
 
126
- const sprintId = args[0];
127
456
  const sprintDir = process.env.CLEARGATE_SPRINT_DIR
128
457
  ? path.resolve(process.env.CLEARGATE_SPRINT_DIR)
129
458
  : path.join(REPO_ROOT, '.cleargate', 'sprint-runs', sprintId);
@@ -133,13 +462,26 @@ function main() {
133
462
  process.exit(1);
134
463
  }
135
464
 
136
- const reportFile = path.join(sprintDir, 'REPORT.md');
465
+ const reportFile = reportFilename(sprintDir, sprintId, { forRead: true });
137
466
  const suggestionsFile = path.join(sprintDir, 'improvement-suggestions.md');
138
467
 
139
- if (!fs.existsSync(reportFile)) {
140
- process.stderr.write(`Error: REPORT.md not found at ${reportFile}\n`);
141
- process.stderr.write('Run the Reporter agent first to generate the report.\n');
142
- process.exit(1);
468
+ // Default mode requires REPORT.md; flag-driven modes do not.
469
+ if (!skillCandidatesMode && !flashcardCleanupMode) {
470
+ if (!fs.existsSync(reportFile)) {
471
+ process.stderr.write(`Error: report file not found at ${reportFile}\n`);
472
+ process.stderr.write('Run the Reporter agent first to generate the report.\n');
473
+ process.exit(1);
474
+ }
475
+ }
476
+
477
+ // Route flag-driven modes before default processing
478
+ if (skillCandidatesMode) {
479
+ scanSkillCandidates(sprintId, sprintDir, suggestionsFile);
480
+ return;
481
+ }
482
+ if (flashcardCleanupMode) {
483
+ scanFlashcardCleanup(sprintId, sprintDir, suggestionsFile);
484
+ return;
143
485
  }
144
486
 
145
487
  const reportContent = fs.readFileSync(reportFile, 'utf8');
@@ -4,7 +4,7 @@
4
4
  #
5
5
  # Strategy: grep-based. Creates a synthetic dev-report fixture with
6
6
  # flashcards_flagged, simulates a mock worktree-creation step, and asserts
7
- # the gate contract documented in protocol §18 and the agent output-shape
7
+ # the gate contract documented in protocol §4 and the agent output-shape
8
8
  # blocks in developer.md + qa.md.
9
9
  #
10
10
  # Usage: bash .cleargate/scripts/test/test_flashcard_gate.sh
@@ -30,14 +30,14 @@ fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
30
30
  # And upon approval each card is appended to .cleargate/FLASHCARD.md
31
31
  # And only then does worktree creation proceed
32
32
  #
33
- # This script validates the contract (protocol §18 + output-shape fields)
33
+ # This script validates the contract (protocol §4 + output-shape fields)
34
34
  # that makes the scenario enforceable. The gate logic itself is orchestrator-
35
35
  # out-of-band (v1: informational; v2: mandatory). The test therefore checks:
36
36
  # (a) developer.md output-shape contains flashcards_flagged field
37
37
  # (b) qa.md output-shape contains flashcards_flagged field
38
- # (c) protocol.md §18 exists with the correct heading
39
- # (d) §18 specifies the approve/reject processing rule
40
- # (e) §18 specifies the worktree creation gate
38
+ # (c) protocol.md §4 exists with the correct heading
39
+ # (d) §4 specifies the approve/reject processing rule
40
+ # (e) §4 specifies the worktree creation gate
41
41
  # (f) live vs mirror diff is empty for all three files
42
42
 
43
43
  DEV_MD="$REPO_ROOT/.claude/agents/developer.md"
@@ -62,25 +62,25 @@ else
62
62
  fail "qa.md missing flashcards_flagged field in output-shape"
63
63
  fi
64
64
 
65
- # (c) protocol.md §18 heading exists
65
+ # (c) protocol.md §4 heading exists
66
66
  if grep -q "^## 18. Immediate Flashcard Gate (v2)" "$PROTOCOL_MD"; then
67
67
  pass "protocol.md contains ## 18. Immediate Flashcard Gate (v2)"
68
68
  else
69
69
  fail "protocol.md missing ## 18. Immediate Flashcard Gate (v2)"
70
70
  fi
71
71
 
72
- # (d) §18 specifies approve + reject processing
72
+ # (d) §4 specifies approve + reject processing
73
73
  if grep -q "Approve" "$PROTOCOL_MD" && grep -q "Reject" "$PROTOCOL_MD"; then
74
- pass "protocol §18 documents Approve/Reject processing rule"
74
+ pass "protocol §4 documents Approve/Reject processing rule"
75
75
  else
76
- fail "protocol §18 missing Approve/Reject processing rule"
76
+ fail "protocol §4 missing Approve/Reject processing rule"
77
77
  fi
78
78
 
79
- # (e) §18 specifies the worktree creation gate
79
+ # (e) §4 specifies the worktree creation gate
80
80
  if grep -q "Worktree creation gate\|worktree creation gate\|MUST NOT.*worktree\|worktree.*MUST NOT" "$PROTOCOL_MD"; then
81
- pass "protocol §18 documents worktree creation gate"
81
+ pass "protocol §4 documents worktree creation gate"
82
82
  else
83
- fail "protocol §18 missing worktree creation gate rule"
83
+ fail "protocol §4 missing worktree creation gate rule"
84
84
  fi
85
85
 
86
86
  # (f) qa.md says QA list is additive to Developer's
@@ -90,18 +90,18 @@ else
90
90
  fail "qa.md missing note that flashcards_flagged is additive"
91
91
  fi
92
92
 
93
- # (g) developer.md references protocol §18
94
- if grep -q "protocol §18\|§18" "$DEV_MD"; then
95
- pass "developer.md references protocol §18"
93
+ # (g) developer.md references protocol §4
94
+ if grep -q "protocol §4\|§4" "$DEV_MD"; then
95
+ pass "developer.md references protocol §4"
96
96
  else
97
- fail "developer.md does not reference protocol §18"
97
+ fail "developer.md does not reference protocol §4"
98
98
  fi
99
99
 
100
- # (h) qa.md references protocol §18
101
- if grep -q "protocol §18\|§18" "$QA_MD"; then
102
- pass "qa.md references protocol §18"
100
+ # (h) qa.md references protocol §4
101
+ if grep -q "protocol §4\|§4" "$QA_MD"; then
102
+ pass "qa.md references protocol §4"
103
103
  else
104
- fail "qa.md does not reference protocol §18"
104
+ fail "qa.md does not reference protocol §4"
105
105
  fi
106
106
 
107
107
  # (i) three-surface diff: live developer.md vs mirror