@entelligentsia/forgecli 0.11.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +314 -0
  2. package/README.md +2 -1
  3. package/dist/CHANGELOG-forge-plugin.md +183 -0
  4. package/dist/bin/forge.js +20 -1
  5. package/dist/bin/forge.js.map +1 -1
  6. package/dist/extensions/forgecli/config-layer.d.ts +15 -0
  7. package/dist/extensions/forgecli/config-layer.js.map +1 -1
  8. package/dist/extensions/forgecli/enhance.js +1 -1
  9. package/dist/extensions/forgecli/enhance.js.map +1 -1
  10. package/dist/extensions/forgecli/forge-cli-schema.json +19 -0
  11. package/dist/extensions/forgecli/forge-tools.js +80 -0
  12. package/dist/extensions/forgecli/forge-tools.js.map +1 -1
  13. package/dist/extensions/forgecli/forge-update-command.js +24 -18
  14. package/dist/extensions/forgecli/forge-update-command.js.map +1 -1
  15. package/dist/extensions/forgecli/friction-emit.d.ts +97 -0
  16. package/dist/extensions/forgecli/friction-emit.js +246 -0
  17. package/dist/extensions/forgecli/friction-emit.js.map +1 -0
  18. package/dist/extensions/forgecli/hook-dispatcher.js +20 -0
  19. package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
  20. package/dist/extensions/forgecli/index.js +29 -5
  21. package/dist/extensions/forgecli/index.js.map +1 -1
  22. package/dist/extensions/forgecli/regenerate.d.ts +22 -0
  23. package/dist/extensions/forgecli/regenerate.js +133 -3
  24. package/dist/extensions/forgecli/regenerate.js.map +1 -1
  25. package/dist/extensions/forgecli/skill-curation-flag.d.ts +21 -0
  26. package/dist/extensions/forgecli/skill-curation-flag.js +71 -0
  27. package/dist/extensions/forgecli/skill-curation-flag.js.map +1 -0
  28. package/dist/extensions/forgecli/skill-curator-subagent.d.ts +101 -0
  29. package/dist/extensions/forgecli/skill-curator-subagent.js +342 -0
  30. package/dist/extensions/forgecli/skill-curator-subagent.js.map +1 -0
  31. package/dist/extensions/forgecli/skill-retriever.d.ts +84 -0
  32. package/dist/extensions/forgecli/skill-retriever.js +246 -0
  33. package/dist/extensions/forgecli/skill-retriever.js.map +1 -0
  34. package/dist/extensions/forgecli/skill-usage-tracker.d.ts +91 -0
  35. package/dist/extensions/forgecli/skill-usage-tracker.js +224 -0
  36. package/dist/extensions/forgecli/skill-usage-tracker.js.map +1 -0
  37. package/dist/forge-payload/.base-pack/workflows/enhance.md +331 -11
  38. package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
  39. package/dist/forge-payload/.schemas/event.schema.json +20 -2
  40. package/dist/forge-payload/.schemas/migrations.json +96 -0
  41. package/dist/forge-payload/.schemas/proposal.schema.json +40 -0
  42. package/dist/forge-payload/agents/store-query-validator.md +103 -0
  43. package/dist/forge-payload/agents/tomoshibi.md +185 -0
  44. package/dist/forge-payload/commands/regenerate.md +109 -20
  45. package/dist/forge-payload/hooks/check-update.js +378 -0
  46. package/dist/forge-payload/hooks/forge-permissions.js +158 -0
  47. package/dist/forge-payload/hooks/triage-error.js +71 -0
  48. package/dist/forge-payload/hooks/validate-write.js +236 -0
  49. package/dist/forge-payload/integrity.json +32 -0
  50. package/dist/forge-payload/meta/workflows/meta-enhance.md +331 -11
  51. package/dist/forge-payload/schemas/structure-manifest.json +511 -0
  52. package/dist/forge-payload/tools/compression-gate.cjs +192 -0
  53. package/dist/forge-payload/tools/delete-candidate-detector.cjs +114 -0
  54. package/dist/forge-payload/tools/judge-proposal.cjs +177 -0
  55. package/dist/forge-payload/tools/manage-versions.cjs +132 -4
  56. package/dist/forge-payload/tools/queue-drain.cjs +152 -0
  57. package/dist/forge-payload/tools/replay-scoring.cjs +117 -0
  58. package/node_modules/@mariozechner/clipboard/package.json +2 -1
  59. package/node_modules/@mariozechner/clipboard-linux-x64-musl/README.md +3 -0
  60. package/node_modules/@mariozechner/clipboard-linux-x64-musl/clipboard.linux-x64-musl.node +0 -0
  61. package/node_modules/@mariozechner/clipboard-linux-x64-musl/package.json +25 -0
  62. package/package.json +4 -2
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ // Forge write-boundary hook — runs PreToolUse on Write / Edit / MultiEdit.
3
+ //
4
+ // Purpose: enforce Forge schemas at the filesystem boundary so agents remain
5
+ // free to bypass deterministic tools (store-cli), as long as any write they
6
+ // do against Forge-owned paths honors the schema contract.
7
+ //
8
+ // Protocol (Claude Code PreToolUse hook):
9
+ // - stdin: JSON envelope { tool_name, tool_input, ... }
10
+ // - exit 0: allow the tool call
11
+ // - exit 2 with stderr payload: block the tool call and surface the message
12
+ //
13
+ // Fail-open philosophy: any internal error (unreadable schema, parse bug,
14
+ // unexpected tool input shape) exits 0 with a stderr warning. A broken
15
+ // validator must never block legitimate work — validate-store.cjs remains
16
+ // as a post-hoc auditor.
17
+
18
+ 'use strict';
19
+
20
+ process.on('uncaughtException', (err) => {
21
+ try { process.stderr.write(`forge validate-write: internal error (fail-open): ${err.message}\n`); } catch (_) {}
22
+ process.exit(0);
23
+ });
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ const { matchRegistry } = require('./lib/write-registry.js');
29
+
30
+ // Schema resolution order mirrors store-cli.cjs so the hook sees the exact
31
+ // same schemas as tool writes do.
32
+ const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || path.join(__dirname, '..');
33
+
34
+ function resolveValidator() {
35
+ // store-cli's shared validator lives at forge/tools/lib/validate.js. Require
36
+ // it relative to the plugin root so the hook works from both dev tree and
37
+ // installed plugin cache.
38
+ const candidates = [
39
+ path.join(PLUGIN_ROOT, 'tools', 'lib', 'validate.js'),
40
+ path.join(__dirname, '..', 'tools', 'lib', 'validate.js'),
41
+ ];
42
+ for (const c of candidates) {
43
+ if (fs.existsSync(c)) return require(c);
44
+ }
45
+ throw new Error(`validate.js not found (looked in: ${candidates.join(', ')})`);
46
+ }
47
+
48
+ function loadSchema(filename) {
49
+ const candidates = [
50
+ path.join(process.cwd(), '.forge', 'schemas', filename),
51
+ path.join(process.cwd(), 'forge', 'schemas', filename),
52
+ path.join(PLUGIN_ROOT, 'schemas', filename),
53
+ path.join(__dirname, '..', 'schemas', filename),
54
+ ];
55
+ for (const c of candidates) {
56
+ if (fs.existsSync(c)) {
57
+ return JSON.parse(fs.readFileSync(c, 'utf8'));
58
+ }
59
+ }
60
+ throw new Error(`schema not found: ${filename}`);
61
+ }
62
+
63
+ function readStdinSync() {
64
+ try {
65
+ return fs.readFileSync(0, 'utf8');
66
+ } catch (_) {
67
+ return '';
68
+ }
69
+ }
70
+
71
+ // Apply Edit semantics: replace old_string with new_string in contents.
72
+ // If replace_all is true, replace every occurrence; otherwise replace the
73
+ // first (and only) occurrence. Errors if old_string is absent or ambiguous
74
+ // when replace_all is false — mirrors the Edit tool contract.
75
+ function applyEdit(contents, oldStr, newStr, replaceAll) {
76
+ if (oldStr === '' && contents === '') return newStr; // new-file Edit
77
+ if (oldStr === '') throw new Error('Edit: old_string is empty');
78
+ if (replaceAll) return contents.split(oldStr).join(newStr);
79
+ const idx = contents.indexOf(oldStr);
80
+ if (idx === -1) throw new Error('Edit: old_string not found in file');
81
+ const next = contents.indexOf(oldStr, idx + oldStr.length);
82
+ if (next !== -1) throw new Error('Edit: old_string is ambiguous (appears more than once)');
83
+ return contents.slice(0, idx) + newStr + contents.slice(idx + oldStr.length);
84
+ }
85
+
86
+ function computePostEditContents(toolName, toolInput) {
87
+ const filePath = toolInput.file_path;
88
+ if (toolName === 'Write') {
89
+ return { filePath, contents: toolInput.content != null ? String(toolInput.content) : '' };
90
+ }
91
+ const prior = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
92
+ if (toolName === 'Edit') {
93
+ return {
94
+ filePath,
95
+ contents: applyEdit(prior, toolInput.old_string || '', toolInput.new_string || '', !!toolInput.replace_all),
96
+ };
97
+ }
98
+ // MultiEdit
99
+ let cur = prior;
100
+ const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
101
+ for (const e of edits) {
102
+ cur = applyEdit(cur, e.old_string || '', e.new_string || '', !!e.replace_all);
103
+ }
104
+ return { filePath, contents: cur, prior };
105
+ }
106
+
107
+ function parseProgressLine(line) {
108
+ // timestamp|agentName|bannerKey|status|detail
109
+ const parts = line.split('|');
110
+ if (parts.length < 4) return null;
111
+ return {
112
+ timestamp: parts[0],
113
+ agentName: parts[1],
114
+ bannerKey: parts[2],
115
+ status: parts[3],
116
+ detail: parts.slice(4).join('|'),
117
+ };
118
+ }
119
+
120
+ function validateProgressAppend(prior, proposed, validator, schema) {
121
+ // Only validate lines that are NEW — anything already on disk is grandfathered.
122
+ if (!proposed.startsWith(prior)) {
123
+ // Wholesale rewrite, not an append. Validate every non-empty line.
124
+ const lines = proposed.split('\n').filter(l => l.length > 0);
125
+ return lines.flatMap((l, i) => annotateLine(validator, schema, l, i));
126
+ }
127
+ const suffix = proposed.slice(prior.length);
128
+ const newLines = suffix.split('\n').filter(l => l.length > 0);
129
+ return newLines.flatMap((l, i) => annotateLine(validator, schema, l, i));
130
+ }
131
+
132
+ function annotateLine(validator, schema, line, idx) {
133
+ const rec = parseProgressLine(line);
134
+ if (!rec) return [`line ${idx + 1}: malformed (expected 4+ pipe-delimited fields)`];
135
+ const errors = validator.validateRecord(rec, schema);
136
+ return errors.map(e => `line ${idx + 1}: ${e}`);
137
+ }
138
+
139
+ function writeBypassAudit(filePath, reason) {
140
+ try {
141
+ const m = /\/\.forge\/store\/events\/([^/]+)\//.exec(filePath) || /\.forge\/store\/events\/([^/]+)\//.exec(filePath);
142
+ const bucket = m ? m[1] : 'unknown';
143
+ const logPath = path.join(process.cwd(), '.forge', 'store', 'events', bucket, 'progress.log');
144
+ if (!fs.existsSync(path.dirname(logPath))) return;
145
+ const ts = new Date().toISOString();
146
+ const line = `${ts}|forge-hook|write-boundary|progress|${reason}\n`;
147
+ fs.appendFileSync(logPath, line, 'utf8');
148
+ } catch (_) { /* audit best-effort */ }
149
+ }
150
+
151
+ function block(message) {
152
+ process.stderr.write(message + '\n');
153
+ process.exit(2);
154
+ }
155
+
156
+ function main() {
157
+ const raw = readStdinSync();
158
+ if (!raw) process.exit(0);
159
+
160
+ let envelope;
161
+ try { envelope = JSON.parse(raw); } catch (_) { process.exit(0); }
162
+
163
+ const toolName = envelope.tool_name;
164
+ if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) process.exit(0);
165
+
166
+ const toolInput = envelope.tool_input || {};
167
+ const filePath = toolInput.file_path;
168
+ if (!filePath || typeof filePath !== 'string') process.exit(0);
169
+
170
+ const entry = matchRegistry(filePath);
171
+ if (!entry) process.exit(0);
172
+
173
+ if (process.env.FORGE_SKIP_WRITE_VALIDATION === '1') {
174
+ writeBypassAudit(filePath, `FORGE_SKIP_WRITE_VALIDATION=1 bypass on ${toolName} ${path.relative(process.cwd(), filePath)}`);
175
+ process.exit(0);
176
+ }
177
+
178
+ let validator, schema, post;
179
+ try {
180
+ validator = resolveValidator();
181
+ schema = loadSchema(entry.schema);
182
+ post = computePostEditContents(toolName, toolInput);
183
+ } catch (err) {
184
+ process.stderr.write(`forge validate-write: setup error (fail-open): ${err.message}\n`);
185
+ process.exit(0);
186
+ }
187
+
188
+ const relPath = path.relative(process.cwd(), post.filePath);
189
+
190
+ if (entry.format === 'line-pipe-delimited') {
191
+ const prior = post.prior != null ? post.prior : (fs.existsSync(post.filePath) ? fs.readFileSync(post.filePath, 'utf8') : '');
192
+ const errs = validateProgressAppend(prior, post.contents, validator, schema);
193
+ if (errs.length > 0) {
194
+ block(
195
+ '❌ Forge schema violation — write blocked\n' +
196
+ `Path: ${relPath}\n` +
197
+ `Kind: ${entry.kind}\n` +
198
+ `Violations:\n - ${errs.join('\n - ')}\n` +
199
+ `Hint: progress.log lines must be "timestamp|agentName|bannerKey|status|detail"; see forge/schemas/${entry.schema}.\n` +
200
+ 'To bypass for one turn (emergency repair): FORGE_SKIP_WRITE_VALIDATION=1.'
201
+ );
202
+ }
203
+ process.exit(0);
204
+ }
205
+
206
+ // JSON payloads
207
+ let parsed;
208
+ try {
209
+ parsed = JSON.parse(post.contents);
210
+ } catch (err) {
211
+ block(
212
+ '❌ Forge schema violation — write blocked\n' +
213
+ `Path: ${relPath}\n` +
214
+ `Kind: ${entry.kind}\n` +
215
+ `Violation: Invalid JSON: ${err.message}\n` +
216
+ `Hint: see forge/schemas/${entry.schema} for the expected shape.\n` +
217
+ 'To bypass for one turn (emergency repair): FORGE_SKIP_WRITE_VALIDATION=1.'
218
+ );
219
+ }
220
+
221
+ const errs = validator.validateRecord(parsed, schema);
222
+ if (errs.length > 0) {
223
+ block(
224
+ '❌ Forge schema violation — write blocked\n' +
225
+ `Path: ${relPath}\n` +
226
+ `Kind: ${entry.kind}\n` +
227
+ `Violations:\n - ${errs.join('\n - ')}\n` +
228
+ `Hint: see forge/schemas/${entry.schema} for the full shape.\n` +
229
+ 'To bypass for one turn (emergency repair): FORGE_SKIP_WRITE_VALIDATION=1.'
230
+ );
231
+ }
232
+
233
+ process.exit(0);
234
+ }
235
+
236
+ main();
@@ -0,0 +1,32 @@
1
+ {
2
+ "version": "0.46.1",
3
+ "generated": "2026-05-22",
4
+ "note": "Tamper-evident only. Authoritative source: /forge:update from remote.",
5
+ "files": {
6
+ "commands/add-pipeline.md": "402c1deb6f19ad99561b48548ad89e3e1f625b827dce633eb84317556c0a1a4a",
7
+ "commands/add-task.md": "b2640c4b3c8c4fb9d4a82633d8c4d8fcd13bd63d4d6deb2c5c6a2008508de770",
8
+ "commands/ask.md": "df15f0aa68c93d65bfef05adb514c377bfc65617cafcd79534eb9b4149302657",
9
+ "commands/calibrate.md": "c764e735c9212eaf785d3d0afaf926f536fef099421b2b1bde7a4b1e3114d443",
10
+ "commands/config.md": "67251316b806d706bb77f063128e29995c6b4abaf554f4d5558bd97a1a6cfcfe",
11
+ "commands/enhance.md": "4bbfc05cf85c862c49883bc36f1272f87cd49c8cbe00f9196e76d542e6eaa720",
12
+ "commands/health.md": "cf8a59cfb403ba268922bc60abcdd9255a3b5690ae0175bb2819b3f6789debc0",
13
+ "commands/init.md": "3c0522294c692e8fbb75f925370caee66f54b23d7dd3a692bedcd69f95718b28",
14
+ "commands/materialize.md": "195292c9e98f50773c1b0c84f9bbc42438c4c8c989a84bd98092f6692799d6d6",
15
+ "commands/migrate.md": "b226f10a6f202bfd4a840314f34d503da34a077abbf1dff8d2b40f864474a76b",
16
+ "commands/quiz-agent.md": "ed261a5c8ac7cd3a1d7c8b372c9bf099bba9a4f9dc0a41cb5c80f22b57a133a1",
17
+ "commands/regenerate.md": "7c14f87b1bb178dd98ebdf0efafb86792c2b8b079bc5052d02a7e618165ec525",
18
+ "commands/remove.md": "de8802ee8ad5db4c4f3d0f526eb8735ab7de1a4b8ad307355d11dac5e1e04fc6",
19
+ "commands/report-bug.md": "af8a54bf8887b35e5c880898dd45783f6c2e80d3dc031d6479a6be613ac43053",
20
+ "commands/store-query.md": "28925bd257ceb6645254628abf0e76524481460382192ea00081b17310b88fed",
21
+ "commands/store-repair.md": "9317be65deb400953b8642b4d353d5583d3b032546c0b8f73d6c3a9b3445ebdd",
22
+ "commands/update-tools.md": "768c0d7ec07a17055c3d4b1b31370812f4292e03b1496723ba36f8caf596a609",
23
+ "commands/update.md": "6f7be76884888b0cbbe5e60a6926bbaa368f2ee3191f2be40adc8d263ad414aa",
24
+ "agents/store-query-validator.md": "f4c3573edcf6e28809515705362df611806a805c5269404fb17e31433cf3a81c",
25
+ "agents/tomoshibi.md": "0112604af0856235d7f028683dbfa3f4af63355cb2dd79d26592e983a6ecec8b",
26
+ "hooks/check-update.js": "75a70718d088b7c92ac0343908d07c2eb3bb334c427947490af1d775790859e1",
27
+ "hooks/forge-permissions.js": "d72fe4a7f010d363798680fa6afeb004f8747e2479ebdf653bc2200c9be073dc",
28
+ "hooks/triage-error.js": "b1e9c1c9a9f3e4868fff295f2caf87a2a72a817ee767af35f6334c88e45e16d4",
29
+ "hooks/validate-write.js": "bf17f740ae43d85a7f4b18bc08e2628534c3698ec5202cb51ebd3f0792375f24",
30
+ "tools/verify-integrity.cjs": "3ec3c970dd3d7c3001f8f373bcc40556803eadd2fc2afafb14f1c232cba4cc3f"
31
+ }
32
+ }
@@ -50,6 +50,40 @@ Phases 2 and 3 write proposal artifacts to `.forge/enhancement-proposals/`. This
50
50
  distinct from `.forge/enhancements/` (FR-007, S14 scope). This workflow uses `mkdir -p` before
