@izkac/forgekit 0.1.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/bin/forge.mjs +100 -0
- package/bin/forgekit.mjs +84 -0
- package/bin/review.mjs +82 -0
- package/package.json +46 -0
- package/scripts/prepack.mjs +78 -0
- package/scripts/run-tests.mjs +43 -0
- package/src/adr.mjs +236 -0
- package/src/adr.test.mjs +170 -0
- package/src/change.mjs +234 -0
- package/src/change.test.mjs +83 -0
- package/src/cleanup-sessions.mjs +70 -0
- package/src/config.mjs +103 -0
- package/src/defer.mjs +75 -0
- package/src/doctor.mjs +341 -0
- package/src/doctor.test.mjs +114 -0
- package/src/init.mjs +575 -0
- package/src/install.mjs +777 -0
- package/src/install.test.mjs +104 -0
- package/src/integrity-check.mjs +58 -0
- package/src/integrity.mjs +317 -0
- package/src/integrity.test.mjs +296 -0
- package/src/lib/workspaces.mjs +55 -0
- package/src/lib.mjs +138 -0
- package/src/models.defaults.json +41 -0
- package/src/new-session.mjs +82 -0
- package/src/openspec-overlays/README.md +19 -0
- package/src/openspec-overlays/openspec-apply-change-footer.md +14 -0
- package/src/openspec-overlays/opsx-apply-completion-step.md +1 -0
- package/src/openspec-overlays/opsx-apply-implement-step.md +11 -0
- package/src/paths.mjs +92 -0
- package/src/plan-engine.mjs +260 -0
- package/src/plan-engine.test.mjs +245 -0
- package/src/preferences.defaults.json +78 -0
- package/src/preferences.mjs +438 -0
- package/src/preferences.test.mjs +174 -0
- package/src/record-evidence.mjs +204 -0
- package/src/record-evidence.test.mjs +260 -0
- package/src/resolve-model.mjs +312 -0
- package/src/resolve-model.test.mjs +194 -0
- package/src/review/carryforward.mjs +413 -0
- package/src/review/carryforward.test.mjs +587 -0
- package/src/review/cli.test.mjs +117 -0
- package/src/review/export.mjs +172 -0
- package/src/review/export.test.mjs +197 -0
- package/src/review/fixtures/valid-review.json +42 -0
- package/src/review/lib.mjs +894 -0
- package/src/review/lib.test.mjs +266 -0
- package/src/review/merge-tentative.mjs +292 -0
- package/src/review/merge-tentative.test.mjs +363 -0
- package/src/review/new-review.mjs +200 -0
- package/src/review/render.mjs +108 -0
- package/src/review/schema-consistency.test.mjs +83 -0
- package/src/review/schema.json +196 -0
- package/src/review/signals.mjs +144 -0
- package/src/review/signals.test.mjs +62 -0
- package/src/score-cli.mjs +68 -0
- package/src/score.mjs +489 -0
- package/src/score.test.mjs +253 -0
- package/src/session-reminder.mjs +168 -0
- package/src/session-status.mjs +70 -0
- package/src/set-models.mjs +186 -0
- package/src/set-phase.mjs +177 -0
- package/src/set-phase.test.mjs +317 -0
- package/src/set-prefs.mjs +294 -0
- package/src/spine.mjs +91 -0
- package/src/triage-prompt.mjs +175 -0
- package/src/triage-prompt.test.mjs +50 -0
- package/src/vendor-openspec-overlays.mjs +176 -0
- package/src/vendor-openspec-overlays.test.mjs +62 -0
- package/vendor/skills/archive-to-adr/SKILL.md +149 -0
- package/vendor/skills/forge/SKILL.md +136 -0
- package/vendor/skills/forge/phases/brainstorm.md +23 -0
- package/vendor/skills/forge/phases/finish.md +87 -0
- package/vendor/skills/forge/phases/implement.md +76 -0
- package/vendor/skills/forge/phases/plan-openspec.md +40 -0
- package/vendor/skills/forge/phases/plan-specs.md +97 -0
- package/vendor/skills/forge/phases/review.md +25 -0
- package/vendor/skills/forge/phases/verify.md +120 -0
- package/vendor/skills/forge/references/forge-layout.md +85 -0
- package/vendor/skills/forge/references/pace.md +115 -0
- package/vendor/skills/forge/references/plan-routing.md +51 -0
- package/vendor/skills/forge/references/runtime-integrity.md +157 -0
- package/vendor/skills/forge/references/substantial-work.md +37 -0
- package/vendor/skills/forge/references/tdd-core.md +29 -0
- package/vendor/skills/forge/references/test-evidence.md +30 -0
- package/vendor/skills/forge/references/test-strategy.md +68 -0
- package/vendor/skills/forge/skills/NOTICE.md +17 -0
- package/vendor/skills/forge/skills/brainstorming/SKILL.md +120 -0
- package/vendor/skills/forge/skills/requesting-code-review/SKILL.md +67 -0
- package/vendor/skills/forge/skills/requesting-code-review/code-reviewer.md +146 -0
- package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -0
- package/vendor/skills/forge/skills/systematic-debugging/SKILL.md +234 -0
- package/vendor/skills/forge/skills/systematic-debugging/condition-based-waiting.md +115 -0
- package/vendor/skills/forge/skills/systematic-debugging/defense-in-depth.md +122 -0
- package/vendor/skills/forge/skills/systematic-debugging/find-polluter.sh +63 -0
- package/vendor/skills/forge/skills/systematic-debugging/root-cause-tracing.md +169 -0
- package/vendor/skills/forge/skills/test-driven-development/SKILL.md +290 -0
- package/vendor/skills/forge/skills/test-driven-development/testing-anti-patterns.md +299 -0
- package/vendor/skills/forge/skills/verification-before-completion/SKILL.md +59 -0
- package/vendor/skills/forge/subagents/final-reviewer-prompt.md +53 -0
- package/vendor/skills/forge/subagents/implementer-prompt.md +38 -0
- package/vendor/skills/forge/subagents/task-reviewer-prompt.md +61 -0
- package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -0
- package/vendor/skills/thorough-code-review/SKILL.md +290 -0
- package/vendor/skills/thorough-code-review/examples/accepted-risks-janus.md +32 -0
- package/vendor/skills/thorough-code-review/examples.md +133 -0
- package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -0
- package/vendor/skills/thorough-code-review/reference/lenses.md +96 -0
- package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -0
- package/vendor/skills/thorough-code-review/reference/phase1c-coverage.md +44 -0
- package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -0
- package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -0
- package/vendor/skills/thorough-code-review/reference/report-template.md +115 -0
- package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -0
- package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -0
- package/vendor/templates/adr/README.md +7 -0
- package/vendor/templates/adr/decisions.md +141 -0
- package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -0
- package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -0
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -0
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -0
- package/vendor/templates/project/claude/commands/forge-apply.md +75 -0
- package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -0
- package/vendor/templates/project/claude/commands/forge-build.md +17 -0
- package/vendor/templates/project/claude/commands/forge-plan.md +12 -0
- package/vendor/templates/project/claude/commands/forge-skip.md +14 -0
- package/vendor/templates/project/claude/commands/forge-status.md +16 -0
- package/vendor/templates/project/claude/commands/forge.md +16 -0
- package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -0
- package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -0
- package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -0
- package/vendor/templates/project/claude/rules/forge.md +16 -0
- package/vendor/templates/project/codex/rules/forge.md +10 -0
- package/vendor/templates/project/cursor/commands/forge-apply.md +75 -0
- package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -0
- package/vendor/templates/project/cursor/commands/forge-build.md +17 -0
- package/vendor/templates/project/cursor/commands/forge-plan.md +15 -0
- package/vendor/templates/project/cursor/commands/forge-skip.md +14 -0
- package/vendor/templates/project/cursor/commands/forge-status.md +16 -0
- package/vendor/templates/project/cursor/commands/forge.md +16 -0
- package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -0
- package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -0
- package/vendor/templates/project/cursor/rules/forge.mdc +21 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import {
|
|
9
|
+
formatScorecardMarkdown,
|
|
10
|
+
gradeForScore,
|
|
11
|
+
scoreSession,
|
|
12
|
+
writeSessionScorecard,
|
|
13
|
+
} from './score.mjs';
|
|
14
|
+
|
|
15
|
+
const PHASE_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'set-phase.mjs');
|
|
16
|
+
const SCORE_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'score-cli.mjs');
|
|
17
|
+
|
|
18
|
+
function tmp(prefix) {
|
|
19
|
+
return fs.mkdtempSync(path.join(tmpdir(), prefix));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeSession(root, overrides = {}) {
|
|
23
|
+
const sessionDir = path.join(root, '.forge', 'sessions', 'sess-score');
|
|
24
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
25
|
+
const now = new Date().toISOString();
|
|
26
|
+
const session = {
|
|
27
|
+
id: 'sess-score',
|
|
28
|
+
slug: 'fixture',
|
|
29
|
+
createdAt: now,
|
|
30
|
+
updatedAt: now,
|
|
31
|
+
phase: 'verify',
|
|
32
|
+
planType: null,
|
|
33
|
+
openspecChange: null,
|
|
34
|
+
tasksTotal: 2,
|
|
35
|
+
tasksComplete: 2,
|
|
36
|
+
pace: 'auto',
|
|
37
|
+
resolvedPace: 'standard',
|
|
38
|
+
pacePinned: false,
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
fs.writeFileSync(path.join(sessionDir, 'session.json'), `${JSON.stringify(session, null, 2)}\n`, 'utf8');
|
|
42
|
+
fs.writeFileSync(
|
|
43
|
+
path.join(root, '.forge', 'active.json'),
|
|
44
|
+
`${JSON.stringify({ sessionId: 'sess-score' }, null, 2)}\n`,
|
|
45
|
+
'utf8',
|
|
46
|
+
);
|
|
47
|
+
return { sessionDir, session };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validSpine(rows) {
|
|
51
|
+
return {
|
|
52
|
+
change: null,
|
|
53
|
+
notApplicable: null,
|
|
54
|
+
rows,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validRow() {
|
|
59
|
+
return {
|
|
60
|
+
capability: 'REQ-GOV-01 matching',
|
|
61
|
+
library: 'etl_core/matcher.py',
|
|
62
|
+
runtimeOwner: 'worker job analyze_study',
|
|
63
|
+
writes: 'study_proposals',
|
|
64
|
+
reads: 'N/A',
|
|
65
|
+
uiConsumer: 'Proposals',
|
|
66
|
+
evidence: 'tasks/12/test-evidence.md',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test('gradeForScore bands', () => {
|
|
71
|
+
assert.equal(gradeForScore(95), 'A');
|
|
72
|
+
assert.equal(gradeForScore(80), 'B');
|
|
73
|
+
assert.equal(gradeForScore(60), 'C');
|
|
74
|
+
assert.equal(gradeForScore(40), 'D');
|
|
75
|
+
assert.equal(gradeForScore(10), 'F');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('scoreSession: strong sync-only session scores high', () => {
|
|
79
|
+
const root = tmp('forge-score-strong-');
|
|
80
|
+
try {
|
|
81
|
+
const { sessionDir, session } = makeSession(root, { slug: 'add-health' });
|
|
82
|
+
fs.writeFileSync(
|
|
83
|
+
path.join(sessionDir, 'spine.json'),
|
|
84
|
+
`${JSON.stringify({ rows: [], notApplicable: 'sync HTTP only' }, null, 2)}\n`,
|
|
85
|
+
'utf8',
|
|
86
|
+
);
|
|
87
|
+
fs.writeFileSync(path.join(sessionDir, 'verify-evidence.md'), '# Verify\n\nExit 0\n', 'utf8');
|
|
88
|
+
const taskDir = path.join(sessionDir, 'tasks', '01-health');
|
|
89
|
+
fs.mkdirSync(taskDir, { recursive: true });
|
|
90
|
+
fs.writeFileSync(
|
|
91
|
+
path.join(taskDir, 'test-evidence.md'),
|
|
92
|
+
'# Test evidence\n\n- **Exit code:** 0\n- **Summary:** assert response.ok === true\n',
|
|
93
|
+
'utf8',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const card = scoreSession({ cwd: root, sessionDir, session });
|
|
97
|
+
assert.ok(card.score >= 80, `expected >=80, got ${card.score}`);
|
|
98
|
+
assert.ok(['A', 'B'].includes(card.grade));
|
|
99
|
+
assert.equal(card.integrityOk, true);
|
|
100
|
+
assert.match(formatScorecardMarkdown(card), /Grade: [AB]/);
|
|
101
|
+
} finally {
|
|
102
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('scoreSession: missing spine scores poorly', () => {
|
|
107
|
+
const root = tmp('forge-score-weak-');
|
|
108
|
+
try {
|
|
109
|
+
const { sessionDir, session } = makeSession(root);
|
|
110
|
+
fs.writeFileSync(path.join(sessionDir, 'verify-evidence.md'), '# ok\n', 'utf8');
|
|
111
|
+
const card = scoreSession({ cwd: root, sessionDir, session });
|
|
112
|
+
assert.ok(card.score < 60, `expected weak score, got ${card.score}`);
|
|
113
|
+
assert.equal(card.integrityOk, false);
|
|
114
|
+
} finally {
|
|
115
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('scoreSession: incompleteReason caps score at 59', () => {
|
|
120
|
+
const root = tmp('forge-score-cap-');
|
|
121
|
+
try {
|
|
122
|
+
const { sessionDir, session } = makeSession(root, {
|
|
123
|
+
incompleteReason: 'E2E blocked',
|
|
124
|
+
slug: 'add-health',
|
|
125
|
+
});
|
|
126
|
+
fs.writeFileSync(
|
|
127
|
+
path.join(sessionDir, 'spine.json'),
|
|
128
|
+
`${JSON.stringify({ rows: [], notApplicable: 'sync HTTP only' }, null, 2)}\n`,
|
|
129
|
+
'utf8',
|
|
130
|
+
);
|
|
131
|
+
fs.writeFileSync(path.join(sessionDir, 'verify-evidence.md'), '# ok\n', 'utf8');
|
|
132
|
+
const card = scoreSession({ cwd: root, sessionDir, session });
|
|
133
|
+
assert.ok(card.score <= 59);
|
|
134
|
+
assert.ok(card.caps.some((c) => /incompleteReason/.test(c)));
|
|
135
|
+
} finally {
|
|
136
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('scoreSession: product-loop with baseline-diff scores higher than bare section', () => {
|
|
141
|
+
const root = tmp('forge-score-loop-');
|
|
142
|
+
try {
|
|
143
|
+
const { sessionDir, session } = makeSession(root, { slug: 'wire-worker-jobs' });
|
|
144
|
+
fs.writeFileSync(
|
|
145
|
+
path.join(sessionDir, 'spine.json'),
|
|
146
|
+
`${JSON.stringify(validSpine([validRow()]), null, 2)}\n`,
|
|
147
|
+
'utf8',
|
|
148
|
+
);
|
|
149
|
+
fs.writeFileSync(
|
|
150
|
+
path.join(sessionDir, 'verify-evidence.md'),
|
|
151
|
+
'# Verify\n\n## Product loop\n\nJust a heading\n',
|
|
152
|
+
'utf8',
|
|
153
|
+
);
|
|
154
|
+
const weak = scoreSession({ cwd: root, sessionDir, session });
|
|
155
|
+
|
|
156
|
+
fs.writeFileSync(
|
|
157
|
+
path.join(sessionDir, 'verify-evidence.md'),
|
|
158
|
+
`# Verify
|
|
159
|
+
|
|
160
|
+
## Product loop
|
|
161
|
+
|
|
162
|
+
Fixture: OP1086
|
|
163
|
+
|
|
164
|
+
1. ingest
|
|
165
|
+
2. analyze
|
|
166
|
+
3. ratify
|
|
167
|
+
4. run @R — output differs from baseline
|
|
168
|
+
`,
|
|
169
|
+
'utf8',
|
|
170
|
+
);
|
|
171
|
+
const strong = scoreSession({ cwd: root, sessionDir, session });
|
|
172
|
+
assert.ok(strong.score > weak.score, `${strong.score} should beat ${weak.score}`);
|
|
173
|
+
const loop = strong.checks.find((c) => c.id === 'product_loop');
|
|
174
|
+
assert.ok(loop.points >= 15);
|
|
175
|
+
} finally {
|
|
176
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('writeSessionScorecard writes json and md', () => {
|
|
181
|
+
const root = tmp('forge-score-write-');
|
|
182
|
+
try {
|
|
183
|
+
const { sessionDir, session } = makeSession(root, { slug: 'docs-only' });
|
|
184
|
+
fs.writeFileSync(
|
|
185
|
+
path.join(sessionDir, 'spine.json'),
|
|
186
|
+
`${JSON.stringify({ rows: [], notApplicable: 'docs-only' }, null, 2)}\n`,
|
|
187
|
+
'utf8',
|
|
188
|
+
);
|
|
189
|
+
fs.writeFileSync(path.join(sessionDir, 'verify-evidence.md'), '# ok\n', 'utf8');
|
|
190
|
+
const { jsonPath, mdPath, card } = writeSessionScorecard({ cwd: root, sessionDir, session });
|
|
191
|
+
assert.equal(fs.existsSync(jsonPath), true);
|
|
192
|
+
assert.equal(fs.existsSync(mdPath), true);
|
|
193
|
+
assert.equal(JSON.parse(fs.readFileSync(jsonPath, 'utf8')).grade, card.grade);
|
|
194
|
+
} finally {
|
|
195
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('forge phase done writes scorecard and stamps session.scoreGrade', () => {
|
|
200
|
+
const root = tmp('forge-score-phase-');
|
|
201
|
+
try {
|
|
202
|
+
const { sessionDir } = makeSession(root, {
|
|
203
|
+
slug: 'add-health',
|
|
204
|
+
phase: 'verify',
|
|
205
|
+
tasksTotal: 1,
|
|
206
|
+
tasksComplete: 1,
|
|
207
|
+
});
|
|
208
|
+
fs.writeFileSync(
|
|
209
|
+
path.join(sessionDir, 'spine.json'),
|
|
210
|
+
`${JSON.stringify({ rows: [], notApplicable: 'sync HTTP only' }, null, 2)}\n`,
|
|
211
|
+
'utf8',
|
|
212
|
+
);
|
|
213
|
+
fs.writeFileSync(path.join(sessionDir, 'verify-evidence.md'), '# ok\n', 'utf8');
|
|
214
|
+
|
|
215
|
+
execFileSync(process.execPath, [PHASE_SCRIPT, 'done'], {
|
|
216
|
+
cwd: root,
|
|
217
|
+
encoding: 'utf8',
|
|
218
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
assert.equal(fs.existsSync(path.join(sessionDir, 'scorecard.md')), true);
|
|
222
|
+
assert.equal(fs.existsSync(path.join(sessionDir, 'scorecard.json')), true);
|
|
223
|
+
const session = JSON.parse(fs.readFileSync(path.join(sessionDir, 'session.json'), 'utf8'));
|
|
224
|
+
assert.equal(session.phase, 'done');
|
|
225
|
+
assert.ok(typeof session.score === 'number');
|
|
226
|
+
assert.ok(['A', 'B', 'C', 'D', 'F'].includes(session.scoreGrade));
|
|
227
|
+
} finally {
|
|
228
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('forge score CLI prints JSON', () => {
|
|
233
|
+
const root = tmp('forge-score-cli-');
|
|
234
|
+
try {
|
|
235
|
+
const { sessionDir } = makeSession(root, { slug: 'add-health' });
|
|
236
|
+
fs.writeFileSync(
|
|
237
|
+
path.join(sessionDir, 'spine.json'),
|
|
238
|
+
`${JSON.stringify({ rows: [], notApplicable: 'sync HTTP only' }, null, 2)}\n`,
|
|
239
|
+
'utf8',
|
|
240
|
+
);
|
|
241
|
+
fs.writeFileSync(path.join(sessionDir, 'verify-evidence.md'), '# ok\n', 'utf8');
|
|
242
|
+
const out = execFileSync(process.execPath, [SCORE_SCRIPT], {
|
|
243
|
+
cwd: root,
|
|
244
|
+
encoding: 'utf8',
|
|
245
|
+
});
|
|
246
|
+
const card = JSON.parse(out);
|
|
247
|
+
assert.ok(card.grade);
|
|
248
|
+
assert.ok(Array.isArray(card.humanPrompts));
|
|
249
|
+
assert.equal(card.humanPrompts.length, 4);
|
|
250
|
+
} finally {
|
|
251
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build Forge session reminders for agent hooks.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* forge reminder --format cursor
|
|
7
|
+
* forge reminder --format claude-session-start
|
|
8
|
+
* forge reminder --format plain
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { FORGE_DIR, loadSession, readActive } from './lib.mjs';
|
|
12
|
+
import { resolveEffectivePreferences } from './preferences.mjs';
|
|
13
|
+
|
|
14
|
+
function getActiveSessionInfo() {
|
|
15
|
+
const active = readActive();
|
|
16
|
+
if (!active?.sessionId) return null;
|
|
17
|
+
try {
|
|
18
|
+
const { dir, session } = loadSession(active.sessionId);
|
|
19
|
+
return { active, dir, session };
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {Record<string, unknown>} session
|
|
27
|
+
*/
|
|
28
|
+
export function formatPaceLine(session) {
|
|
29
|
+
try {
|
|
30
|
+
const eff = resolveEffectivePreferences({
|
|
31
|
+
forgeDir: FORGE_DIR,
|
|
32
|
+
session,
|
|
33
|
+
signalText:
|
|
34
|
+
(typeof session.paceSignal === 'string' && session.paceSignal) ||
|
|
35
|
+
(typeof session.slug === 'string' && session.slug) ||
|
|
36
|
+
'',
|
|
37
|
+
});
|
|
38
|
+
const requested = eff.requestedPace;
|
|
39
|
+
const resolved = eff.resolvedPace;
|
|
40
|
+
if (requested === 'auto') {
|
|
41
|
+
return `Pace: auto → ${resolved} (${eff.paceReason})`;
|
|
42
|
+
}
|
|
43
|
+
return `Pace: ${resolved}`;
|
|
44
|
+
} catch {
|
|
45
|
+
const requested = session.pace ?? 'auto';
|
|
46
|
+
const resolved = session.resolvedPace;
|
|
47
|
+
if (resolved) return `Pace: ${requested} → ${resolved}`;
|
|
48
|
+
return `Pace: ${requested}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const OPENSPEC_PLAN_REMINDER =
|
|
53
|
+
'Planning: Forge always uses OpenSpec. After brainstorm approval, run /opsx:propose (openspec-propose). Do not ask for a plan mode. See forge references/plan-routing.md.';
|
|
54
|
+
|
|
55
|
+
/** @deprecated use OPENSPEC_PLAN_REMINDER */
|
|
56
|
+
export const PLAN_MODE_PROMPT_REMINDER = OPENSPEC_PLAN_REMINDER;
|
|
57
|
+
|
|
58
|
+
export function needsOpenSpecPlan(session) {
|
|
59
|
+
return (
|
|
60
|
+
!session.planType &&
|
|
61
|
+
(session.phase === 'brainstorm' || session.phase === 'plan')
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @deprecated use needsOpenSpecPlan */
|
|
66
|
+
export const needsPlanModePrompt = needsOpenSpecPlan;
|
|
67
|
+
|
|
68
|
+
export const RUNTIME_INTEGRITY_REMINDER =
|
|
69
|
+
'Integrity: spine.json mandatory every change (rows or notApplicable); no stubs/false success; specs beat task wording; product-loop when spine has rows; defer wiring only via `forge defer` — `forge phase done` runs `forge integrity-check` (forge references/runtime-integrity.md).';
|
|
70
|
+
|
|
71
|
+
export function buildForgeMessage(info) {
|
|
72
|
+
const { session } = info;
|
|
73
|
+
const lines = [];
|
|
74
|
+
lines.push(`[forge] Active Forge session: ${session.id}`);
|
|
75
|
+
lines.push(
|
|
76
|
+
`Phase: ${session.phase} | Plan: ${session.planType ?? 'pending'}${
|
|
77
|
+
session.openspecChange ? ` (${session.openspecChange})` : ''
|
|
78
|
+
}`,
|
|
79
|
+
);
|
|
80
|
+
lines.push(formatPaceLine(session));
|
|
81
|
+
if (session.tasksTotal > 0) {
|
|
82
|
+
lines.push(`Tasks: ${session.tasksComplete}/${session.tasksTotal}`);
|
|
83
|
+
}
|
|
84
|
+
if (needsOpenSpecPlan(session)) {
|
|
85
|
+
lines.push(OPENSPEC_PLAN_REMINDER);
|
|
86
|
+
}
|
|
87
|
+
lines.push(RUNTIME_INTEGRITY_REMINDER);
|
|
88
|
+
lines.push('Resume: invoke the forge skill for the current phase.');
|
|
89
|
+
lines.push('Honor pace: see forge references/pace.md (`forge prefs`).');
|
|
90
|
+
lines.push('Skip Forge for this task only: /forge:skip');
|
|
91
|
+
lines.push('Guide: Forge skill + forgekit docs/forge.md');
|
|
92
|
+
return lines.join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildForgePromptMessage(info, prompt) {
|
|
96
|
+
const base = buildForgeMessage(info);
|
|
97
|
+
const trimmed = (prompt || '').trim();
|
|
98
|
+
if (/^\/forge:skip\b/i.test(trimmed)) {
|
|
99
|
+
return `${base}\n\nUser invoked /forge:skip — execute directly; do not start brainstorm/plan.`;
|
|
100
|
+
}
|
|
101
|
+
if (/^\/forge:status\b/i.test(trimmed)) {
|
|
102
|
+
return `${base}\n\nRun: forge status — then summarize for the user.`;
|
|
103
|
+
}
|
|
104
|
+
if (/^\/forge:plan\b/i.test(trimmed)) {
|
|
105
|
+
return `${base}\n\nPlan phase: ${OPENSPEC_PLAN_REMINDER}`;
|
|
106
|
+
}
|
|
107
|
+
if (/^\/forge:brainstorm\b/i.test(trimmed)) {
|
|
108
|
+
return `${base}\n\nBrainstorm terminal state: proceed to OpenSpec propose — do not implement until artefacts are approved.`;
|
|
109
|
+
}
|
|
110
|
+
return `${base}\n\nUser invoked Forge — follow forge skill from phase "${info.session.phase}".`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function emitCursor(message) {
|
|
114
|
+
const payload = JSON.stringify({ agent_message: message });
|
|
115
|
+
process.stdout.write(`${payload}\n`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function emitClaudeSessionStart(message) {
|
|
119
|
+
const payload = {
|
|
120
|
+
hookSpecificOutput: {
|
|
121
|
+
hookEventName: 'SessionStart',
|
|
122
|
+
additionalContext: `<system-reminder>\n${message}\n</system-reminder>`,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function emitPlain(message) {
|
|
129
|
+
process.stdout.write(`${message}\n`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const args = process.argv.slice(2);
|
|
133
|
+
let format = 'plain';
|
|
134
|
+
let prompt = '';
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
137
|
+
if (args[i] === '--format' && args[i + 1]) {
|
|
138
|
+
format = args[i + 1];
|
|
139
|
+
i += 1;
|
|
140
|
+
} else if (args[i] === '--prompt' && args[i + 1]) {
|
|
141
|
+
prompt = args[i + 1];
|
|
142
|
+
i += 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const info = getActiveSessionInfo();
|
|
147
|
+
if (!info) {
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const message = prompt
|
|
152
|
+
? buildForgePromptMessage(info, prompt)
|
|
153
|
+
: buildForgeMessage(info);
|
|
154
|
+
|
|
155
|
+
switch (format) {
|
|
156
|
+
case 'cursor':
|
|
157
|
+
emitCursor(message);
|
|
158
|
+
break;
|
|
159
|
+
case 'claude-session-start':
|
|
160
|
+
emitClaudeSessionStart(message);
|
|
161
|
+
break;
|
|
162
|
+
case 'plain':
|
|
163
|
+
emitPlain(message);
|
|
164
|
+
break;
|
|
165
|
+
default:
|
|
166
|
+
process.stderr.write(`Unknown format: ${format}\n`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Print the active Forge session or a specific session by id.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* forge status
|
|
7
|
+
* forge status [--session <id>]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
FORGE_DIR,
|
|
14
|
+
loadSession,
|
|
15
|
+
readActive,
|
|
16
|
+
REPO_ROOT,
|
|
17
|
+
} from './lib.mjs';
|
|
18
|
+
import { resolveEffectivePreferences } from './preferences.mjs';
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
let sessionId = null;
|
|
22
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
23
|
+
if (args[i] === '--session' && args[i + 1]) {
|
|
24
|
+
sessionId = args[i + 1];
|
|
25
|
+
i += 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!sessionId) {
|
|
30
|
+
const active = readActive();
|
|
31
|
+
if (!active?.sessionId) {
|
|
32
|
+
process.stdout.write(JSON.stringify({ status: 'none', message: 'No active Forge session.' }, null, 2));
|
|
33
|
+
process.stdout.write('\n');
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
sessionId = active.sessionId;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { dir, session } = loadSession(sessionId);
|
|
40
|
+
const statusPath = path.join(dir, 'status.json');
|
|
41
|
+
const status = fs.existsSync(statusPath) ? JSON.parse(fs.readFileSync(statusPath, 'utf8')) : null;
|
|
42
|
+
|
|
43
|
+
const pace = resolveEffectivePreferences({
|
|
44
|
+
forgeDir: FORGE_DIR,
|
|
45
|
+
session,
|
|
46
|
+
signalText: session.paceSignal || session.slug || '',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
process.stdout.write(
|
|
50
|
+
JSON.stringify(
|
|
51
|
+
{
|
|
52
|
+
status: 'ok',
|
|
53
|
+
sessionId,
|
|
54
|
+
sessionPath: path.relative(REPO_ROOT, dir).replace(/\\/g, '/'),
|
|
55
|
+
session,
|
|
56
|
+
progress: status,
|
|
57
|
+
pace: {
|
|
58
|
+
requested: pace.requestedPace,
|
|
59
|
+
resolved: pace.resolvedPace,
|
|
60
|
+
reason: pace.paceReason,
|
|
61
|
+
source: pace.source,
|
|
62
|
+
effective: pace.effective,
|
|
63
|
+
},
|
|
64
|
+
integrity: pace.integrity,
|
|
65
|
+
},
|
|
66
|
+
null,
|
|
67
|
+
2,
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
process.stdout.write('\n');
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Get or set the checkout-local Forge subagent billing preference.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* forge set-models # print effective billing
|
|
7
|
+
* forge set-models included # set local billing
|
|
8
|
+
* forge set-models metered
|
|
9
|
+
*
|
|
10
|
+
* Options:
|
|
11
|
+
* --forge-dir <path> Forge root (default: .forge under cwd)
|
|
12
|
+
* --defaults <path> Defaults JSON path
|
|
13
|
+
* --json Machine-readable stdout for get
|
|
14
|
+
* --help
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { pathToFileURL } from 'node:url';
|
|
20
|
+
import {
|
|
21
|
+
BILLING_LANES,
|
|
22
|
+
DEFAULTS_PATH,
|
|
23
|
+
loadJsonFile,
|
|
24
|
+
loadMergedConfig,
|
|
25
|
+
} from './resolve-model.mjs';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string[]} argv
|
|
29
|
+
*/
|
|
30
|
+
export function parseArgs(argv) {
|
|
31
|
+
const opts = {
|
|
32
|
+
billing: null,
|
|
33
|
+
forgeDir: null,
|
|
34
|
+
defaults: null,
|
|
35
|
+
json: false,
|
|
36
|
+
help: false,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
40
|
+
const arg = argv[i];
|
|
41
|
+
if (arg === '--forge-dir') opts.forgeDir = argv[++i];
|
|
42
|
+
else if (arg === '--defaults') opts.defaults = argv[++i];
|
|
43
|
+
else if (arg === '--json') opts.json = true;
|
|
44
|
+
else if (arg === '--help' || arg === '-h') opts.help = true;
|
|
45
|
+
else if (arg === '--') continue;
|
|
46
|
+
else if (!arg.startsWith('-') && opts.billing === null) opts.billing = arg;
|
|
47
|
+
else throw new Error(`Unknown argument: ${arg}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return opts;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {{ forgeDir: string, billing: string }} opts
|
|
55
|
+
*/
|
|
56
|
+
export function writeLocalBilling(opts) {
|
|
57
|
+
if (!BILLING_LANES.includes(opts.billing)) {
|
|
58
|
+
throw new Error(`Unknown billing "${opts.billing}". Expected one of: ${BILLING_LANES.join(', ')}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fs.mkdirSync(opts.forgeDir, { recursive: true });
|
|
62
|
+
const localPath = path.join(opts.forgeDir, 'models.local.json');
|
|
63
|
+
|
|
64
|
+
/** @type {Record<string, unknown>} */
|
|
65
|
+
let existing = {};
|
|
66
|
+
if (fs.existsSync(localPath)) {
|
|
67
|
+
existing = loadJsonFile(localPath);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const next = { ...existing, billing: opts.billing };
|
|
71
|
+
fs.writeFileSync(localPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
72
|
+
return { localPath, billing: opts.billing };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {{ forgeDir?: string, defaultsPath?: string, cwd?: string }} [paths]
|
|
77
|
+
*/
|
|
78
|
+
export function getEffectiveBilling(paths = {}) {
|
|
79
|
+
const { config, sources, localPath } = loadMergedConfig({
|
|
80
|
+
defaultsPath: paths.defaultsPath,
|
|
81
|
+
forgeDir: paths.forgeDir,
|
|
82
|
+
cwd: paths.cwd,
|
|
83
|
+
});
|
|
84
|
+
const billing =
|
|
85
|
+
typeof config.billing === 'string' && BILLING_LANES.includes(config.billing)
|
|
86
|
+
? config.billing
|
|
87
|
+
: 'included';
|
|
88
|
+
return {
|
|
89
|
+
billing,
|
|
90
|
+
localPath,
|
|
91
|
+
localExists: fs.existsSync(localPath),
|
|
92
|
+
source: sources,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function printHelp() {
|
|
97
|
+
process.stdout.write(`Usage: forge set-models [included|metered] [options]
|
|
98
|
+
|
|
99
|
+
Get or set checkout-local Forge subagent billing lane.
|
|
100
|
+
|
|
101
|
+
forge models
|
|
102
|
+
Print effective lane only — does NOT create .forge/models.local.json.
|
|
103
|
+
Without a local file, this prints the committed default (usually "included").
|
|
104
|
+
|
|
105
|
+
forge models included|metered
|
|
106
|
+
Write/update .forge/models.local.json (gitignored, per-checkout).
|
|
107
|
+
|
|
108
|
+
forge models -- --json
|
|
109
|
+
Same get, with paths and localExists.
|
|
110
|
+
|
|
111
|
+
Defaults: models.defaults.json
|
|
112
|
+
Local overlay: .forge/models.local.json (appears only after a set)
|
|
113
|
+
|
|
114
|
+
Options:
|
|
115
|
+
--forge-dir <path> Forge directory (default: .forge)
|
|
116
|
+
--defaults <path> Defaults JSON path
|
|
117
|
+
--json JSON stdout when getting
|
|
118
|
+
--help Show help
|
|
119
|
+
`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string[]} argv
|
|
124
|
+
* @param {{ cwd?: string, stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream }} [io]
|
|
125
|
+
*/
|
|
126
|
+
export function runSetModels(argv, io = {}) {
|
|
127
|
+
const stdout = io.stdout ?? process.stdout;
|
|
128
|
+
const stderr = io.stderr ?? process.stderr;
|
|
129
|
+
const cwd = io.cwd ?? process.cwd();
|
|
130
|
+
|
|
131
|
+
let opts;
|
|
132
|
+
try {
|
|
133
|
+
opts = parseArgs(argv);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
136
|
+
stderr.write(`${msg}\n`);
|
|
137
|
+
return 2;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (opts.help) {
|
|
141
|
+
printHelp();
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const forgeDir = opts.forgeDir ?? path.join(cwd, '.forge');
|
|
146
|
+
const defaultsPath = opts.defaults ?? DEFAULTS_PATH;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
if (opts.billing) {
|
|
150
|
+
const written = writeLocalBilling({ forgeDir, billing: opts.billing });
|
|
151
|
+
stdout.write(`billing=${written.billing}\n`);
|
|
152
|
+
stdout.write(`wrote ${written.localPath}\n`);
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const effective = getEffectiveBilling({
|
|
157
|
+
forgeDir,
|
|
158
|
+
defaultsPath,
|
|
159
|
+
cwd,
|
|
160
|
+
});
|
|
161
|
+
if (opts.json) {
|
|
162
|
+
stdout.write(`${JSON.stringify(effective, null, 2)}\n`);
|
|
163
|
+
} else {
|
|
164
|
+
stdout.write(`billing=${effective.billing}\n`);
|
|
165
|
+
if (effective.localExists) {
|
|
166
|
+
stdout.write(`local=${effective.localPath}\n`);
|
|
167
|
+
} else {
|
|
168
|
+
stdout.write(`local=(none — using models.defaults.json)\n`);
|
|
169
|
+
stdout.write(`hint: forge models included|metered # writes .forge/models.local.json\n`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return 0;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
175
|
+
stderr.write(`${msg}\n`);
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const isMain =
|
|
181
|
+
process.argv[1] &&
|
|
182
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
183
|
+
|
|
184
|
+
if (isMain) {
|
|
185
|
+
process.exitCode = runSetModels(process.argv.slice(2));
|
|
186
|
+
}
|