@entelligentsia/forgecli 0.11.3 → 0.15.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 (62) hide show
  1. package/CHANGELOG.md +314 -0
  2. package/README.md +2 -1
  3. package/dist/CHANGELOG-forge-plugin.md +183 -0
  4. package/dist/bin/forge.js +20 -1
  5. package/dist/bin/forge.js.map +1 -1
  6. package/dist/extensions/forgecli/config-layer.d.ts +15 -0
  7. package/dist/extensions/forgecli/config-layer.js.map +1 -1
  8. package/dist/extensions/forgecli/enhance.js +1 -1
  9. package/dist/extensions/forgecli/enhance.js.map +1 -1
  10. package/dist/extensions/forgecli/forge-cli-schema.json +19 -0
  11. package/dist/extensions/forgecli/forge-tools.js +80 -0
  12. package/dist/extensions/forgecli/forge-tools.js.map +1 -1
  13. package/dist/extensions/forgecli/forge-update-command.js +24 -18
  14. package/dist/extensions/forgecli/forge-update-command.js.map +1 -1
  15. package/dist/extensions/forgecli/friction-emit.d.ts +97 -0
  16. package/dist/extensions/forgecli/friction-emit.js +246 -0
  17. package/dist/extensions/forgecli/friction-emit.js.map +1 -0
  18. package/dist/extensions/forgecli/hook-dispatcher.js +20 -0
  19. package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
  20. package/dist/extensions/forgecli/index.js +29 -5
  21. package/dist/extensions/forgecli/index.js.map +1 -1
  22. package/dist/extensions/forgecli/regenerate.d.ts +22 -0
  23. package/dist/extensions/forgecli/regenerate.js +133 -3
  24. package/dist/extensions/forgecli/regenerate.js.map +1 -1
  25. package/dist/extensions/forgecli/skill-curation-flag.d.ts +21 -0
  26. package/dist/extensions/forgecli/skill-curation-flag.js +71 -0
  27. package/dist/extensions/forgecli/skill-curation-flag.js.map +1 -0
  28. package/dist/extensions/forgecli/skill-curator-subagent.d.ts +101 -0
  29. package/dist/extensions/forgecli/skill-curator-subagent.js +342 -0
  30. package/dist/extensions/forgecli/skill-curator-subagent.js.map +1 -0
  31. package/dist/extensions/forgecli/skill-retriever.d.ts +84 -0
  32. package/dist/extensions/forgecli/skill-retriever.js +246 -0
  33. package/dist/extensions/forgecli/skill-retriever.js.map +1 -0
  34. package/dist/extensions/forgecli/skill-usage-tracker.d.ts +91 -0
  35. package/dist/extensions/forgecli/skill-usage-tracker.js +224 -0
  36. package/dist/extensions/forgecli/skill-usage-tracker.js.map +1 -0
  37. package/dist/forge-payload/.base-pack/workflows/enhance.md +331 -11
  38. package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
  39. package/dist/forge-payload/.schemas/event.schema.json +20 -2
  40. package/dist/forge-payload/.schemas/migrations.json +96 -0
  41. package/dist/forge-payload/.schemas/proposal.schema.json +40 -0
  42. package/dist/forge-payload/agents/store-query-validator.md +103 -0
  43. package/dist/forge-payload/agents/tomoshibi.md +185 -0
  44. package/dist/forge-payload/commands/regenerate.md +109 -20
  45. package/dist/forge-payload/hooks/check-update.js +378 -0
  46. package/dist/forge-payload/hooks/forge-permissions.js +158 -0
  47. package/dist/forge-payload/hooks/triage-error.js +71 -0
  48. package/dist/forge-payload/hooks/validate-write.js +236 -0
  49. package/dist/forge-payload/integrity.json +32 -0
  50. package/dist/forge-payload/meta/workflows/meta-enhance.md +331 -11
  51. package/dist/forge-payload/schemas/structure-manifest.json +511 -0
  52. package/dist/forge-payload/tools/compression-gate.cjs +192 -0
  53. package/dist/forge-payload/tools/delete-candidate-detector.cjs +114 -0
  54. package/dist/forge-payload/tools/judge-proposal.cjs +177 -0
  55. package/dist/forge-payload/tools/manage-versions.cjs +132 -4
  56. package/dist/forge-payload/tools/queue-drain.cjs +152 -0
  57. package/dist/forge-payload/tools/replay-scoring.cjs +117 -0
  58. package/node_modules/@mariozechner/clipboard/package.json +2 -1
  59. package/node_modules/@mariozechner/clipboard-linux-x64-musl/README.md +3 -0
  60. package/node_modules/@mariozechner/clipboard-linux-x64-musl/clipboard.linux-x64-musl.node +0 -0
  61. package/node_modules/@mariozechner/clipboard-linux-x64-musl/package.json +25 -0
  62. package/package.json +4 -2
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+ // FORGE-S24-T05 — delete-candidate detection (3-sprint zero-use).
3
+ //
4
+ // Scan `skill_usage` events across the trailing `windowSize` sprints of
5
+ // `sprintOrder`. Any skill that has at least one observation inside the
6
+ // window AND zero retrievals AND zero invocations across every in-window
7
+ // observation is a delete candidate. The detector emits one `delete_skill`
8
+ // proposal per qualifying skill.
9
+ //
10
+ // Pure module; consumed by Phase 2 of meta-enhance.md (between the
11
+ // recurrence-annotation step and the proposal-artifact write step).
12
+ //
13
+ // Carry-over caveat (documented in the workflow): the window only becomes
14
+ // meaningful once `windowSize` sprints have actually elapsed since
15
+ // skill_usage emission landed in T01. Until then, the function still runs
16
+ // over the available sprints but the signal is noisier.
17
+ //
18
+ // We deliberately do NOT propose deletion for skills with zero observations
19
+ // in the window — that case is indistinguishable from a brand-new skill
20
+ // that simply hasn't been loaded yet. The detector only deletes what it
21
+ // has actually seen go cold.
22
+ //
23
+ // Exports:
24
+ // scanZeroUse({ events, sprintOrder, windowSize = 3 })
25
+ // -> [{ skillId, observedSprintIds }]
26
+ // buildDeleteProposals({ events, sprintOrder, windowSize = 3, targetPathFor })
27
+ // -> [proposal] — proposal shape conforming to proposal.schema.json.
28
+
29
+ const DEFAULT_WINDOW_SIZE = 3;
30
+
31
+ function scanZeroUse({ events, sprintOrder, windowSize = DEFAULT_WINDOW_SIZE }) {
32
+ if (!Array.isArray(events)) throw new TypeError('events must be an array');
33
+ if (!Array.isArray(sprintOrder)) throw new TypeError('sprintOrder must be an array');
34
+ if (!Number.isInteger(windowSize) || windowSize < 1) {
35
+ throw new TypeError('windowSize must be a positive integer');
36
+ }
37
+
38
+ // Trailing N sprints. If sprintOrder is shorter than the window, use all of it.
39
+ const window = sprintOrder.slice(-windowSize);
40
+ const windowSet = new Set(window);
41
+
42
+ // skillId -> { observedSprintIds: Set, anyRetrieved: bool, anyUsed: bool }
43
+ const bySkill = new Map();
44
+
45
+ for (const evt of events) {
46
+ if (!evt || evt.type !== 'skill_usage') continue;
47
+ if (typeof evt.skillId !== 'string' || evt.skillId === '') continue;
48
+ if (typeof evt.sprintId !== 'string' || evt.sprintId === '') continue;
49
+ if (!windowSet.has(evt.sprintId)) continue;
50
+
51
+ let agg = bySkill.get(evt.skillId);
52
+ if (!agg) {
53
+ agg = { observedSprintIds: new Set(), anyRetrieved: false, anyUsed: false };
54
+ bySkill.set(evt.skillId, agg);
55
+ }
56
+ agg.observedSprintIds.add(evt.sprintId);
57
+ if (evt.retrieved === true) agg.anyRetrieved = true;
58
+ if (evt.used === true) agg.anyUsed = true;
59
+ }
60
+
61
+ const results = [];
62
+ for (const [skillId, agg] of bySkill) {
63
+ if (agg.anyRetrieved || agg.anyUsed) continue;
64
+ if (agg.observedSprintIds.size === 0) continue;
65
+
66
+ // Sort observedSprintIds by window order so output is deterministic and
67
+ // mirrors sprintOrder.
68
+ const ordered = window.filter(s => agg.observedSprintIds.has(s));
69
+ results.push({ skillId, observedSprintIds: ordered });
70
+ }
71
+
72
+ // Deterministic skill ordering.
73
+ results.sort((a, b) => a.skillId.localeCompare(b.skillId));
74
+ return results;
75
+ }
76
+
77
+ function buildDeleteProposals({
78
+ events,
79
+ sprintOrder,
80
+ windowSize = DEFAULT_WINDOW_SIZE,
81
+ targetPathFor,
82
+ }) {
83
+ if (typeof targetPathFor !== 'function') {
84
+ throw new TypeError('targetPathFor must be a function');
85
+ }
86
+
87
+ const candidates = scanZeroUse({ events, sprintOrder, windowSize });
88
+ const window = sprintOrder.slice(-windowSize);
89
+
90
+ return candidates.map(({ skillId, observedSprintIds }) => ({
91
+ op: 'delete_skill',
92
+ target_path: targetPathFor(skillId),
93
+ diff_body:
94
+ `- entire skill removed: ${skillId}\n` +
95
+ `- reason: zero retrieval AND zero invocation across ` +
96
+ `${observedSprintIds.length} observed sprint(s) within trailing ` +
97
+ `${windowSize}-sprint window (${window.join(', ')})`,
98
+ rationale:
99
+ `Skill "${skillId}" has no retrieval and no invocation in any of the ` +
100
+ `trailing ${windowSize} sprints it was observed in (${observedSprintIds.join(', ')}). ` +
101
+ `Delete-candidate per FORGE-S24-T05 detector.`,
102
+ sourceFrictionIds: [],
103
+ window_size: windowSize,
104
+ window_sprint_ids: observedSprintIds.slice(),
105
+ recurrence_count: 1,
106
+ recurrence_task_ids: [],
107
+ }));
108
+ }
109
+
110
+ module.exports = {
111
+ scanZeroUse,
112
+ buildDeleteProposals,
113
+ DEFAULT_WINDOW_SIZE,
114
+ };
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+ // FORGE-S24-T03 — LLM-judge step in Phase 2 (Sonnet rubric, drop <3/5).
3
+ //
4
+ // Pure module consumed by meta-enhance.md Phase 2 (step 5c) after the
5
+ // recurrence annotator (step 5a) and delete-candidate detector (step 5b)
6
+ // and BEFORE the artifact write (step 6).
7
+ //
8
+ // Two roles:
9
+ //
10
+ // 1. scoreProposal(proposal) — deterministic per-axis scorer. The workflow
11
+ // instructs Sonnet to apply the same rubric and emit scores; this
12
+ // module is both the fallback when Sonnet is unavailable AND the
13
+ // reference implementation tests pin so the rubric definition stays
14
+ // single-sourced. The five axes (RUBRIC_AXES) each carry a 0..5 score.
15
+ //
16
+ // 2. decideJudgement({ axes }) — pure aggregation. average < 3 -> drop;
17
+ // otherwise keep. Reason names the average so the rejection log is
18
+ // self-describing for retro review (AC3).
19
+ //
20
+ // The judge runs against every proposal pre-presentation (AC1). Dropped
21
+ // proposals are logged with per-axis scores (AC3) by the workflow caller.
22
+ //
23
+ // Iron Law 2: tests for this module landed first (see judge-proposal.test.cjs).
24
+
25
+ const RUBRIC_AXES = Object.freeze([
26
+ 'specificity',
27
+ 'when_not_to_use',
28
+ 'no_trajectory_copy_paste',
29
+ 'body_under_2kb',
30
+ 'cites_friction',
31
+ ]);
32
+
33
+ const DROP_THRESHOLD = 3; // avg < 3 -> drop (AC: drop < 3/5)
34
+ const MAX_BODY_BYTES = 2048; // 2KB cap (AC: <=2KB body)
35
+ const TRAJECTORY_RUN_BYTES = 400; // suspicious verbatim run length
36
+
37
+ // --- Per-axis scorers -----------------------------------------------------
38
+
39
+ function scoreSpecificity(proposal) {
40
+ // Specificity heuristic: a specific proposal names a concrete artifact
41
+ // path beyond the generic top-level skills directory AND has a non-empty
42
+ // rationale OR a recurrence trail.
43
+ const t = String(proposal.target_path || '');
44
+ const r = String(proposal.rationale || '');
45
+ const seg = t.split('/').filter(Boolean);
46
+ const deepPath = seg.length >= 3; // forge/skills/<name>.md is the floor
47
+ const namedSkill =
48
+ seg.length >= 1 &&
49
+ !/^(misc|tips|notes|general)\b/i.test(seg[seg.length - 1] || '');
50
+ const hasRationale = r.trim().length >= 16;
51
+ const recurrent = Number(proposal.recurrence_count || 1) > 1;
52
+
53
+ let s = 0;
54
+ if (deepPath) s += 2;
55
+ if (namedSkill) s += 1;
56
+ if (hasRationale) s += 1;
57
+ if (recurrent) s += 1;
58
+ return clamp05(s);
59
+ }
60
+
61
+ function scoreWhenNotToUse(proposal) {
62
+ // AC: rubric axis "When NOT to use" present. Phrase-based check. We accept
63
+ // the canonical phrase or any reasonable case variation.
64
+ const body = String(proposal.diff_body || '');
65
+ return /when\s+not\s+to\s+use/i.test(body) ? 5 : 0;
66
+ }
67
+
68
+ function scoreNoTrajectoryCopyPaste(proposal) {
69
+ // Heuristic: long verbatim runs of the same character or massive
70
+ // unbroken whitespace-free blocks suggest pasted trajectory output.
71
+ const body = String(proposal.diff_body || '');
72
+ // Penalise long runs of the same character (>= TRAJECTORY_RUN_BYTES).
73
+ const longRun = new RegExp('(.)\\1{' + (TRAJECTORY_RUN_BYTES - 1) + ',}');
74
+ if (longRun.test(body)) return 0;
75
+ // Penalise any unbroken non-whitespace block >= TRAJECTORY_RUN_BYTES.
76
+ if (/\S{400,}/.test(body)) return 0;
77
+ // Penalise lines that look like raw log entries dominating the body.
78
+ const lines = body.split(/\n/);
79
+ const longLines = lines.filter((l) => l.length >= 300).length;
80
+ if (longLines >= 3) return 1;
81
+ return 5;
82
+ }
83
+
84
+ function scoreBodyUnder2KB(proposal) {
85
+ const body = String(proposal.diff_body || '');
86
+ const bytes = Buffer.byteLength(body, 'utf8');
87
+ return bytes <= MAX_BODY_BYTES ? 5 : 0;
88
+ }
89
+
90
+ function scoreCitesFriction(proposal) {
91
+ const ids = Array.isArray(proposal.sourceFrictionIds)
92
+ ? proposal.sourceFrictionIds
93
+ : [];
94
+ if (ids.length === 0) return 0;
95
+ if (ids.length >= 3) return 5;
96
+ if (ids.length === 2) return 4;
97
+ // Single citation: boost when recurrence trail shows the friction is real.
98
+ const recurrent =
99
+ Number(proposal.recurrence_count || 1) > 1 &&
100
+ Array.isArray(proposal.recurrence_task_ids) &&
101
+ proposal.recurrence_task_ids.length > 1;
102
+ return recurrent ? 3 : 1;
103
+ }
104
+
105
+ // --- Aggregators ----------------------------------------------------------
106
+
107
+ function scoreProposal(proposal) {
108
+ if (!proposal || typeof proposal !== 'object') {
109
+ throw new TypeError('proposal must be an object');
110
+ }
111
+
112
+ const axes = {
113
+ specificity: scoreSpecificity(proposal),
114
+ when_not_to_use: scoreWhenNotToUse(proposal),
115
+ no_trajectory_copy_paste: scoreNoTrajectoryCopyPaste(proposal),
116
+ body_under_2kb: scoreBodyUnder2KB(proposal),
117
+ cites_friction: scoreCitesFriction(proposal),
118
+ };
119
+
120
+ validateAxes(axes);
121
+
122
+ const average = averageOf(axes);
123
+ return { axes, average };
124
+ }
125
+
126
+ function decideJudgement({ axes }) {
127
+ if (!axes || typeof axes !== 'object') {
128
+ throw new TypeError('axes must be an object');
129
+ }
130
+ validateAxes(axes);
131
+ const average = averageOf(axes);
132
+ const verdict = average < DROP_THRESHOLD ? 'drop' : 'keep';
133
+ const reason = verdict === 'drop'
134
+ ? `dropped: rubric average ${average.toFixed(1)} < ${DROP_THRESHOLD}/5 (axes: ${formatAxes(axes)})`
135
+ : `kept: rubric average ${average.toFixed(1)} >= ${DROP_THRESHOLD}/5 (axes: ${formatAxes(axes)})`;
136
+ return { verdict, average, axes: { ...axes }, reason };
137
+ }
138
+
139
+ // --- Internal helpers -----------------------------------------------------
140
+
141
+ function validateAxes(axes) {
142
+ for (const axis of RUBRIC_AXES) {
143
+ if (!(axis in axes)) {
144
+ throw new RangeError(`missing axis: ${axis}`);
145
+ }
146
+ const v = axes[axis];
147
+ if (!Number.isFinite(v) || v < 0 || v > 5) {
148
+ throw new RangeError(`axis ${axis} out of range [0,5]: ${v}`);
149
+ }
150
+ }
151
+ }
152
+
153
+ function averageOf(axes) {
154
+ let sum = 0;
155
+ for (const axis of RUBRIC_AXES) sum += axes[axis];
156
+ // Round to one decimal place; rubric resolution is intentionally coarse so
157
+ // the rejection log reads cleanly in retros.
158
+ return Math.round((sum / RUBRIC_AXES.length) * 10) / 10;
159
+ }
160
+
161
+ function formatAxes(axes) {
162
+ return RUBRIC_AXES.map((a) => `${a}=${axes[a]}`).join(', ');
163
+ }
164
+
165
+ function clamp05(n) {
166
+ if (n < 0) return 0;
167
+ if (n > 5) return 5;
168
+ return n;
169
+ }
170
+
171
+ module.exports = {
172
+ RUBRIC_AXES,
173
+ DROP_THRESHOLD,
174
+ MAX_BODY_BYTES,
175
+ scoreProposal,
176
+ decideJudgement,
177
+ };
@@ -152,7 +152,11 @@ function copyFileWithDirs(src, dest) {
152
152
  *
153
153
  * @param {string} projectRoot - path to the project root (where .forge/ lives)
154
154
  * @param {string} source - snapshot source label (post-init | post-sprint:<ID> | on-demand)
155
- * @param {string[]} enhancedElements - list of .forge/-relative paths that were enhanced
155
+ * @param {string[]} enhancedElements - list of paths that were enhanced. Both
156
+ * ".forge/-relative" (e.g. "personas/X.md") and
157
+ * project-root-relative (".forge/personas/X.md")
158
+ * forms accepted; the tool strips a leading
159
+ * ".forge/" if present. See forge#108.
156
160
  * @param {boolean} [dryRun] - when true, log intent but perform no I/O
157
161
  */
158
162
  function addSnapshot(projectRoot, source, enhancedElements, dryRun) {
@@ -191,9 +195,23 @@ function addSnapshot(projectRoot, source, enhancedElements, dryRun) {
191
195
  }
192
196
 
193
197
  // Archive each enhanced element by copying from .forge/ into the archive dir.
198
+ //
199
+ // Workflow callers pass paths in two forms historically:
200
+ // (a) ".forge/-relative", e.g. "personas/engineer.md"
201
+ // (b) project-root-relative, e.g. ".forge/personas/engineer.md"
202
+ // The docstring at the top of this function declared (a), but every meta-enhance
203
+ // and base-pack/enhance.md invocation passes (b). The previous code did
204
+ // path.join(projectRoot, ".forge", relPath) which double-prefixed (b) paths
205
+ // and silently skipped them via fs.existsSync. Result: every archive directory
206
+ // since basePackVersion 0.43.3 was created empty — layer 2 of the composition
207
+ // contract (manage-versions.cjs:13) became a no-op. See forge#108 / FORGE-BUG-038.
208
+ //
209
+ // Fix: normalize each relPath by stripping a leading ".forge/" if present.
210
+ // Archive destination uses the normalized form for consistency.
194
211
  for (const relPath of (enhancedElements || [])) {
195
- const srcPath = path.join(projectRoot, '.forge', relPath);
196
- const destPath = path.join(archiveAbsPath, relPath);
212
+ const normalizedRel = relPath.replace(/^\.\/?(?=\.forge\/)/, '').replace(/^\.forge\//, '');
213
+ const srcPath = path.join(projectRoot, '.forge', normalizedRel);
214
+ const destPath = path.join(archiveAbsPath, normalizedRel);
197
215
  if (fs.existsSync(srcPath)) {
198
216
  copyFileWithDirs(srcPath, destPath);
199
217
  }
@@ -261,6 +279,101 @@ function initStructureVersions(projectRoot, forgeRoot, dryRun, source) {
261
279
  console.log(`ノ structure-versions.json written (snapshot 0, source: ${effectiveSource}, plugin: v${basePackVersion})`);
262
280
  }
263
281
 
282
+ // ---------------------------------------------------------------------------
283
+ // forge#107 — Approach A layer 3: snapshot replay
284
+ //
285
+ // After /forge:regenerate writes fresh base-pack content over .forge/{personas,
286
+ // skills,workflows,templates}/, walk the snapshots in order and restore each
287
+ // enhancedElement that matches the target prefix. Later snapshots win on file
288
+ // collision (last write).
289
+ //
290
+ // Semantics: "overlay" — user-enhanced files retain their captured content
291
+ // even when the base-pack version of that file has changed in a plugin
292
+ // update. Trade-off accepted for v1; future v2 may layer 3-way merge.
293
+ //
294
+ // Target prefix is normalized (leading ".forge/" stripped) and matches against
295
+ // the normalized form of each enhancedElement's path. So:
296
+ // --target personas matches personas/X.md, personas/Y.md
297
+ // --target personas/engineer.md matches only that exact file
298
+ // --target .forge/personas same as --target personas
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * Normalize a path by stripping a leading ".forge/" prefix.
303
+ * Symmetric with addSnapshot's archive normalization (forge#108).
304
+ */
305
+ function normalizeRel(p) {
306
+ return p.replace(/^\.\/?(?=\.forge\/)/, '').replace(/^\.forge\//, '');
307
+ }
308
+
309
+ /**
310
+ * Replay snapshots — restore enhanced files matching the target prefix.
311
+ *
312
+ * @param {string} projectRoot - cwd; project root with .forge/
313
+ * @param {string} target - prefix (e.g. "personas") or exact path
314
+ * (e.g. "personas/engineer.md"); ".forge/" prefix tolerated
315
+ * @param {boolean} [dryRun] - log intent without copying
316
+ * @returns {{restored: string[], skipped: string[]}}
317
+ */
318
+ function replaySnapshots(projectRoot, target, dryRun) {
319
+ if (!target) {
320
+ throw new Error('replay requires --target <prefix>. Examples: --target personas, --target personas/engineer.md');
321
+ }
322
+
323
+ const normalizedTarget = normalizeRel(target);
324
+ const doc = readStructureVersions(projectRoot);
325
+
326
+ // Build a per-file map: relPath -> { archivePath, snapshotIndex }.
327
+ // Later snapshots overwrite earlier on collision (Map insertion semantics
328
+ // give us first-set value; we want last-set, so iterate ascending and
329
+ // overwrite explicitly).
330
+ const fileMap = new Map();
331
+ for (const snap of doc.snapshots) {
332
+ if (!snap.enhancedElements || !snap.archivePath) continue;
333
+ for (const elem of snap.enhancedElements) {
334
+ const normalized = normalizeRel(elem);
335
+ // Match: target is a path-prefix of normalized element.
336
+ // Path-boundary: ensure "personas" matches "personas/X.md" but not "personas-foo.md".
337
+ const isMatch = normalized === normalizedTarget ||
338
+ normalized.startsWith(normalizedTarget + '/');
339
+ if (!isMatch) continue;
340
+
341
+ const archiveAbs = path.join(projectRoot, snap.archivePath, normalized);
342
+ fileMap.set(normalized, { archiveAbs, snapshotIndex: snap.index });
343
+ }
344
+ }
345
+
346
+ const restored = [];
347
+ const skipped = [];
348
+
349
+ for (const [normalized, { archiveAbs, snapshotIndex }] of fileMap) {
350
+ const destAbs = path.join(projectRoot, '.forge', normalized);
351
+ if (!fs.existsSync(archiveAbs)) {
352
+ // Archive missing (pre-forge#108 snapshots created empty archives).
353
+ // Skip — we can't restore what we don't have.
354
+ skipped.push(normalized);
355
+ continue;
356
+ }
357
+ if (dryRun) {
358
+ console.log(`[dry-run] Would restore ${normalized} from snap-${snapshotIndex}`);
359
+ restored.push(normalized);
360
+ continue;
361
+ }
362
+ copyFileWithDirs(archiveAbs, destAbs);
363
+ restored.push(normalized);
364
+ }
365
+
366
+ if (restored.length > 0) {
367
+ console.log(`〇 replay complete — ${restored.length} file(s) restored from snapshots matching '${target}'`);
368
+ } else if (skipped.length > 0) {
369
+ console.log(`△ replay — ${skipped.length} matching element(s) skipped (archive missing; created before forge#108 fix)`);
370
+ } else {
371
+ console.log(`〇 replay — no enhanced elements match target '${target}'; nothing to restore`);
372
+ }
373
+
374
+ return { restored, skipped };
375
+ }
376
+
264
377
  // ---------------------------------------------------------------------------
265
378
  // Exports (for unit tests)
266
379
  // ---------------------------------------------------------------------------
@@ -268,12 +381,14 @@ function initStructureVersions(projectRoot, forgeRoot, dryRun, source) {
268
381
  module.exports = {
269
382
  initStructureVersions,
270
383
  addSnapshot,
384
+ replaySnapshots,
271
385
  readStructureVersions,
272
386
  writeStructureVersions,
273
387
  VERSIONS_PATH,
274
388
  versionsPath,
275
389
  readPluginVersion,
276
390
  readOverlayToolVersion,
391
+ normalizeRel,
277
392
  };
278
393
 
279
394
  // ---------------------------------------------------------------------------
@@ -352,9 +467,22 @@ if (require.main === module) {
352
467
  break;
353
468
  }
354
469
 
470
+ case 'replay': {
471
+ // forge#107 — Approach A layer 3
472
+ const targetIdx = args.indexOf('--target');
473
+ const target = targetIdx !== -1 ? args[targetIdx + 1] : null;
474
+ if (!target || target.startsWith('--')) {
475
+ console.error('× replay requires --target <prefix>.');
476
+ console.error(' Examples: --target personas, --target personas/engineer.md');
477
+ process.exit(1);
478
+ }
479
+ replaySnapshots(projectRoot, target, DRY_RUN);
480
+ break;
481
+ }
482
+
355
483
  default: {
356
484
  console.error(`× Unknown subcommand: ${subcommand || '(none)'}`);
357
- console.error(' Usage: manage-versions.cjs <init|current|list|add-snapshot> [--dry-run]');
485
+ console.error(' Usage: manage-versions.cjs <init|current|list|add-snapshot|replay> [--dry-run]');
358
486
  process.exit(1);
359
487
  }
360
488
  }
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+ // FORGE-S24-T07 — queue drain at sprint close (per-task curator → batched review).
3
+ //
4
+ // Paper §3.2.1 grouped reward: one batched review at sprint close, not one
5
+ // prompt per task. Per-task curators (T10) append proposals to a project-local
6
+ // queue as they encounter friction during the sprint; Phase 2 drains the queue,
7
+ // dedupes by {op, target_path, body-hash}, and feeds a single batch into the
8
+ // downstream pipeline (compression gate T06 → replay scoring T04 → judge T03).
9
+ //
10
+ // Queue layout (canonical):
11
+ // .forge/enhancement-proposals/queue/<sprintId>/<taskId>-<ts>.json
12
+ //
13
+ // - One file per per-task curator run.
14
+ // - The `<ts>` suffix (ISO compact timestamp) means concurrent or repeated
15
+ // curator runs on the same task never collide; each run produces a fresh
16
+ // file. Files are append-only — once written, never overwritten. The drain
17
+ // is a read-only pass that does NOT delete the queue (operators triage
18
+ // queue files for retrospective debugging).
19
+ //
20
+ // Dedup key: `<op>|<target_path>|sha256(diff_body)`.
21
+ // - `op` and `target_path` together identify "what artifact and how" (insert
22
+ // vs update vs delete).
23
+ // - `sha256(diff_body)` makes two proposals with byte-identical diff bodies
24
+ // collapse to one even if their rationales or sourceFrictionIds differ.
25
+ // Different bodies → different proposals (a smaller surgical patch and a
26
+ // full-file rewrite are distinct decisions for the judge).
27
+ //
28
+ // Pure helpers (`bodyHash`, `dedupeKey`, `dedupeProposals`, `queuePathFor`) are
29
+ // fs-free. `appendToQueue` and `drainQueue` touch the filesystem.
30
+ //
31
+ // Exports:
32
+ // bodyHash(body) → sha256 hex digest of body (UTF-8).
33
+ // dedupeKey(proposal) → composite key string.
34
+ // dedupeProposals(proposals) → first-seen-wins deduped array.
35
+ // queuePathFor({ queueRoot, sprintId, taskId, ts })
36
+ // → canonical file path (string).
37
+ // appendToQueue({ queueRoot, sprintId, taskId, ts, proposals })
38
+ // → absolute path written; throws if path
39
+ // already exists (append-only invariant).
40
+ // drainQueue({ queueRoot, sprintId })
41
+ // → { proposals: [...deduped], files: [...],
42
+ // errors: [...] } — fs read, never write.
43
+
44
+ const fs = require('node:fs');
45
+ const path = require('node:path');
46
+ const crypto = require('node:crypto');
47
+
48
+ function bodyHash(body) {
49
+ if (typeof body !== 'string') {
50
+ throw new TypeError('bodyHash: body must be a string');
51
+ }
52
+ return crypto.createHash('sha256').update(body, 'utf8').digest('hex');
53
+ }
54
+
55
+ function dedupeKey(proposal) {
56
+ if (!proposal || typeof proposal !== 'object') {
57
+ throw new TypeError('dedupeKey: proposal must be an object');
58
+ }
59
+ const op = String(proposal.op ?? '');
60
+ const target_path = String(proposal.target_path ?? '');
61
+ const diff_body = String(proposal.diff_body ?? '');
62
+ return `${op}|${target_path}|${bodyHash(diff_body)}`;
63
+ }
64
+
65
+ function dedupeProposals(proposals) {
66
+ if (!Array.isArray(proposals)) {
67
+ throw new TypeError('dedupeProposals: proposals must be an array');
68
+ }
69
+ const seen = new Set();
70
+ const out = [];
71
+ for (const p of proposals) {
72
+ const k = dedupeKey(p);
73
+ if (seen.has(k)) continue;
74
+ seen.add(k);
75
+ out.push(p);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ function queuePathFor({ queueRoot, sprintId, taskId, ts } = {}) {
81
+ if (typeof sprintId !== 'string' || !sprintId) {
82
+ throw new TypeError('queuePathFor: sprintId is required');
83
+ }
84
+ if (typeof taskId !== 'string' || !taskId) {
85
+ throw new TypeError('queuePathFor: taskId is required');
86
+ }
87
+ if (typeof ts !== 'string' || !ts) {
88
+ throw new TypeError('queuePathFor: ts is required');
89
+ }
90
+ const root = queueRoot ?? '.forge/enhancement-proposals/queue';
91
+ return path.join(root, sprintId, `${taskId}-${ts}.json`);
92
+ }
93
+
94
+ function appendToQueue({ queueRoot, sprintId, taskId, ts, proposals } = {}) {
95
+ if (!Array.isArray(proposals)) {
96
+ throw new TypeError('appendToQueue: proposals must be an array');
97
+ }
98
+ const target = queuePathFor({ queueRoot, sprintId, taskId, ts });
99
+ if (fs.existsSync(target)) {
100
+ throw new Error(
101
+ `appendToQueue: queue file already exists at ${target} ` +
102
+ `(queue is append-only; choose a fresh ts).`,
103
+ );
104
+ }
105
+ fs.mkdirSync(path.dirname(target), { recursive: true });
106
+ fs.writeFileSync(target, JSON.stringify(proposals, null, 2), 'utf8');
107
+ return target;
108
+ }
109
+
110
+ function drainQueue({ queueRoot, sprintId } = {}) {
111
+ if (typeof sprintId !== 'string' || !sprintId) {
112
+ throw new TypeError('drainQueue: sprintId is required');
113
+ }
114
+ if (typeof queueRoot !== 'string' || !queueRoot) {
115
+ throw new TypeError('drainQueue: queueRoot is required');
116
+ }
117
+ const sprintDir = path.join(queueRoot, sprintId);
118
+ const result = { proposals: [], files: [], errors: [] };
119
+ if (!fs.existsSync(sprintDir)) return result;
120
+
121
+ const entries = fs.readdirSync(sprintDir)
122
+ .filter((name) => name.endsWith('.json'))
123
+ .sort(); // deterministic order: lexicographic ⇒ chronological because of ts suffix
124
+ const merged = [];
125
+ for (const name of entries) {
126
+ const abs = path.join(sprintDir, name);
127
+ result.files.push(abs);
128
+ let parsed;
129
+ try {
130
+ parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
131
+ } catch (err) {
132
+ result.errors.push({ file: abs, error: err.message });
133
+ continue;
134
+ }
135
+ if (!Array.isArray(parsed)) {
136
+ result.errors.push({ file: abs, error: 'expected JSON array of proposals' });
137
+ continue;
138
+ }
139
+ for (const p of parsed) merged.push(p);
140
+ }
141
+ result.proposals = dedupeProposals(merged);
142
+ return result;
143
+ }
144
+
145
+ module.exports = {
146
+ bodyHash,
147
+ dedupeKey,
148
+ dedupeProposals,
149
+ queuePathFor,
150
+ appendToQueue,
151
+ drainQueue,
152
+ };