51
51
  writing the first proposal artifact to avoid assuming the directory exists. No conflict with S14.
52
52
 
53
+ ### Sub-directory: `.forge/enhancement-proposals/queue/`
54
+
55
+ FORGE-S24-T07 introduces a project-local **enhancement queue** at
56
+ `.forge/enhancement-proposals/queue/<sprintId>/<taskId>-<ts>.json` — one file per
57
+ per-task curator run (T10). The queue is **append-only**: each curator run writes
58
+ a fresh file (the ISO compact `<ts>` suffix differentiates writes; nothing is
59
+ overwritten). Phase 2 drains the queue at sprint close, dedupes by
60
+ `{op, target_path, sha256(diff_body)}`, and feeds the merged batch into the
61
+ existing recurrence → delete-candidate → compression-gate → judge pipeline.
62
+ The result: **one batched review prompt per sprint, not one per task** (paper
63
+ §3.2.1 grouped reward). The drain is read-only — Phase 2 never deletes queue
64
+ files; operators triage them during retrospective if needed.
65
+
66
+ **Per-task curator (T10) write contract.** A curator MUST write via
67
+ `forge/tools/queue-drain.cjs` to preserve the append-only invariant:
68
+
69
+ ```sh
70
+ node -e "
71
+ const { appendToQueue } = require('./forge/tools/queue-drain.cjs');
72
+ appendToQueue({
73
+ queueRoot: '.forge/enhancement-proposals/queue',
74
+ sprintId: process.env.FORGE_SPRINT_ID,
75
+ taskId: process.env.FORGE_TASK_ID,
76
+ ts: new Date().toISOString().replace(/[-:]|\\.\\d{3}/g, ''),
77
+ proposals: PROPOSALS_ARRAY,
78
+ });
79
+ "
80
+ ```
81
+
82
+ `appendToQueue` throws if the exact file path already exists; curators MUST
83
+ choose a fresh `ts` per run rather than overwriting. The drain is empty-safe:
84
+ if no curator ever wrote (queue dir missing) or no files exist in the sprint
85
+ sub-dir, Phase 2 reports "no proposals" and exits cleanly (AC5).
86
+
53
87
  ## Confidence gating (Phase 1)
