@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.
- package/CHANGELOG.md +324 -0
- package/README.md +2 -1
- package/dist/CHANGELOG-forge-plugin.md +210 -0
- package/dist/bin/forge.js +20 -1
- package/dist/bin/forge.js.map +1 -1
- package/dist/extensions/forgecli/ask-user-tool.js +32 -20
- package/dist/extensions/forgecli/ask-user-tool.js.map +1 -1
- package/dist/extensions/forgecli/config-layer.d.ts +15 -0
- package/dist/extensions/forgecli/config-layer.js +4 -1
- package/dist/extensions/forgecli/config-layer.js.map +1 -1
- package/dist/extensions/forgecli/config-writer.js +4 -1
- package/dist/extensions/forgecli/config-writer.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/fix-bug.js +31 -1
- package/dist/extensions/forgecli/fix-bug.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/health-check.d.ts +10 -0
- package/dist/extensions/forgecli/health-check.js +160 -8
- package/dist/extensions/forgecli/health-check.js.map +1 -1
- package/dist/extensions/forgecli/hook-dispatcher.js +24 -2
- package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
- package/dist/extensions/forgecli/hooks/write-guard.js +5 -1
- package/dist/extensions/forgecli/hooks/write-guard.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/lib/store-error-remediation.d.ts +65 -0
- package/dist/extensions/forgecli/lib/store-error-remediation.js +298 -0
- package/dist/extensions/forgecli/lib/store-error-remediation.js.map +1 -0
- 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/run-sprint.js +16 -1
- package/dist/extensions/forgecli/run-sprint.js.map +1 -1
- package/dist/extensions/forgecli/run-task.js +30 -8
- package/dist/extensions/forgecli/run-task.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/extensions/forgecli/store-resolver.d.ts +18 -0
- package/dist/extensions/forgecli/store-resolver.js +44 -4
- package/dist/extensions/forgecli/store-resolver.js.map +1 -1
- package/dist/extensions/forgecli/store-validator.d.ts +3 -0
- package/dist/extensions/forgecli/store-validator.js +4 -2
- package/dist/extensions/forgecli/store-validator.js.map +1 -1
- package/dist/forge-payload/.base-pack/personas/supervisor.md +9 -0
- package/dist/forge-payload/.base-pack/workflows/enhance.md +344 -18
- 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 +112 -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 +344 -18
- package/dist/forge-payload/schemas/structure-manifest.json +511 -0
- package/dist/forge-payload/tools/build-persona-pack.cjs +120 -11
- 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,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
|
|
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
|
}
|