@entelligentsia/forgecli 0.11.2 → 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 (89) hide show
  1. package/CHANGELOG.md +324 -0
  2. package/README.md +2 -1
  3. package/dist/CHANGELOG-forge-plugin.md +210 -0
  4. package/dist/bin/forge.js +20 -1
  5. package/dist/bin/forge.js.map +1 -1
  6. package/dist/extensions/forgecli/ask-user-tool.js +32 -20
  7. package/dist/extensions/forgecli/ask-user-tool.js.map +1 -1
  8. package/dist/extensions/forgecli/config-layer.d.ts +15 -0
  9. package/dist/extensions/forgecli/config-layer.js +4 -1
  10. package/dist/extensions/forgecli/config-layer.js.map +1 -1
  11. package/dist/extensions/forgecli/config-writer.js +4 -1
  12. package/dist/extensions/forgecli/config-writer.js.map +1 -1
  13. package/dist/extensions/forgecli/enhance.js +1 -1
  14. package/dist/extensions/forgecli/enhance.js.map +1 -1
  15. package/dist/extensions/forgecli/fix-bug.js +31 -1
  16. package/dist/extensions/forgecli/fix-bug.js.map +1 -1
  17. package/dist/extensions/forgecli/forge-cli-schema.json +19 -0
  18. package/dist/extensions/forgecli/forge-tools.js +80 -0
  19. package/dist/extensions/forgecli/forge-tools.js.map +1 -1
  20. package/dist/extensions/forgecli/forge-update-command.js +24 -18
  21. package/dist/extensions/forgecli/forge-update-command.js.map +1 -1
  22. package/dist/extensions/forgecli/friction-emit.d.ts +97 -0
  23. package/dist/extensions/forgecli/friction-emit.js +246 -0
  24. package/dist/extensions/forgecli/friction-emit.js.map +1 -0
  25. package/dist/extensions/forgecli/health-check.d.ts +10 -0
  26. package/dist/extensions/forgecli/health-check.js +160 -8
  27. package/dist/extensions/forgecli/health-check.js.map +1 -1
  28. package/dist/extensions/forgecli/hook-dispatcher.js +24 -2
  29. package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
  30. package/dist/extensions/forgecli/hooks/write-guard.js +5 -1
  31. package/dist/extensions/forgecli/hooks/write-guard.js.map +1 -1
  32. package/dist/extensions/forgecli/index.js +29 -5
  33. package/dist/extensions/forgecli/index.js.map +1 -1
  34. package/dist/extensions/forgecli/lib/store-error-remediation.d.ts +65 -0
  35. package/dist/extensions/forgecli/lib/store-error-remediation.js +298 -0
  36. package/dist/extensions/forgecli/lib/store-error-remediation.js.map +1 -0
  37. package/dist/extensions/forgecli/regenerate.d.ts +22 -0
  38. package/dist/extensions/forgecli/regenerate.js +133 -3
  39. package/dist/extensions/forgecli/regenerate.js.map +1 -1
  40. package/dist/extensions/forgecli/run-sprint.js +16 -1
  41. package/dist/extensions/forgecli/run-sprint.js.map +1 -1
  42. package/dist/extensions/forgecli/run-task.js +30 -8
  43. package/dist/extensions/forgecli/run-task.js.map +1 -1
  44. package/dist/extensions/forgecli/skill-curation-flag.d.ts +21 -0
  45. package/dist/extensions/forgecli/skill-curation-flag.js +71 -0
  46. package/dist/extensions/forgecli/skill-curation-flag.js.map +1 -0
  47. package/dist/extensions/forgecli/skill-curator-subagent.d.ts +101 -0
  48. package/dist/extensions/forgecli/skill-curator-subagent.js +342 -0
  49. package/dist/extensions/forgecli/skill-curator-subagent.js.map +1 -0
  50. package/dist/extensions/forgecli/skill-retriever.d.ts +84 -0
  51. package/dist/extensions/forgecli/skill-retriever.js +246 -0
  52. package/dist/extensions/forgecli/skill-retriever.js.map +1 -0
  53. package/dist/extensions/forgecli/skill-usage-tracker.d.ts +91 -0
  54. package/dist/extensions/forgecli/skill-usage-tracker.js +224 -0
  55. package/dist/extensions/forgecli/skill-usage-tracker.js.map +1 -0
  56. package/dist/extensions/forgecli/store-resolver.d.ts +18 -0
  57. package/dist/extensions/forgecli/store-resolver.js +44 -4
  58. package/dist/extensions/forgecli/store-resolver.js.map +1 -1
  59. package/dist/extensions/forgecli/store-validator.d.ts +3 -0
  60. package/dist/extensions/forgecli/store-validator.js +4 -2
  61. package/dist/extensions/forgecli/store-validator.js.map +1 -1
  62. package/dist/forge-payload/.base-pack/personas/supervisor.md +9 -0
  63. package/dist/forge-payload/.base-pack/workflows/enhance.md +344 -18
  64. package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
  65. package/dist/forge-payload/.schemas/event.schema.json +20 -2
  66. package/dist/forge-payload/.schemas/migrations.json +112 -0
  67. package/dist/forge-payload/.schemas/proposal.schema.json +40 -0
  68. package/dist/forge-payload/agents/store-query-validator.md +103 -0
  69. package/dist/forge-payload/agents/tomoshibi.md +185 -0
  70. package/dist/forge-payload/commands/regenerate.md +109 -20
  71. package/dist/forge-payload/hooks/check-update.js +378 -0
  72. package/dist/forge-payload/hooks/forge-permissions.js +158 -0
  73. package/dist/forge-payload/hooks/triage-error.js +71 -0
  74. package/dist/forge-payload/hooks/validate-write.js +236 -0
  75. package/dist/forge-payload/integrity.json +32 -0
  76. package/dist/forge-payload/meta/workflows/meta-enhance.md +344 -18
  77. package/dist/forge-payload/schemas/structure-manifest.json +511 -0
  78. package/dist/forge-payload/tools/build-persona-pack.cjs +120 -11
  79. package/dist/forge-payload/tools/compression-gate.cjs +192 -0
  80. package/dist/forge-payload/tools/delete-candidate-detector.cjs +114 -0
  81. package/dist/forge-payload/tools/judge-proposal.cjs +177 -0
  82. package/dist/forge-payload/tools/manage-versions.cjs +132 -4
  83. package/dist/forge-payload/tools/queue-drain.cjs +152 -0
  84. package/dist/forge-payload/tools/replay-scoring.cjs +117 -0
  85. package/node_modules/@mariozechner/clipboard/package.json +2 -1
  86. package/node_modules/@mariozechner/clipboard-linux-x64-musl/README.md +3 -0
  87. package/node_modules/@mariozechner/clipboard-linux-x64-musl/clipboard.linux-x64-musl.node +0 -0
  88. package/node_modules/@mariozechner/clipboard-linux-x64-musl/package.json +25 -0
  89. package/package.json +4 -2
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+ // FORGE-S24-T06 — compression gate (reject >20% growth without 3+ frictions).
3
+ //
4
+ // Phase 2 of meta-enhance runs this gate BEFORE the LLM judge (T03). The gate
5
+ // is a cheap deterministic filter: an `update_skill` proposal that grows the
6
+ // target file by more than 20% (byte-wise) must be backed by at least 3
7
+ // supporting friction events. Insufficient support → reject; the judge never
8
+ // sees the proposal.
9
+ //
10
+ // Why a cheap pre-judge gate?
11
+ // - Judging is expensive (LLM call).
12
+ // - Unbounded growth of a skill body is the classic SkillOS failure mode —
13
+ // adding pages of trajectory copy-paste to "patch" a friction. Cheap to
14
+ // detect deterministically; wasteful to ask the judge to rule on.
15
+ //
16
+ // Op semantics:
17
+ // - `insert_skill`: not gated — the file doesn't exist yet, "growth" is
18
+ // undefined; the judge's body_under_2kb axis handles bloat instead.
19
+ // - `delete_skill`: not gated — deletion always shrinks.
20
+ // - `update_skill`: GATED. Growth is measured byte-wise (UTF-8) on the new
21
+ // body that would land after applying the diff. The caller resolves the
22
+ // "currentBody" and "newBody" by reading the file and applying the patch.
23
+ //
24
+ // Friction support:
25
+ // - Default: `proposal.sourceFrictionIds.length`.
26
+ // - Override: caller may supply `supportingFrictionCountFor(proposal) -> int`
27
+ // when the policy is "count frictions citing the same skill across the
28
+ // sprint" rather than "count citations on the proposal itself".
29
+ //
30
+ // Pure module: no fs access, no LLM call. Consumed by Phase 2 between
31
+ // step 5b (delete-candidate detection) and step 5c (LLM-judge gate).
32
+ //
33
+ // Exports:
34
+ // GROWTH_THRESHOLD — 0.20 (strict >, ties admit).
35
+ // MIN_SUPPORTING_FRICTIONS — 3.
36
+ // evaluateGrowth({ currentBody, newBody })
37
+ // -> { currentBytes, newBytes, growthRatio }
38
+ // evaluateProposal({ proposal, currentBody, newBody, supportingFrictionCount })
39
+ // -> { admit, reason, growthRatio, currentBytes, newBytes,
40
+ // supportingFrictionCount, threshold, minSupportingFrictions, op }
41
+ // filterProposals({ proposals, currentBodyFor, newBodyFor,
42
+ // supportingFrictionCountFor })
43
+ // -> { admitted: proposal[], rejected: { proposal, ...evaluation }[] }
44
+
45
+ const GROWTH_THRESHOLD = 0.20;
46
+ const MIN_SUPPORTING_FRICTIONS = 3;
47
+
48
+ const VALID_OPS = new Set(['insert_skill', 'update_skill', 'delete_skill']);
49
+
50
+ function evaluateGrowth({ currentBody, newBody }) {
51
+ if (typeof currentBody !== 'string') {
52
+ throw new TypeError('currentBody must be a string');
53
+ }
54
+ if (typeof newBody !== 'string') {
55
+ throw new TypeError('newBody must be a string');
56
+ }
57
+ const currentBytes = Buffer.byteLength(currentBody, 'utf8');
58
+ const newBytes = Buffer.byteLength(newBody, 'utf8');
59
+ // When currentBytes === 0 the ratio is undefined for update; we return
60
+ // Infinity and let the caller decide. evaluateProposal treats Infinity as
61
+ // "over threshold" — an update on an empty file is, by definition,
62
+ // unbounded growth — so the friction-support gate still applies.
63
+ const growthRatio = currentBytes === 0
64
+ ? (newBytes === 0 ? 0 : Infinity)
65
+ : (newBytes - currentBytes) / currentBytes;
66
+ return { currentBytes, newBytes, growthRatio };
67
+ }
68
+
69
+ function evaluateProposal({
70
+ proposal,
71
+ currentBody,
72
+ newBody,
73
+ supportingFrictionCount,
74
+ }) {
75
+ if (!proposal || typeof proposal !== 'object') {
76
+ throw new TypeError('proposal must be an object');
77
+ }
78
+ if (typeof proposal.op !== 'string' || !VALID_OPS.has(proposal.op)) {
79
+ throw new TypeError(
80
+ `proposal.op must be one of ${Array.from(VALID_OPS).join(', ')}; got ${JSON.stringify(proposal.op)}`,
81
+ );
82
+ }
83
+ if (typeof currentBody !== 'string') {
84
+ throw new TypeError('currentBody must be a string');
85
+ }
86
+ if (typeof newBody !== 'string') {
87
+ throw new TypeError('newBody must be a string');
88
+ }
89
+
90
+ // Resolve supporting friction count — explicit > proposal.sourceFrictionIds.
91
+ let frictionCount;
92
+ if (supportingFrictionCount === undefined) {
93
+ frictionCount = Array.isArray(proposal.sourceFrictionIds)
94
+ ? proposal.sourceFrictionIds.length
95
+ : 0;
96
+ } else {
97
+ if (!Number.isInteger(supportingFrictionCount) || supportingFrictionCount < 0) {
98
+ throw new TypeError(
99
+ 'supportingFrictionCount must be a non-negative integer',
100
+ );
101
+ }
102
+ frictionCount = supportingFrictionCount;
103
+ }
104
+
105
+ const { currentBytes, newBytes, growthRatio } = evaluateGrowth({
106
+ currentBody, newBody,
107
+ });
108
+
109
+ const base = {
110
+ growthRatio,
111
+ currentBytes,
112
+ newBytes,
113
+ supportingFrictionCount: frictionCount,
114
+ threshold: GROWTH_THRESHOLD,
115
+ minSupportingFrictions: MIN_SUPPORTING_FRICTIONS,
116
+ op: proposal.op,
117
+ };
118
+
119
+ // Non-update ops are not gated.
120
+ if (proposal.op !== 'update_skill') {
121
+ return { admit: true, reason: 'op_not_gated', ...base };
122
+ }
123
+
124
+ // Strict >: growth exactly at threshold admits.
125
+ if (!(growthRatio > GROWTH_THRESHOLD)) {
126
+ return { admit: true, reason: 'admitted_below_threshold', ...base };
127
+ }
128
+
129
+ // Over threshold — admit only if enough friction support.
130
+ if (frictionCount >= MIN_SUPPORTING_FRICTIONS) {
131
+ return { admit: true, reason: 'admitted_with_friction_support', ...base };
132
+ }
133
+
134
+ return { admit: false, reason: 'compression_gate_growth_unsupported', ...base };
135
+ }
136
+
137
+ function filterProposals({
138
+ proposals,
139
+ currentBodyFor,
140
+ newBodyFor,
141
+ supportingFrictionCountFor,
142
+ }) {
143
+ if (!Array.isArray(proposals)) {
144
+ throw new TypeError('proposals must be an array');
145
+ }
146
+ if (typeof currentBodyFor !== 'function') {
147
+ throw new TypeError('currentBodyFor must be a function');
148
+ }
149
+ if (typeof newBodyFor !== 'function') {
150
+ throw new TypeError('newBodyFor must be a function');
151
+ }
152
+ if (
153
+ supportingFrictionCountFor !== undefined &&
154
+ typeof supportingFrictionCountFor !== 'function'
155
+ ) {
156
+ throw new TypeError('supportingFrictionCountFor must be a function when provided');
157
+ }
158
+
159
+ const admitted = [];
160
+ const rejected = [];
161
+
162
+ for (const proposal of proposals) {
163
+ const currentBody = currentBodyFor(proposal);
164
+ const newBody = newBodyFor(proposal);
165
+ const supportingFrictionCount = supportingFrictionCountFor
166
+ ? supportingFrictionCountFor(proposal)
167
+ : undefined;
168
+
169
+ const evaluation = evaluateProposal({
170
+ proposal,
171
+ currentBody,
172
+ newBody,
173
+ supportingFrictionCount,
174
+ });
175
+
176
+ if (evaluation.admit) {
177
+ admitted.push(proposal);
178
+ } else {
179
+ rejected.push({ proposal, ...evaluation });
180
+ }
181
+ }
182
+
183
+ return { admitted, rejected };
184
+ }
185
+
186
+ module.exports = {
187
+ GROWTH_THRESHOLD,
188
+ MIN_SUPPORTING_FRICTIONS,
189
+ evaluateGrowth,
190
+ evaluateProposal,
191
+ filterProposals,
192
+ };
@@ -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
  }