54
88
 
55
89
  A key substitution is **high-confidence** when there is exactly one unambiguous signal source
@@ -191,11 +225,40 @@ Invoked by T09 post-sprint hook or manually via `/forge:enhance --phase 2`.
191
225
  "
192
226
  ```
193
227
 
194
- 2. **Zero-friction guard**: If the friction event list is empty, print:
228
+ 1a. **Drain enhancement queue** (FORGE-S24-T07) read per-task curator
229
+ proposals from `.forge/enhancement-proposals/queue/<sprintId>/`, dedupe by
230
+ `{op, target_path, sha256(diff_body)}`, and produce a `queuedProposals`
231
+ array that joins the synthesised proposals from step 5. This is what makes
232
+ the review **batched** rather than per-task (paper §3.2.1):
233
+
234
+ ```sh
235
+ node -e "
236
+ const { drainQueue } = require('./forge/tools/queue-drain.cjs');
237
+ const drained = drainQueue({
238
+ queueRoot: '.forge/enhancement-proposals/queue',
239
+ sprintId: process.env.FORGE_SPRINT_ID,
240
+ });
241
+ process.stdout.write(JSON.stringify(drained));
242
+ "
243
+ ```
244
+
245
+ Contract (per `forge/tools/queue-drain.cjs`):
246
+ - Returns `{ proposals: [...], files: [...], errors: [...] }`. `proposals`
247
+ is the deduped union of every per-task curator file in the sprint
248
+ sub-dir. `files` is the lexicographic-sorted list of source paths (used
249
+ by step 6 to log provenance). `errors` carries any malformed JSON files
250
+ skipped during read — log them, do not abort.
251
+ - Empty / missing queue → empty result. The drain never throws on absent
252
+ queue dir (first-run or no curators registered yet, AC5).
253
+ - The drain is read-only. Operators are responsible for queue triage
254
+ after sprint close.
255
+
256
+ 2. **Zero-input guard**: If both the friction event list AND `queuedProposals`
257
+ are empty, print:
195
258
  ```
