cleargate 0.8.2 → 0.11.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 (122) hide show
  1. package/CHANGELOG.md +210 -0
  2. package/README.md +22 -1
  3. package/dist/MANIFEST.json +276 -31
  4. package/dist/chunk-HZPJ5QX4.js +459 -0
  5. package/dist/chunk-HZPJ5QX4.js.map +1 -0
  6. package/dist/{chunk-OM4FAEA7.js → chunk-Q3BTSXCK.js} +69 -3
  7. package/dist/chunk-Q3BTSXCK.js.map +1 -0
  8. package/dist/cli.cjs +2888 -598
  9. package/dist/cli.cjs.map +1 -1
  10. package/dist/cli.js +2481 -619
  11. package/dist/cli.js.map +1 -1
  12. package/dist/lib/ledger.cjs +120 -0
  13. package/dist/lib/ledger.cjs.map +1 -0
  14. package/dist/lib/ledger.d.cts +64 -0
  15. package/dist/lib/ledger.d.ts +64 -0
  16. package/dist/lib/ledger.js +96 -0
  17. package/dist/lib/ledger.js.map +1 -0
  18. package/dist/lib/lifecycle-reconcile.cjs +497 -0
  19. package/dist/lib/lifecycle-reconcile.cjs.map +1 -0
  20. package/dist/lib/lifecycle-reconcile.d.cts +136 -0
  21. package/dist/lib/lifecycle-reconcile.d.ts +136 -0
  22. package/dist/lib/lifecycle-reconcile.js +20 -0
  23. package/dist/lib/lifecycle-reconcile.js.map +1 -0
  24. package/dist/templates/cleargate-planning/.claude/agents/architect.md +65 -10
  25. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-contradict.md +108 -0
  26. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-ingest.md +49 -3
  27. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-lint.md +6 -1
  28. package/dist/templates/cleargate-planning/.claude/agents/developer.md +51 -2
  29. package/dist/templates/cleargate-planning/.claude/agents/devops.md +249 -0
  30. package/dist/templates/cleargate-planning/.claude/agents/qa.md +91 -1
  31. package/dist/templates/cleargate-planning/.claude/agents/reporter.md +72 -14
  32. package/dist/templates/cleargate-planning/.claude/hooks/pre-commit-surface-gate.sh +21 -0
  33. package/dist/templates/cleargate-planning/.claude/hooks/pre-tool-use-task.sh +148 -0
  34. package/dist/templates/cleargate-planning/.claude/hooks/session-start.sh +6 -0
  35. package/dist/templates/cleargate-planning/.claude/hooks/stamp-and-gate.sh +12 -1
  36. package/dist/templates/cleargate-planning/.claude/hooks/token-ledger.sh +334 -96
  37. package/dist/templates/cleargate-planning/.claude/settings.json +4 -0
  38. package/dist/templates/cleargate-planning/.claude/skills/sprint-execution/SKILL.md +644 -0
  39. package/dist/templates/cleargate-planning/.cleargate/config.example.yml +19 -0
  40. package/dist/templates/cleargate-planning/.cleargate/knowledge/cleargate-enforcement.md +542 -0
  41. package/dist/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +102 -428
  42. package/dist/templates/cleargate-planning/.cleargate/knowledge/mid-sprint-triage-rubric.md +160 -0
  43. package/dist/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +72 -9
  44. package/dist/templates/cleargate-planning/.cleargate/knowledge/sprint-closeout-checklist.md +71 -0
  45. package/dist/templates/cleargate-planning/.cleargate/scripts/assert_story_files.mjs +24 -2
  46. package/dist/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +471 -29
  47. package/dist/templates/cleargate-planning/.cleargate/scripts/dedupe_frontmatter.mjs +219 -0
  48. package/dist/templates/cleargate-planning/.cleargate/scripts/gate-checks.json +3 -3
  49. package/dist/templates/cleargate-planning/.cleargate/scripts/init_sprint.mjs +86 -10
  50. package/dist/templates/cleargate-planning/.cleargate/scripts/lib/report-filename.mjs +54 -0
  51. package/dist/templates/cleargate-planning/.cleargate/scripts/prep_doc_refresh.mjs +378 -0
  52. package/dist/templates/cleargate-planning/.cleargate/scripts/prep_qa_context.mjs +888 -0
  53. package/dist/templates/cleargate-planning/.cleargate/scripts/run_script.sh +173 -87
  54. package/dist/templates/cleargate-planning/.cleargate/scripts/sprint_trends.mjs +71 -0
  55. package/dist/templates/cleargate-planning/.cleargate/scripts/suggest_improvements.mjs +483 -13
  56. package/dist/templates/cleargate-planning/.cleargate/scripts/test/test_prep_qa_context.sh +482 -0
  57. package/dist/templates/cleargate-planning/.cleargate/scripts/validate_state.mjs +32 -8
  58. package/dist/templates/cleargate-planning/.cleargate/scripts/write_dispatch.sh +136 -0
  59. package/dist/templates/cleargate-planning/.cleargate/templates/Bug.md +27 -1
  60. package/dist/templates/cleargate-planning/.cleargate/templates/CR.md +35 -1
  61. package/dist/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +48 -14
  62. package/dist/templates/cleargate-planning/.cleargate/templates/epic.md +40 -3
  63. package/dist/templates/cleargate-planning/.cleargate/templates/hotfix.md +53 -0
  64. package/dist/templates/cleargate-planning/.cleargate/templates/initiative.md +98 -29
  65. package/dist/templates/cleargate-planning/.cleargate/templates/proposal.md +17 -4
  66. package/dist/templates/cleargate-planning/.cleargate/templates/sprint_context.md +8 -0
  67. package/dist/templates/cleargate-planning/.cleargate/templates/sprint_report.md +23 -4
  68. package/dist/templates/cleargate-planning/.cleargate/templates/story.md +58 -3
  69. package/dist/templates/cleargate-planning/CLAUDE.md +30 -10
  70. package/dist/templates/cleargate-planning/MANIFEST.json +276 -31
  71. package/dist/{whoami-CX7CXJD5.js → whoami-W4U6DPVG.js} +17 -17
  72. package/dist/whoami-W4U6DPVG.js.map +1 -0
  73. package/package.json +20 -6
  74. package/templates/cleargate-planning/.claude/agents/architect.md +65 -10
  75. package/templates/cleargate-planning/.claude/agents/cleargate-wiki-contradict.md +108 -0
  76. package/templates/cleargate-planning/.claude/agents/cleargate-wiki-ingest.md +49 -3
  77. package/templates/cleargate-planning/.claude/agents/cleargate-wiki-lint.md +6 -1
  78. package/templates/cleargate-planning/.claude/agents/developer.md +51 -2
  79. package/templates/cleargate-planning/.claude/agents/devops.md +249 -0
  80. package/templates/cleargate-planning/.claude/agents/qa.md +91 -1
  81. package/templates/cleargate-planning/.claude/agents/reporter.md +72 -14
  82. package/templates/cleargate-planning/.claude/hooks/pre-commit-surface-gate.sh +21 -0
  83. package/templates/cleargate-planning/.claude/hooks/pre-tool-use-task.sh +148 -0
  84. package/templates/cleargate-planning/.claude/hooks/session-start.sh +6 -0
  85. package/templates/cleargate-planning/.claude/hooks/stamp-and-gate.sh +12 -1
  86. package/templates/cleargate-planning/.claude/hooks/token-ledger.sh +334 -96
  87. package/templates/cleargate-planning/.claude/settings.json +4 -0
  88. package/templates/cleargate-planning/.claude/skills/sprint-execution/SKILL.md +644 -0
  89. package/templates/cleargate-planning/.cleargate/config.example.yml +19 -0
  90. package/templates/cleargate-planning/.cleargate/knowledge/cleargate-enforcement.md +542 -0
  91. package/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +102 -428
  92. package/templates/cleargate-planning/.cleargate/knowledge/mid-sprint-triage-rubric.md +160 -0
  93. package/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +72 -9
  94. package/templates/cleargate-planning/.cleargate/knowledge/sprint-closeout-checklist.md +71 -0
  95. package/templates/cleargate-planning/.cleargate/scripts/assert_story_files.mjs +24 -2
  96. package/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +471 -29
  97. package/templates/cleargate-planning/.cleargate/scripts/dedupe_frontmatter.mjs +219 -0
  98. package/templates/cleargate-planning/.cleargate/scripts/gate-checks.json +3 -3
  99. package/templates/cleargate-planning/.cleargate/scripts/init_sprint.mjs +86 -10
  100. package/templates/cleargate-planning/.cleargate/scripts/lib/report-filename.mjs +54 -0
  101. package/templates/cleargate-planning/.cleargate/scripts/prep_doc_refresh.mjs +378 -0
  102. package/templates/cleargate-planning/.cleargate/scripts/prep_qa_context.mjs +888 -0
  103. package/templates/cleargate-planning/.cleargate/scripts/run_script.sh +173 -87
  104. package/templates/cleargate-planning/.cleargate/scripts/sprint_trends.mjs +71 -0
  105. package/templates/cleargate-planning/.cleargate/scripts/suggest_improvements.mjs +483 -13
  106. package/templates/cleargate-planning/.cleargate/scripts/test/test_prep_qa_context.sh +482 -0
  107. package/templates/cleargate-planning/.cleargate/scripts/validate_state.mjs +32 -8
  108. package/templates/cleargate-planning/.cleargate/scripts/write_dispatch.sh +136 -0
  109. package/templates/cleargate-planning/.cleargate/templates/Bug.md +27 -1
  110. package/templates/cleargate-planning/.cleargate/templates/CR.md +35 -1
  111. package/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +48 -14
  112. package/templates/cleargate-planning/.cleargate/templates/epic.md +40 -3
  113. package/templates/cleargate-planning/.cleargate/templates/hotfix.md +53 -0
  114. package/templates/cleargate-planning/.cleargate/templates/initiative.md +98 -29
  115. package/templates/cleargate-planning/.cleargate/templates/sprint_context.md +8 -0
  116. package/templates/cleargate-planning/.cleargate/templates/sprint_report.md +23 -4
  117. package/templates/cleargate-planning/.cleargate/templates/story.md +58 -3
  118. package/templates/cleargate-planning/CLAUDE.md +30 -10
  119. package/templates/cleargate-planning/MANIFEST.json +276 -31
  120. package/dist/chunk-OM4FAEA7.js.map +0 -1
  121. package/dist/whoami-CX7CXJD5.js.map +0 -1
  122. package/templates/cleargate-planning/.cleargate/templates/proposal.md +0 -61
