@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.
Files changed (143) hide show
  1. package/bin/forge.mjs +100 -0
  2. package/bin/forgekit.mjs +84 -0
  3. package/bin/review.mjs +82 -0
  4. package/package.json +46 -0
  5. package/scripts/prepack.mjs +78 -0
  6. package/scripts/run-tests.mjs +43 -0
  7. package/src/adr.mjs +236 -0
  8. package/src/adr.test.mjs +170 -0
  9. package/src/change.mjs +234 -0
  10. package/src/change.test.mjs +83 -0
  11. package/src/cleanup-sessions.mjs +70 -0
  12. package/src/config.mjs +103 -0
  13. package/src/defer.mjs +75 -0
  14. package/src/doctor.mjs +341 -0
  15. package/src/doctor.test.mjs +114 -0
  16. package/src/init.mjs +575 -0
  17. package/src/install.mjs +777 -0
  18. package/src/install.test.mjs +104 -0
  19. package/src/integrity-check.mjs +58 -0
  20. package/src/integrity.mjs +317 -0
  21. package/src/integrity.test.mjs +296 -0
  22. package/src/lib/workspaces.mjs +55 -0
  23. package/src/lib.mjs +138 -0
  24. package/src/models.defaults.json +41 -0
  25. package/src/new-session.mjs +82 -0
  26. package/src/openspec-overlays/README.md +19 -0
  27. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -0
  28. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -0
  29. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -0
  30. package/src/paths.mjs +92 -0
  31. package/src/plan-engine.mjs +260 -0
  32. package/src/plan-engine.test.mjs +245 -0
  33. package/src/preferences.defaults.json +78 -0
  34. package/src/preferences.mjs +438 -0
  35. package/src/preferences.test.mjs +174 -0
  36. package/src/record-evidence.mjs +204 -0
  37. package/src/record-evidence.test.mjs +260 -0
  38. package/src/resolve-model.mjs +312 -0
  39. package/src/resolve-model.test.mjs +194 -0
  40. package/src/review/carryforward.mjs +413 -0
  41. package/src/review/carryforward.test.mjs +587 -0
  42. package/src/review/cli.test.mjs +117 -0
  43. package/src/review/export.mjs +172 -0
  44. package/src/review/export.test.mjs +197 -0
  45. package/src/review/fixtures/valid-review.json +42 -0
  46. package/src/review/lib.mjs +894 -0
  47. package/src/review/lib.test.mjs +266 -0
  48. package/src/review/merge-tentative.mjs +292 -0
  49. package/src/review/merge-tentative.test.mjs +363 -0
  50. package/src/review/new-review.mjs +200 -0
  51. package/src/review/render.mjs +108 -0
  52. package/src/review/schema-consistency.test.mjs +83 -0
  53. package/src/review/schema.json +196 -0
  54. package/src/review/signals.mjs +144 -0
  55. package/src/review/signals.test.mjs +62 -0
  56. package/src/score-cli.mjs +68 -0
  57. package/src/score.mjs +489 -0
  58. package/src/score.test.mjs +253 -0
  59. package/src/session-reminder.mjs +168 -0
  60. package/src/session-status.mjs +70 -0
  61. package/src/set-models.mjs +186 -0
  62. package/src/set-phase.mjs +177 -0
  63. package/src/set-phase.test.mjs +317 -0
  64. package/src/set-prefs.mjs +294 -0
  65. package/src/spine.mjs +91 -0
  66. package/src/triage-prompt.mjs +175 -0
  67. package/src/triage-prompt.test.mjs +50 -0
  68. package/src/vendor-openspec-overlays.mjs +176 -0
  69. package/src/vendor-openspec-overlays.test.mjs +62 -0
  70. package/vendor/skills/archive-to-adr/SKILL.md +149 -0
  71. package/vendor/skills/forge/SKILL.md +136 -0
  72. package/vendor/skills/forge/phases/brainstorm.md +23 -0
  73. package/vendor/skills/forge/phases/finish.md +87 -0
  74. package/vendor/skills/forge/phases/implement.md +76 -0
  75. package/vendor/skills/forge/phases/plan-openspec.md +40 -0
  76. package/vendor/skills/forge/phases/plan-specs.md +97 -0
  77. package/vendor/skills/forge/phases/review.md +25 -0
  78. package/vendor/skills/forge/phases/verify.md +120 -0
  79. package/vendor/skills/forge/references/forge-layout.md +85 -0
  80. package/vendor/skills/forge/references/pace.md +115 -0
  81. package/vendor/skills/forge/references/plan-routing.md +51 -0
  82. package/vendor/skills/forge/references/runtime-integrity.md +157 -0
  83. package/vendor/skills/forge/references/substantial-work.md +37 -0
  84. package/vendor/skills/forge/references/tdd-core.md +29 -0
  85. package/vendor/skills/forge/references/test-evidence.md +30 -0
  86. package/vendor/skills/forge/references/test-strategy.md +68 -0
  87. package/vendor/skills/forge/skills/NOTICE.md +17 -0
  88. package/vendor/skills/forge/skills/brainstorming/SKILL.md +120 -0
  89. package/vendor/skills/forge/skills/requesting-code-review/SKILL.md +67 -0
  90. package/vendor/skills/forge/skills/requesting-code-review/code-reviewer.md +146 -0
  91. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -0
  92. package/vendor/skills/forge/skills/systematic-debugging/SKILL.md +234 -0
  93. package/vendor/skills/forge/skills/systematic-debugging/condition-based-waiting.md +115 -0
  94. package/vendor/skills/forge/skills/systematic-debugging/defense-in-depth.md +122 -0
  95. package/vendor/skills/forge/skills/systematic-debugging/find-polluter.sh +63 -0
  96. package/vendor/skills/forge/skills/systematic-debugging/root-cause-tracing.md +169 -0
  97. package/vendor/skills/forge/skills/test-driven-development/SKILL.md +290 -0
  98. package/vendor/skills/forge/skills/test-driven-development/testing-anti-patterns.md +299 -0
  99. package/vendor/skills/forge/skills/verification-before-completion/SKILL.md +59 -0
  100. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +53 -0
  101. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -0
  102. package/vendor/skills/forge/subagents/task-reviewer-prompt.md +61 -0
  103. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -0
  104. package/vendor/skills/thorough-code-review/SKILL.md +290 -0
  105. package/vendor/skills/thorough-code-review/examples/accepted-risks-janus.md +32 -0
  106. package/vendor/skills/thorough-code-review/examples.md +133 -0
  107. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -0
  108. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -0
  109. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -0
  110. package/vendor/skills/thorough-code-review/reference/phase1c-coverage.md +44 -0
  111. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -0
  112. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -0
  113. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -0
  114. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -0
  115. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -0
  116. package/vendor/templates/adr/README.md +7 -0
  117. package/vendor/templates/adr/decisions.md +141 -0
  118. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -0
  119. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -0
  120. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -0
  121. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -0
  122. package/vendor/templates/project/claude/commands/forge-apply.md +75 -0
  123. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -0
  124. package/vendor/templates/project/claude/commands/forge-build.md +17 -0
  125. package/vendor/templates/project/claude/commands/forge-plan.md +12 -0
  126. package/vendor/templates/project/claude/commands/forge-skip.md +14 -0
  127. package/vendor/templates/project/claude/commands/forge-status.md +16 -0
  128. package/vendor/templates/project/claude/commands/forge.md +16 -0
  129. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -0
  130. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -0
  131. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -0
  132. package/vendor/templates/project/claude/rules/forge.md +16 -0
  133. package/vendor/templates/project/codex/rules/forge.md +10 -0
  134. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -0
  135. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -0
  136. package/vendor/templates/project/cursor/commands/forge-build.md +17 -0
  137. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -0
  138. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -0
  139. package/vendor/templates/project/cursor/commands/forge-status.md +16 -0
  140. package/vendor/templates/project/cursor/commands/forge.md +16 -0
  141. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -0
  142. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -0
  143. 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
+ }