196
- No friction events queued for the active sprint — nothing to enhance.
259
+ No friction events or queued proposals for the active sprint — nothing to enhance.
197
260
  ```
198
- and exit Phase 2 immediately (skip steps 3–9; emit the enhancement event with `"notes": "{\"phase\":2,\"frictionCount\":0}"`). Do not create `.forge/enhancement-proposals/` when there are no proposals.
261
+ and exit Phase 2 immediately (skip steps 3–9; emit the enhancement event with `"notes": "{\"phase\":2,\"frictionCount\":0,\"queuedCount\":0}"`). Do not create `.forge/enhancement-proposals/` when there are no proposals.
199
262
 
200
263
  3. **Deduplicate** friction events by composite key `workflow + persona + issue`. Keep the most
201
264
  recent occurrence of each composite key.
@@ -204,19 +267,276 @@ Invoked by T09 post-sprint hook or manually via `/forge:enhance --phase 2`.
204
267
  `retrospective-done`), sorted by completion date. Read its task records from
205
268
  `.forge/store/tasks/` filtered by the sprint ID.
206
269
 
207
- 5. **Synthesize enrichment proposals** — for each friction event:
208
- - Identify which persona or skill file it references.
209
- - Propose a targeted addition: e.g., "architect persona lacks routing pattern knowledge —
210
- suggest adding `{{KB_PATH}}/routing.md` reference to deps.kb_docs."
211
- - For large committed file sets (> 5 files in the sprint), also check whether
212
- `engineer-skills.md` or `architect-skills.md` should reference new patterns.
270
+ 5. **Synthesize enrichment proposals** — for each friction event, classify the proposed
271
+ change into exactly one of three ops (see `forge/schemas/proposal.schema.json`):
272
+
273
+ | `op` | When to use |
274
+ |-----------------|-----------------------------------------------------------------------------|
275
+ | `insert_skill` | A new skill / persona / kb_docs reference is needed; target file does not yet carry the guidance. |
276
+ | `update_skill` | An existing skill or persona file needs revised guidance — e.g., add a routing pattern reference to `deps.kb_docs`, replace a stale instruction. |
277
+ | `delete_skill` | A skill is unused, redundant, or stale (`skill_unused` / `skill_redundant` / `skill_stale` friction subkinds); target file or section should be removed. |
278
+
279
+ For each proposal capture **at minimum** the schema-required triplet
280
+ `{op, target_path, diff_body}` plus optional `rationale` and `sourceFrictionIds`.
281
+ `sourceFrictionIds` MUST carry the `eventId` of every friction event that
282
+ contributed to the proposal — the next step depends on it to resolve the
283
+ originating task for the recurrence scan.
284
+ For large committed file sets (> 5 files in the sprint), also check whether
285
+ `engineer-skills.md` or `architect-skills.md` should be updated (`update_skill`).
286
+ The op classification is the foundation for the downstream judge (T03),
287
+ delete-candidate detection (T05), compression gate (T06), and queue drain (T07).
288
+
289
+ **Merge with queued proposals (T07).** Concatenate the synthesised
290
+ proposals built in this step with the `queuedProposals` array from
291
+ step 1a, then dedupe the combined array with the same key the drain
292
+ uses (`{op, target_path, sha256(diff_body)}`) so a friction-synthesised
293
+ proposal that happens to be byte-identical to a curator-queued one
294
+ collapses. Use:
295
+
296
+ ```sh
297
+ node -e "
298
+ const { dedupeProposals } = require('./forge/tools/queue-drain.cjs');
299
+ // synthesised = proposals built above from friction events.
300
+ // queued = drained.proposals from step 1a.
301
+ const merged = dedupeProposals(synthesised.concat(queued));
302
+ process.stdout.write(JSON.stringify(merged));
303
+ "
304
+ ```
305
+
306
+ The merged array is what feeds steps 5a (recurrence) → 5b (delete
307
+ candidates) → 5b.5 (compression gate) → 5c (judge) — a single batched
308
+ pipeline, never one per task (AC4).
309
+
310
+ 5a. **Cross-task replay scoring (recurrence boost)** — before writing the
311
+ artifact, stamp each proposal with `recurrence_count` and
312
+ `recurrence_task_ids` so the T03 judge can score "this friction recurred
313
+ across N tasks" rather than treating every signal as a singleton:
314
+
315
+ ```sh
316
+ node -e "
317
+ const { annotateProposals } = require('./forge/tools/replay-scoring.cjs');
318
+ // friction = deduped friction events from step 3, each carrying eventId,
319
+ // taskId, subkind, evidence.skillId (orchestrator-stamped).
320
+ // proposals = array built in step 5.
321
+ // taskOrder = task IDs of the most-recent sprint sorted by completion
322
+ // order — same source as step 4.
323
+ const annotated = annotateProposals(proposals, friction, taskOrder);
324
+ process.stdout.write(JSON.stringify(annotated));
325
+ "
326
+ ```
327
+
328
+ Contract (per `forge/tools/replay-scoring.cjs`):
329
+ - `recurrence_count` is the number of distinct tasks (origin task + later
330
+ tasks in `taskOrder`) whose friction events match the proposal's
331
+ originating `(subkind, evidence.skillId)` pair. Always `>= 1`.
332
+ - `recurrence_task_ids` is the `taskOrder`-sorted list of those task IDs.
333
+ - Proposals whose `sourceFrictionIds` cannot be resolved (no matching
334
+ `eventId` in the friction set, or the resolved event lacks
335
+ `subkind`/`evidence.skillId`) receive `recurrence_count: 1` and an empty
336
+ `recurrence_task_ids: []` — neutral signal, not silent failure.
337
+ - The annotator returns new proposal objects; the input array is not
338
+ mutated.
339
+
340
+ 5b. **Delete-candidate detection (3-sprint zero-use)** — scan `skill_usage`
341
+ events across the trailing 3 sprints and emit a `delete_skill` proposal
342
+ for every skill with zero retrieval AND zero invocation across the
343
+ window. This is the only mechanism by which the skill repository shrinks:
344
+
345
+ ```sh
346
+ node -e "
347
+ const { buildDeleteProposals } = require('./forge/tools/delete-candidate-detector.cjs');
348
+ // skillUsageEvents = all events with type === 'skill_usage' across the
349
+ // sprints in scope (collected via the same Step 1
350
+ // walker, filtered by type instead of friction).
351
+ // sprintOrder = sprint IDs sorted by completion order (oldest →
352
+ // newest). The detector takes the trailing windowSize
353
+ // entries.
354
+ // windowSize = 3 by default; configurable. Defined as the trailing
355
+ // N sprints of sprintOrder.
356
+ // targetPathFor = (skillId) => the on-disk path of the skill file to
357
+ // delete. Workflow chooses the mapping convention.
358
+ const deletes = buildDeleteProposals({
359
+ events: skillUsageEvents,
360
+ sprintOrder,
361
+ windowSize: 3,
362
+ targetPathFor: (skillId) => 'forge/skills/' + skillId + '.md',
363
+ });
364
+ process.stdout.write(JSON.stringify(deletes));
365
+ "
366
+ ```
367
+
368
+ Append the resulting `delete_skill` proposals to the proposal array from
369
+ step 5/5a before step 6. Each delete proposal already carries
370
+ `recurrence_count: 1` and `recurrence_task_ids: []` (the annotator from
371
+ step 5a is for friction-derived proposals; delete candidates come from
372
+ usage telemetry, not friction, so recurrence is neutral by construction).
373
+
374
+ 5b.5. **Compression gate (reject >20% growth without 3+ frictions)** — a cheap
375
+ deterministic filter that runs BEFORE the LLM judge (step 5c). Any
376
+ `update_skill` proposal that would grow the target file by more than 20%
377
+ (byte-wise, UTF-8) must be backed by at least 3 supporting friction events;
378
+ otherwise it is rejected here and never reaches the judge. `insert_skill`
379
+ and `delete_skill` proposals pass through unconditionally — insert growth
380
+ is handled by the judge's `body_under_2kb` axis and delete only shrinks.
381
+
382
+ Why a pre-judge gate? Judging is expensive. Unbounded skill-body growth is
383
+ the classic SkillOS failure mode — pasting pages of trajectory copy-paste to
384
+ "patch" a friction. It is cheap to detect deterministically and wasteful to
385
+ ask the judge to rule on.
386
+
387
+ ```sh
388
+ node -e "
389
+ const fs = require('node:fs');
390
+ const path = require('node:path');
391
+ const { filterProposals } = require('./forge/tools/compression-gate.cjs');
392
+ // proposals = post-5b array (synthesis + recurrence + delete-candidates).
393
+ // PROJECT_ROOT resolves the target_path; forge plugin source is the source
394
+ // of truth for current bodies. The workflow renders the diff via its own
395
+ // applyProposalDiff helper (left abstract here — the gate is body-agnostic).
396
+ const projectRoot = process.env.PROJECT_ROOT;
397
+ const result = filterProposals({
398
+ proposals,
399
+ currentBodyFor: (p) => {
400
+ const abs = path.join(projectRoot, p.target_path);
401
+ try { return fs.readFileSync(abs, 'utf8'); }
402
+ catch (e) { return ''; } // insert_skill or missing file → empty
403
+ },
404
+ newBodyFor: (p) => applyProposalDiff(currentBodyFor(p), p),
405
+ // Default supporting count = proposal.sourceFrictionIds.length. Override
406
+ // if the policy is 'count frictions citing the same skill across the
407
+ // sprint' rather than 'count citations on the proposal itself'.
408
+ });
409
+ const proposalsAfterGate = result.admitted;
410
+ const compressionRejections = result.rejected; // [{ proposal, ...evaluation }]
411
+ process.stdout.write(JSON.stringify({ kept: proposalsAfterGate, rejected: compressionRejections }));
412
+ "
413
+ ```
414
+
415
+ **Logging gate rejections.** Append every rejection from this step to the
416
+ same `phase2-<timestamp>-rejections.json` sibling that step 5c uses, with
417
+ the rejection record carrying `{ proposal, admit: false,
418
+ reason: 'compression_gate_growth_unsupported', growthRatio, currentBytes,
419
+ newBytes, supportingFrictionCount, threshold, minSupportingFrictions }`.
420
+ This keeps every drop — gate or judge — traceable in one place.
421
+
422
+ Contract (per `forge/tools/compression-gate.cjs`):
423
+ - `GROWTH_THRESHOLD === 0.20`; comparison is **strict** (`> 0.20`). A
424
+ proposal at exactly 20% growth admits without friction support.
425
+ - `MIN_SUPPORTING_FRICTIONS === 3`. Two or fewer citations is not enough.
426
+ - An update on an empty current body yields `growthRatio: Infinity`; the
427
+ friction-support rule still applies.
428
+ - Negative growth (shrink) admits unconditionally.
429
+ - `filterProposals` partitions the input array preserving order; the
430
+ output `rejected` array carries the structured evaluation alongside the
431
+ original proposal.
432
+
433
+ 5c. **LLM-judge gate (Sonnet rubric, drop <3/5)** — score every proposal
434
+ against the 5-axis rubric and drop low-signal proposals before
435
+ presentation. The rubric is single-sourced in
436
+ `forge/tools/judge-proposal.cjs`:
437
+
438
+ | Axis (0..5) | What it measures |
439
+ |---|---|
440
+ | `specificity` | Names a concrete target_path beyond `forge/skills/*` floor; carries a non-trivial rationale; recurrence trail boosts. |
441
+ | `when_not_to_use` | Body contains a literal "When NOT to use" section. |
442
+ | `no_trajectory_copy_paste` | No long verbatim runs or unbroken non-whitespace blocks (>= 400 bytes) that suggest pasted trajectory log. |
443
+ | `body_under_2kb` | `Buffer.byteLength(diff_body, 'utf8') <= 2048`. |
444
+ | `cites_friction` | Proposal carries at least one `sourceFrictionIds` entry; multiple citations or recurrence boost the score. |
445
+
446
+ For each proposal in the post-5b array, the workflow asks Sonnet to
447
+ apply the rubric and emit per-axis 0..5 scores; in the absence of an
448
+ LLM call, the deterministic `scoreProposal(proposal)` helper in
449
+ `judge-proposal.cjs` is used as both the fallback scorer and the
450
+ validation contract for Sonnet-produced scores (single source of truth
451
+ for the rubric definition).
452
+
453
+ ```sh
454
+ node -e "
455
+ const {
456
+ scoreProposal,
457
+ decideJudgement,
458
+ } = require('./forge/tools/judge-proposal.cjs');
459
+ // proposals = post-5b array of proposal records.
460
+ const judged = proposals.map((p) => {
461
+ const scored = scoreProposal(p);
462
+ const decision = decideJudgement(scored);
463
+ return { proposal: p, ...decision };
464
+ });
465
+ const kept = judged.filter((j) => j.verdict === 'keep').map((j) => j.proposal);
466
+ const dropped = judged.filter((j) => j.verdict === 'drop');
467
+ process.stdout.write(JSON.stringify({ kept, dropped }));
468
+ "
469
+ ```
470
+
471
+ Contract (per `forge/tools/judge-proposal.cjs`):
472
+ - `scoreProposal(proposal)` returns `{ axes, average }` with `axes`
473
+ keyed by every entry in `RUBRIC_AXES` and `average` rounded to one
474
+ decimal place.
475
+ - `decideJudgement({ axes })` returns
476
+ `{ verdict, average, axes, reason }`. `verdict === 'drop'` iff
477
+ `average < 3` (strictly less than); ties at exactly 3.0 keep.
478
+ - `decideJudgement` fails loud on missing or out-of-range axes — the
479
+ judge will NOT silently coerce a malformed score sheet into a verdict.
480
+
481
+ **Logging dropped proposals (AC3).** Every rejection MUST be persisted
482
+ for retro review. Replace the proposal array passed to step 6 with the
483
+ `kept` list, and append the `dropped` list to
484
+ `$PROJECT_ROOT/.forge/enhancement-proposals/phase2-<timestamp>-rejections.json`
485
+ as a sibling artifact. Each rejection record carries the original
486
+ proposal alongside `{ verdict: 'drop', average, axes, reason }`. The
487
+ markdown summary written in step 6 SHOULD include a "Dropped (N)" line
488
+ pointing at the rejections file when N > 0.
489
+
490
+ **Carry-over caveat** — the rubric is deterministic; Sonnet's role is
491
+ to add semantic judgement to axes that the heuristic scorer
492
+ approximates (specificity in particular). When Sonnet is invoked, its
493
+ per-axis scores MUST be validated against the 0..5 range via the same
494
+ `validateAxes` invariant `decideJudgement` enforces. Operators
495
+ investigating an unexpected drop should consult the per-axis trace in
496
+ `reason`.
497
+
498
+ Contract (per `forge/tools/delete-candidate-detector.cjs`):
499
+ - A skill qualifies for deletion iff it has at least one `skill_usage`
500
+ event inside the trailing window AND every in-window observation has
501
+ `retrieved === false` AND `used === false`. Any single `retrieved: true`
502
+ or `used: true` event disqualifies the skill.
503
+ - Skills with zero observations in the window are NOT proposed — this
504
+ case is indistinguishable from a newly-added skill that hasn't been
505
+ loaded yet, so silence is the safe default.
506
+ - Each proposal carries `window_size`, `window_sprint_ids`, and a
507
+ `sourceFrictionIds: []` (delete candidates derive from usage telemetry,
508
+ not friction).
509
+
510
+ **Carry-over caveat** — the trailing-3-sprint window is only meaningful
511
+ once 3 sprints have actually elapsed since `skill_usage` event emission
512
+ landed in FORGE-S24-T01 (forge 0.45.1). During the carry-over period the
513
+ detector still runs over whatever sprintOrder it receives, but the
514
+ signal is noisier: a skill flagged after only one or two sprints of
515
+ history may simply be new or temporarily idle. Operators should treat
516
+ delete proposals from short-history runs as advisory until the full
517
+ window is populated.
213
518
 
214
519
  6. **Write proposal artifact**:
215
520
  ```sh
216
521
  mkdir -p "$PROJECT_ROOT/.forge/enhancement-proposals"
217
522
  ```
218
- Write to `$PROJECT_ROOT/.forge/enhancement-proposals/phase2-<timestamp>.md`. Format:
219
- one section per proposed change, with a fenced diff block showing before/after text.
523
+ Write **two** outputs for each Phase 2 run (using the `kept` list from
524
+ step 5c dropped proposals are persisted separately to the
525
+ `phase2-<timestamp>-rejections.json` sibling described in step 5c):
526
+
527
+ - `phase2-<timestamp>.md` — human-readable markdown, one section per proposal,
528
+ showing op + target_path + a fenced diff block.
529
+ - `phase2-<timestamp>.json` — machine-readable array of proposal records, each
530
+ conforming to `forge/schemas/proposal.schema.json` (required keys: `op`,
531
+ `target_path`, `diff_body`; `op` ∈ {insert_skill, update_skill, delete_skill};
532
+ optional `recurrence_count` ≥ 1 and `recurrence_task_ids` populated by step 5a).
533
+
534
+ **Back-compat on read** — pre-0.45.2 proposal records lack `op`. Downstream
535
+ consumers MUST route legacy records through
536
+ `forge/tools/proposal-normalize.cjs:normaliseProposal()` which defaults the
537
+ missing `op` to `insert_skill` (the only op the prior insert-biased flow
538
+ could produce). Do NOT silently coerce — call the helper explicitly so the
539
+ normalisation is auditable.
220
540
 
221
541
  7. **Present to user**:
222
542
  ```