@@ -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,456 @@ function atomicWrite(filePath, content) {
116
131
  fs.renameSync(tmpFile, filePath);
117
132
  }
118
133
 
134
+ /**
135
+ * Read all ledger entries from a token-ledger.jsonl file.
136
+ * @param {string} ledgerPath
137
+ * @returns {{ work_item_id: string, agent_type: string, session_id?: string, sprint_id?: string }[]}
138
+ */
139
+ function readLedgerEntries(ledgerPath) {
140
+ if (!fs.existsSync(ledgerPath)) return [];
141
+ const lines = fs.readFileSync(ledgerPath, 'utf8').split('\n').filter(l => l.trim());
142
+ const entries = [];
143
+ for (const line of lines) {
144
+ try {
145
+ const entry = JSON.parse(line);
146
+ if (entry.work_item_id && entry.agent_type) {
147
+ entries.push({
148
+ work_item_id: entry.work_item_id,
149
+ agent_type: entry.agent_type,
150
+ session_id: entry.session_id ?? '',
151
+ sprint_id: entry.sprint_id ?? '',
152
+ });
153
+ }
154
+ } catch { /* skip malformed lines */ }
155
+ }
156
+ return entries;
157
+ }
158
+
159
+ /**
160
+ * Check if a (work_item_id|agent_type) bucket is a session-attribution artifact.
161
+ *
162
+ * Session-shared filter (CR-056): When multiple Architect (or other agent) dispatches run
163
+ * within the same session, the token ledger attributes them all to the same bucket keyed
164
+ * by the first-merged work_item_id. The canonical example is "CR-045 × architect" in
165
+ * SPRINT-23/SPRINT-24 — all 17 entries share the SAME session_id 48aa90c9-..., making
166
+ * them one session mis-attributed as 17 distinct repeats.
167
+ *
168
+ * Rule: session-attribution artifact if ALL entries with a known session_id share the
169
+ * SAME session_id (i.e., exactly 1 distinct session across all entries). When multiple
170
+ * distinct sessions are present, there is genuine independent repetition.
171
+ *
172
+ * Known false-positive class: "CR-045 × architect" — 17 entries, 1 session UUID.
173
+ *
174
+ * @param {{ session_id?: string }[]} entries all entries for this bucket (across sprints)
175
+ * @returns {boolean} true if bucket should be filtered as session-shared artifact
176
+ */
177
+ function isSessionShared(entries) {
178
+ if (entries.length < 3) return false;
179
+ const distinctSessions = new Set(
180
+ entries.map(e => e.session_id ?? '').filter(s => s !== '')
181
+ );
182
+ // If exactly 1 distinct session accounts for all entries, this is a session-attribution artifact
183
+ return distinctSessions.size === 1;
184
+ }
185
+
186
+ /**
187
+ * Scan token-ledger.jsonl and FLASHCARD.md for skill creation candidates.
188
+ *
189
+ * Heuristic (CR-056 tightened):
190
+ * 1. Session-shared filter: if ≥2 of ≥3 entries for a bucket share the same
191
+ * session_id → skip (token-attribution artifact, not a real recurring pattern).
192
+ * Known false-positive class: "CR-045 × architect" from SPRINT-23/SPRINT-24 —
193
+ * all 17 entries share session_id 48aa90c9-... (session-shared).
194
+ * 2. Cross-sprint aggregation: collect entries from the current sprint's ledger PLUS
195
+ * prior sprints' ledgers (via CLEARGATE_SPRINT_RUNS_DIR). Count ≥3× total across
196
+ * ≥2 distinct sprints.
197
+ * 3. Cross-sprint dedup: if the candidate's hash already appears in any prior sprint's
198
+ * improvement-suggestions.md → surface as seen_in: [...] instead of re-flagging.
199
+ * 4. Threshold raised to "≥3× across ≥2 distinct sprints AND not session-shared".
200
+ *
201
+ * Appends or replaces the "## Skill Creation Candidates" section in improvement-suggestions.md.
202
+ * @param {string} sprintId
203
+ * @param {string} sprintDir
204
+ * @param {string} suggestionsFile
205
+ */
206
+ function scanSkillCandidates(sprintId, sprintDir, suggestionsFile) {
207
+ const flashcardPath = process.env.CLEARGATE_FLASHCARD_PATH
208
+ ? path.resolve(process.env.CLEARGATE_FLASHCARD_PATH)
209
+ : path.join(REPO_ROOT, '.cleargate', 'FLASHCARD.md');
210
+ const ledgerPath = path.join(sprintDir, 'token-ledger.jsonl');
211
+
212
+ // Resolve sprint-runs root (used for cross-sprint lookback)
213
+ const sprintRunsDir = process.env.CLEARGATE_SPRINT_RUNS_DIR
214
+ ? path.resolve(process.env.CLEARGATE_SPRINT_RUNS_DIR)
215
+ : path.dirname(sprintDir);
216
+
217
+ // ── Step 1: collect all ledger entries across current + prior sprints ─────────
218
+ // Collect from current sprint
219
+ const currentEntries = readLedgerEntries(ledgerPath);
220
+
221
+ // Collect from prior sprints (all sprint dirs except current)
222
+ const allPriorEntries = [];
223
+ const priorSuggestionsContents = [];
224
+ if (fs.existsSync(sprintRunsDir)) {
225
+ let priorDirs;
226
+ try {
227
+ priorDirs = fs.readdirSync(sprintRunsDir)
228
+ .filter(name => name !== sprintId && !name.startsWith('.'))
229
+ .map(name => path.join(sprintRunsDir, name))
230
+ .filter(p => {
231
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
232
+ });
233
+ } catch { priorDirs = []; }
234
+
235
+ for (const priorDir of priorDirs) {
236
+ const priorLedger = path.join(priorDir, 'token-ledger.jsonl');
237
+ const entries = readLedgerEntries(priorLedger);
238
+ allPriorEntries.push(...entries);
239
+
240
+ // Collect prior improvement-suggestions.md for cross-sprint dedup
241
+ const priorSuggFile = path.join(priorDir, 'improvement-suggestions.md');
242
+ if (fs.existsSync(priorSuggFile)) {
243
+ try {
244
+ priorSuggestionsContents.push(fs.readFileSync(priorSuggFile, 'utf8'));
245
+ } catch { /* skip unreadable */ }
246
+ }
247
+ }
248
+ }
249
+
250
+ // ── Step 2: build cross-sprint bucket map ────────────────────────────────────
251
+ // Map: key (work_item_id|agent_type) → { entries: [...], sprintIds: Set<string> }
252
+ /** @type {Map<string, { entries: { session_id?: string, sprint_id?: string }[], sprintIds: Set<string> }>} */
253
+ const buckets = new Map();
254
+
255
+ for (const e of currentEntries) {
256
+ const key = `${e.work_item_id}|${e.agent_type}`;
257
+ if (!buckets.has(key)) buckets.set(key, { entries: [], sprintIds: new Set() });
258
+ const b = buckets.get(key);
259
+ b.entries.push(e);
260
+ b.sprintIds.add(sprintId);
261
+ }
262
+ for (const e of allPriorEntries) {
263
+ const key = `${e.work_item_id}|${e.agent_type}`;
264
+ if (!buckets.has(key)) buckets.set(key, { entries: [], sprintIds: new Set() });
265
+ const b = buckets.get(key);
266
+ b.entries.push(e);
267
+ if (e.sprint_id) b.sprintIds.add(e.sprint_id);
268
+ }
269
+
270
+ // ── Step 3: apply heuristic filters ──────────────────────────────────────────
271
+ // Threshold: ≥3× total AND ≥2 distinct sprints AND NOT session-shared
272
+ const repeatedTuples = [...buckets.entries()].filter(([, b]) => {
273
+ if (b.entries.length < 3) return false;
274
+ if (b.sprintIds.size < 2) return false;
275
+ if (isSessionShared(b.entries)) return false;
276
+ return true;
277
+ });
278
+
279
+ // Grep FLASHCARD.md for "also do" patterns
280
+ const alsoDoMatches = [];
281
+ if (fs.existsSync(flashcardPath)) {
282
+ const fcContent = fs.readFileSync(flashcardPath, 'utf8');
283
+ const lines = fcContent.split('\n');
284
+ for (const line of lines) {
285
+ if (/remember to also|also do X|also need to/i.test(line)) {
286
+ alsoDoMatches.push(line.trim());
287
+ }
288
+ }
289
+ }
290
+
291
+ // Read existing suggestions file content (current sprint)
292
+ let existingContent = fs.existsSync(suggestionsFile)
293
+ ? fs.readFileSync(suggestionsFile, 'utf8')
294
+ : `# Improvement Suggestions — ${sprintId}\n\nGenerated by \`suggest_improvements.mjs\`. Append-only; IDs are stable.\nVocabulary: Templates | Handoffs | Skills | Process | Tooling\n\n---\n\n`;
295
+
296
+ /**
297
+ * Check if a hash already appears in current sprint's suggestions OR any prior sprint's.
298
+ * @param {string} hash
299
+ * @returns {boolean}
300
+ */
301
+ function hashAlreadySeen(hash) {
302
+ const marker = `<!-- hash:${hash} -->`;
303
+ if (existingContent.includes(marker)) return true;
304
+ for (const priorContent of priorSuggestionsContents) {
305
+ if (priorContent.includes(marker)) return true;
306
+ }
307
+ return false;
308
+ }
309
+
310
+ // Build the candidates
311
+ const candidates = [];
312
+ let candN = 1;
313
+ for (const [key, bucket] of repeatedTuples) {
314
+ const [workItemId, agentType] = key.split('|');
315
+ const candId = `CAND-${sprintId}-S${String(candN).padStart(2, '0')}`;
316
+ const hashKey = `skill|${key}`;
317
+ const hash = stableHash(hashKey);
318
+ if (!hashAlreadySeen(hash)) {
319
+ candidates.push({ candId, hash, workItemId, agentType, source: 'ledger', sprintIds: bucket.sprintIds });
320
+ candN++;
321
+ }
322
+ }
323
+ for (const line of alsoDoMatches) {
324
+ const candId = `CAND-${sprintId}-S${String(candN).padStart(2, '0')}`;
325
+ const hashKey = `skill|flashcard|${line.slice(0, 60)}`;
326
+ const hash = stableHash(hashKey);
327
+ if (!hashAlreadySeen(hash)) {
328
+ candidates.push({ candId, hash, workItemId: null, agentType: null, source: 'flashcard', line, sprintIds: new Set() });
329
+ candN++;
330
+ }
331
+ }
332
+
333
+ // Build the section content
334
+ const sectionLines = [
335
+ '## Skill Creation Candidates',
336
+ '',
337
+ '<!-- generated-by: suggest_improvements.mjs --skill-candidates -->',
338
+ '',
339
+ ];
340
+
341
+ if (candidates.length === 0) {
342
+ sectionLines.push('_No candidates detected this sprint._');
343
+ sectionLines.push('');
344
+ } else {
345
+ for (const c of candidates) {
346
+ sectionLines.push(`### ${c.candId}: ${c.source === 'ledger' ? `${c.workItemId} × ${c.agentType}` : 'flashcard pattern'}`);
347
+ sectionLines.push(`<!-- hash:${c.hash} -->`);
348
+ sectionLines.push('');
349
+ if (c.source === 'ledger') {
350
+ const sprintList = c.sprintIds ? [...c.sprintIds].sort().join(', ') : sprintId;
351
+ sectionLines.push(`**Pattern detected:** ${c.workItemId} × ${c.agentType} repeated ≥3× across ≥2 distinct sprints (${sprintList})`);
352
+ } else {
353
+ sectionLines.push(`**Pattern detected:** "also do" pattern in FLASHCARD.md`);
354
+ sectionLines.push(`**Source line:** \`${c.line}\``);
355
+ }
356
+ sectionLines.push(`**Proposed skill:** \`.claude/skills/<slug>/SKILL.md\``);
357
+ sectionLines.push('');
358
+ sectionLines.push('---');
359
+ sectionLines.push('');
360
+ }
361
+ }
362
+
363
+ const sectionContent = sectionLines.join('\n');
364
+ // Anchored heading regex — strict match, no trailing text
365
+ const headingRe = /^## Skill Creation Candidates$/m;
366
+
367
+ let finalContent;
368
+ if (headingRe.test(existingContent)) {
369
+ // Replace existing section (splice out old, append new)
370
+ const nextHeadingRe = /^## /m;
371
+ const headingIdx = existingContent.search(headingRe);
372
+ const afterHeading = existingContent.slice(headingIdx + existingContent.match(headingRe)[0].length);
373
+ const nextMatch = afterHeading.search(/^## /m);
374
+ if (nextMatch === -1) {
375
+ finalContent = existingContent.slice(0, headingIdx).trimEnd() + '\n\n' + sectionContent;
376
+ } else {
377
+ finalContent = existingContent.slice(0, headingIdx).trimEnd() + '\n\n' + sectionContent + '\n' + afterHeading.slice(nextMatch);
378
+ }
379
+ void nextHeadingRe;
380
+ } else {
381
+ finalContent = existingContent.trimEnd() + '\n\n' + sectionContent;
382
+ }
383
+
384
+ atomicWrite(suggestionsFile, finalContent);
385
+ process.stdout.write(`suggest_improvements: skill-candidates section written to ${suggestionsFile}\n`);
386
+ }
387
+
388
+ /**
389
+ * Scan FLASHCARD.md for cleanup candidates (stale / superseded / resolved).
390
+ * Appends or replaces the "## FLASHCARD Cleanup Candidates" section in improvement-suggestions.md.
391
+ * @param {string} sprintId
392
+ * @param {string} sprintDir
393
+ * @param {string} suggestionsFile
394
+ */
395
+ function scanFlashcardCleanup(sprintId, sprintDir, suggestionsFile) {
396
+ const flashcardPath = process.env.CLEARGATE_FLASHCARD_PATH
397
+ ? path.resolve(process.env.CLEARGATE_FLASHCARD_PATH)
398
+ : path.join(REPO_ROOT, '.cleargate', 'FLASHCARD.md');
399
+
400
+ const lookback = parseInt(process.env.CLEARGATE_FLASHCARD_LOOKBACK ?? '3', 10);
401
+ const sprintRunsDir = process.env.CLEARGATE_SPRINT_RUNS_DIR
402
+ ? path.resolve(process.env.CLEARGATE_SPRINT_RUNS_DIR)
403
+ : path.dirname(sprintDir);
404
+
405
+ if (!fs.existsSync(flashcardPath)) {
406
+ process.stderr.write(`suggest_improvements --flashcard-cleanup: FLASHCARD.md not found at ${flashcardPath}, skipping\n`);
407
+ return;
408
+ }
409
+
410
+ const fcContent = fs.readFileSync(flashcardPath, 'utf8');
411
+ // Parse entries: YYYY-MM-DD · #tag1 #tag2 · lesson
412
+ const entryRe = /^(\d{4}-\d{2}-\d{2})\s+·\s+(.*?)\s+·\s+(.+)$/;
413
+ const entries = fcContent.split('\n').filter(l => entryRe.test(l.trim()));
414
+
415
+ // Determine lookback sprint dirs (numerical extraction from sprintId)
416
+ const numMatch = sprintId.match(/(\d+)$/);
417
+ const currentNum = numMatch ? parseInt(numMatch[1], 10) : null;
418
+ const sprintPrefix = numMatch ? sprintId.replace(/\d+$/, '') : null;
419
+
420
+ // Collect prior sprint REPORT.md content for "resolved" detection
421
+ const priorReportContent = [];
422
+ if (currentNum !== null && sprintPrefix !== null) {
423
+ for (let i = 1; i <= lookback; i++) {
424
+ const priorId = `${sprintPrefix}${currentNum - i}`;
425
+ const priorDir = path.join(sprintRunsDir, priorId);
426
+ if (!fs.existsSync(priorDir)) continue;
427
+ try {
428
+ const rFile = reportFilename(priorDir, priorId, { forRead: true });
429
+ if (fs.existsSync(rFile)) {
430
+ priorReportContent.push(fs.readFileSync(rFile, 'utf8'));
431
+ }
432
+ } catch { /* skip missing */ }
433
+ }
434
+ }
435
+
436
+ // Collect prior sprint dirs for stale detection
437
+ const priorSprintDirs = [];
438
+ if (currentNum !== null && sprintPrefix !== null) {
439
+ for (let i = 1; i <= lookback; i++) {
440
+ const priorId = `${sprintPrefix}${currentNum - i}`;
441
+ const priorDir = path.join(sprintRunsDir, priorId);
442
+ if (fs.existsSync(priorDir)) priorSprintDirs.push(priorDir);
443
+ }
444
+ }
445
+
446
+ /** @type {{ entry: string, date: string, tags: string, lesson: string, category: 'stale'|'superseded'|'resolved', reason: string }[]} */
447
+ const candidates = [];
448
+ const processedHashes = new Set();
449
+
450
+ for (const rawEntry of entries) {
451
+ const m = rawEntry.trim().match(entryRe);
452
+ if (!m) continue;
453
+ const [, date, tags, lesson] = m;
454
+ const entry = rawEntry.trim();
455
+ const hashKey = `flashcard|${entry.slice(0, 80)}`;
456
+ const hash = stableHash(hashKey);
457
+ if (processedHashes.has(hash)) continue;
458
+ processedHashes.add(hash);
459
+
460
+ // Extract first keyword from lesson (first word-run, stripping punctuation)
461
+ const keyword = lesson.split(/\s+/)[0].replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase();
462
+ if (!keyword) continue;
463
+
464
+ // Check "resolved": keyword appears in §6 Tooling "Resolved" of a prior REPORT.md
465
+ let resolved = false;
466
+ for (const rc of priorReportContent) {
467
+ const resolvedSection = rc.match(/##\s*§6[^\n]*\n([\s\S]*?)(?=##\s*§|$)/);
468
+ if (resolvedSection && resolvedSection[1].toLowerCase().includes(keyword)) {
469
+ resolved = true;
470
+ break;
471
+ }
472
+ }
473
+ if (resolved) {
474
+ candidates.push({ entry, date, tags, lesson, category: 'resolved', reason: 'keyword found in a prior §6 Tooling section' });
475
+ continue;
476
+ }
477
+
478
+ // Check "stale": zero grep hits for keyword across prior sprint dirs
479
+ if (priorSprintDirs.length > 0) {
480
+ let hitCount = 0;
481
+ for (const priorDir of priorSprintDirs) {
482
+ try {
483
+ const files = fs.readdirSync(priorDir);
484
+ for (const f of files) {
485
+ const fPath = path.join(priorDir, f);
486
+ if (fs.statSync(fPath).isFile()) {
487
+ const content = fs.readFileSync(fPath, 'utf8');
488
+ if (content.toLowerCase().includes(keyword)) { hitCount++; break; }
489
+ }
490
+ }
491
+ } catch { /* skip unreadable dirs */ }
492
+ }
493
+ if (hitCount === 0) {
494
+ candidates.push({ entry, date, tags, lesson, category: 'stale', reason: `stale: zero grep hits across last ${priorSprintDirs.length} sprint dir(s)` });
495
+ continue;
496
+ }
497
+ }
498
+ }
499
+
500
+ // Read existing suggestions file content
501
+ let existingContent = fs.existsSync(suggestionsFile)
502
+ ? fs.readFileSync(suggestionsFile, 'utf8')
503
+ : `# Improvement Suggestions — ${sprintId}\n\nGenerated by \`suggest_improvements.mjs\`. Append-only; IDs are stable.\nVocabulary: Templates | Handoffs | Skills | Process | Tooling\n\n---\n\n`;
504
+
505
+ // Build section
506
+ const sectionLines = [
507
+ '## FLASHCARD Cleanup Candidates',
508
+ '',
509
+ '<!-- generated-by: suggest_improvements.mjs --flashcard-cleanup -->',
510
+ '',
511
+ ];
512
+
513
+ // Filter out already-captured candidates
514
+ const newCandidates = candidates.filter(c => {
515
+ const hash = stableHash(`flashcard|${c.entry.slice(0, 80)}`);
516
+ return !existingContent.includes(`<!-- hash:${hash} -->`);
517
+ });
518
+
519
+ if (newCandidates.length === 0) {
520
+ sectionLines.push('_No candidates detected this sprint._');
521
+ sectionLines.push('');
522
+ } else {
523
+ newCandidates.forEach((c, i) => {
524
+ const candId = `CAND-${sprintId}-F${String(i + 1).padStart(2, '0')}`;
525
+ const hash = stableHash(`flashcard|${c.entry.slice(0, 80)}`);
526
+ sectionLines.push(`### ${candId}: ${c.lesson.slice(0, 60)}`);
527
+ sectionLines.push(`<!-- hash:${hash} -->`);
528
+ sectionLines.push('');
529
+ sectionLines.push(`**Category:** ${c.category}`);
530
+ sectionLines.push(`**Reason:** ${c.reason}`);
531
+ sectionLines.push(`**Original entry:** \`${c.entry}\``);
532
+ sectionLines.push('**Suggested action:** approve to remove via `cleargate flashcard prune` (run /improve)');
533
+ sectionLines.push('');
534
+ sectionLines.push('---');
535
+ sectionLines.push('');
536
+ });
537
+ }
538
+
539
+ const sectionContent = sectionLines.join('\n');
540
+ // Anchored heading regex — strict match, no trailing text
541
+ const headingRe = /^## FLASHCARD Cleanup Candidates$/m;
542
+
543
+ let finalContent;
544
+ if (headingRe.test(existingContent)) {
545
+ const headingIdx = existingContent.search(headingRe);
546
+ const afterHeading = existingContent.slice(headingIdx + existingContent.match(headingRe)[0].length);
547
+ const nextMatch = afterHeading.search(/^## /m);
548
+ if (nextMatch === -1) {
549
+ finalContent = existingContent.slice(0, headingIdx).trimEnd() + '\n\n' + sectionContent;
550
+ } else {
551
+ finalContent = existingContent.slice(0, headingIdx).trimEnd() + '\n\n' + sectionContent + '\n' + afterHeading.slice(nextMatch);
552
+ }
553
+ } else {
554
+ finalContent = existingContent.trimEnd() + '\n\n' + sectionContent;
555
+ }
556
+
557
+ atomicWrite(suggestionsFile, finalContent);
558
+ process.stdout.write(`suggest_improvements: flashcard-cleanup section written to ${suggestionsFile}\n`);
559
+ }
560
+
119
561
  function main() {
120
562
  const args = process.argv.slice(2);
121
563
  if (args.length < 1) {
122
- process.stderr.write('Usage: node suggest_improvements.mjs <sprint-id>\n');
564
+ process.stderr.write('Usage: node suggest_improvements.mjs <sprint-id> [--skill-candidates | --flashcard-cleanup]\n');
565
+ process.exit(2);
566
+ }
567
+
568
+ // Flag-aware parse: detect flags first, then extract sprintId from positional args.
569
+ // Sprint IDs match ^SPRINT-\d+$ so they never start with '--'; no collision possible.
570
+ const skillCandidatesMode = args.includes('--skill-candidates');
571
+ const flashcardCleanupMode = args.includes('--flashcard-cleanup');
572
+
573
+ if (skillCandidatesMode && flashcardCleanupMode) {
574
+ process.stderr.write('Error: --skill-candidates and --flashcard-cleanup are mutually exclusive\n');
575
+ process.exit(2);
576
+ }
577
+
578
+ const sprintId = args.find(a => !a.startsWith('--'));
579
+ if (!sprintId) {
580
+ process.stderr.write('Usage: node suggest_improvements.mjs <sprint-id> [--skill-candidates | --flashcard-cleanup]\n');
123
581
  process.exit(2);
124
582
  }
125
583
 
126
- const sprintId = args[0];
127
584
  const sprintDir = process.env.CLEARGATE_SPRINT_DIR
128
585
  ? path.resolve(process.env.CLEARGATE_SPRINT_DIR)
129
586
  : path.join(REPO_ROOT, '.cleargate', 'sprint-runs', sprintId);
@@ -133,13 +590,26 @@ function main() {
133
590
  process.exit(1);
134
591
  }
135
592
 
136
- const reportFile = path.join(sprintDir, 'REPORT.md');
593
+ const reportFile = reportFilename(sprintDir, sprintId, { forRead: true });
137
594
  const suggestionsFile = path.join(sprintDir, 'improvement-suggestions.md');
138
595
 
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);
596
+ // Default mode requires REPORT.md; flag-driven modes do not.
597
+ if (!skillCandidatesMode && !flashcardCleanupMode) {
598
+ if (!fs.existsSync(reportFile)) {
599
+ process.stderr.write(`Error: report file not found at ${reportFile}\n`);
600
+ process.stderr.write('Run the Reporter agent first to generate the report.\n');
601
+ process.exit(1);
602
+ }
603
+ }
604
+
605
+ // Route flag-driven modes before default processing
606
+ if (skillCandidatesMode) {
607
+ scanSkillCandidates(sprintId, sprintDir, suggestionsFile);
608
+ return;
609
+ }
610
+ if (flashcardCleanupMode) {
611
+ scanFlashcardCleanup(sprintId, sprintDir, suggestionsFile);
612
+ return;
143
613
  }
144
614
 
145
615
  const reportContent = fs.readFileSync(reportFile, 'utf8');