@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.
- package/CHANGELOG.md +314 -0
- package/README.md +2 -1
- package/dist/CHANGELOG-forge-plugin.md +183 -0
- package/dist/bin/forge.js +20 -1
- package/dist/bin/forge.js.map +1 -1
- package/dist/extensions/forgecli/config-layer.d.ts +15 -0
- package/dist/extensions/forgecli/config-layer.js.map +1 -1
- package/dist/extensions/forgecli/enhance.js +1 -1
- package/dist/extensions/forgecli/enhance.js.map +1 -1
- package/dist/extensions/forgecli/forge-cli-schema.json +19 -0
- package/dist/extensions/forgecli/forge-tools.js +80 -0
- package/dist/extensions/forgecli/forge-tools.js.map +1 -1
- package/dist/extensions/forgecli/forge-update-command.js +24 -18
- package/dist/extensions/forgecli/forge-update-command.js.map +1 -1
- package/dist/extensions/forgecli/friction-emit.d.ts +97 -0
- package/dist/extensions/forgecli/friction-emit.js +246 -0
- package/dist/extensions/forgecli/friction-emit.js.map +1 -0
- package/dist/extensions/forgecli/hook-dispatcher.js +20 -0
- package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
- package/dist/extensions/forgecli/index.js +29 -5
- package/dist/extensions/forgecli/index.js.map +1 -1
- package/dist/extensions/forgecli/regenerate.d.ts +22 -0
- package/dist/extensions/forgecli/regenerate.js +133 -3
- package/dist/extensions/forgecli/regenerate.js.map +1 -1
- package/dist/extensions/forgecli/skill-curation-flag.d.ts +21 -0
- package/dist/extensions/forgecli/skill-curation-flag.js +71 -0
- package/dist/extensions/forgecli/skill-curation-flag.js.map +1 -0
- package/dist/extensions/forgecli/skill-curator-subagent.d.ts +101 -0
- package/dist/extensions/forgecli/skill-curator-subagent.js +342 -0
- package/dist/extensions/forgecli/skill-curator-subagent.js.map +1 -0
- package/dist/extensions/forgecli/skill-retriever.d.ts +84 -0
- package/dist/extensions/forgecli/skill-retriever.js +246 -0
- package/dist/extensions/forgecli/skill-retriever.js.map +1 -0
- package/dist/extensions/forgecli/skill-usage-tracker.d.ts +91 -0
- package/dist/extensions/forgecli/skill-usage-tracker.js +224 -0
- package/dist/extensions/forgecli/skill-usage-tracker.js.map +1 -0
- package/dist/forge-payload/.base-pack/workflows/enhance.md +331 -11
- package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
- package/dist/forge-payload/.schemas/event.schema.json +20 -2
- package/dist/forge-payload/.schemas/migrations.json +96 -0
- package/dist/forge-payload/.schemas/proposal.schema.json +40 -0
- package/dist/forge-payload/agents/store-query-validator.md +103 -0
- package/dist/forge-payload/agents/tomoshibi.md +185 -0
- package/dist/forge-payload/commands/regenerate.md +109 -20
- package/dist/forge-payload/hooks/check-update.js +378 -0
- package/dist/forge-payload/hooks/forge-permissions.js +158 -0
- package/dist/forge-payload/hooks/triage-error.js +71 -0
- package/dist/forge-payload/hooks/validate-write.js +236 -0
- package/dist/forge-payload/integrity.json +32 -0
- package/dist/forge-payload/meta/workflows/meta-enhance.md +331 -11
- package/dist/forge-payload/schemas/structure-manifest.json +511 -0
- package/dist/forge-payload/tools/compression-gate.cjs +192 -0
- package/dist/forge-payload/tools/delete-candidate-detector.cjs +114 -0
- package/dist/forge-payload/tools/judge-proposal.cjs +177 -0
- package/dist/forge-payload/tools/manage-versions.cjs +132 -4
- package/dist/forge-payload/tools/queue-drain.cjs +152 -0
- package/dist/forge-payload/tools/replay-scoring.cjs +117 -0
- package/node_modules/@mariozechner/clipboard/package.json +2 -1
- package/node_modules/@mariozechner/clipboard-linux-x64-musl/README.md +3 -0
- package/node_modules/@mariozechner/clipboard-linux-x64-musl/clipboard.linux-x64-musl.node +0 -0
- package/node_modules/@mariozechner/clipboard-linux-x64-musl/package.json +25 -0
- 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
|
|
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
|
|
196
|
-
const
|
|
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
|
+
};
|