@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,296 @@
|
|
|
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 {
|
|
7
|
+
JOBS_SIGNAL_RE,
|
|
8
|
+
addDeferral,
|
|
9
|
+
initSpine,
|
|
10
|
+
loadDeferrals,
|
|
11
|
+
openDeferrals,
|
|
12
|
+
resolveChangeDir,
|
|
13
|
+
resolveDeferral,
|
|
14
|
+
runIntegrityChecks,
|
|
15
|
+
sessionJobsSignalText,
|
|
16
|
+
spinePath,
|
|
17
|
+
spineTemplate,
|
|
18
|
+
validateSpine,
|
|
19
|
+
} from './integrity.mjs';
|
|
20
|
+
|
|
21
|
+
function tmp(prefix) {
|
|
22
|
+
return fs.mkdtempSync(path.join(tmpdir(), prefix));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validRow(overrides = {}) {
|
|
26
|
+
return {
|
|
27
|
+
capability: 'REQ-GOV-01 matching',
|
|
28
|
+
library: 'services/etl-core/matcher.py',
|
|
29
|
+
runtimeOwner: 'worker job analyze_study',
|
|
30
|
+
writes: 'study_proposals',
|
|
31
|
+
reads: 'N/A',
|
|
32
|
+
uiConsumer: 'Proposals page',
|
|
33
|
+
evidence: 'tasks/12-analyze/test-evidence.md',
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('validateSpine: filled rows pass', () => {
|
|
39
|
+
const result = validateSpine({ change: 'x', notApplicable: null, rows: [validRow()] });
|
|
40
|
+
assert.equal(result.ok, true);
|
|
41
|
+
assert.deepEqual(result.problems, []);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('validateSpine: library-only row fails (missing runtimeOwner/writes/evidence)', () => {
|
|
45
|
+
const result = validateSpine({
|
|
46
|
+
rows: [validRow({ runtimeOwner: '', writes: '', evidence: '' })],
|
|
47
|
+
});
|
|
48
|
+
assert.equal(result.ok, false);
|
|
49
|
+
assert.equal(result.problems.length, 3);
|
|
50
|
+
assert.match(result.problems.join('\n'), /runtimeOwner/);
|
|
51
|
+
assert.match(result.problems.join('\n'), /writes/);
|
|
52
|
+
assert.match(result.problems.join('\n'), /evidence/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('validateSpine: scaffold placeholders are rejected', () => {
|
|
56
|
+
const result = validateSpine(spineTemplate({ change: 'x' }));
|
|
57
|
+
assert.equal(result.ok, false);
|
|
58
|
+
assert.match(result.problems.join('\n'), /scaffold placeholder/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('validateSpine: empty rows fail; notApplicable opt-out passes', () => {
|
|
62
|
+
assert.equal(validateSpine({ rows: [] }).ok, false);
|
|
63
|
+
assert.equal(validateSpine({ rows: [], notApplicable: 'docs-only change' }).ok, true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('spine init writes template and refuses overwrite without force', () => {
|
|
67
|
+
const dir = tmp('forge-spine-');
|
|
68
|
+
try {
|
|
69
|
+
const file = path.join(dir, 'spine.json');
|
|
70
|
+
initSpine({ file, change: 'my-change' });
|
|
71
|
+
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
72
|
+
assert.equal(doc.change, 'my-change');
|
|
73
|
+
assert.throws(() => initSpine({ file, change: 'my-change' }), /already exists/);
|
|
74
|
+
initSpine({ file, change: 'other', force: true });
|
|
75
|
+
assert.equal(JSON.parse(fs.readFileSync(file, 'utf8')).change, 'other');
|
|
76
|
+
} finally {
|
|
77
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('resolveChangeDir: openspec plan type and session-dir fallback', () => {
|
|
82
|
+
const cwd = tmp('forge-changedir-');
|
|
83
|
+
try {
|
|
84
|
+
const dir = resolveChangeDir({
|
|
85
|
+
cwd,
|
|
86
|
+
session: { planType: 'openspec', openspecChange: 'my-change' },
|
|
87
|
+
});
|
|
88
|
+
assert.equal(dir, path.join(cwd, 'openspec', 'changes', 'my-change'));
|
|
89
|
+
assert.equal(resolveChangeDir({ cwd, session: { openspecChange: null } }), null);
|
|
90
|
+
const sessionDir = path.join(cwd, '.forge', 'sessions', 's1');
|
|
91
|
+
assert.equal(
|
|
92
|
+
spinePath({ cwd, session: { openspecChange: null }, sessionDir }),
|
|
93
|
+
path.join(sessionDir, 'spine.json'),
|
|
94
|
+
);
|
|
95
|
+
} finally {
|
|
96
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('deferrals: add, list, resolve lifecycle', () => {
|
|
101
|
+
const dir = tmp('forge-defer-');
|
|
102
|
+
try {
|
|
103
|
+
addDeferral(dir, { task: '9.2', reason: 'wiring lands in 9.7' });
|
|
104
|
+
assert.equal(openDeferrals(dir).length, 1);
|
|
105
|
+
assert.throws(() => addDeferral(dir, { task: '9.2', reason: 'dup' }), /already open/);
|
|
106
|
+
resolveDeferral(dir, '9.2');
|
|
107
|
+
assert.equal(openDeferrals(dir).length, 0);
|
|
108
|
+
assert.equal(loadDeferrals(dir).deferrals.length, 1);
|
|
109
|
+
assert.throws(() => resolveDeferral(dir, '9.2'), /No open deferral/);
|
|
110
|
+
} finally {
|
|
111
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('deferrals: add requires task and reason', () => {
|
|
116
|
+
const dir = tmp('forge-defer-req-');
|
|
117
|
+
try {
|
|
118
|
+
assert.throws(() => addDeferral(dir, { task: '', reason: 'x' }), /--task/);
|
|
119
|
+
assert.throws(() => addDeferral(dir, { task: '1.1', reason: '' }), /--reason/);
|
|
120
|
+
} finally {
|
|
121
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('JOBS_SIGNAL_RE + sessionJobsSignalText', () => {
|
|
126
|
+
assert.equal(JOBS_SIGNAL_RE.test('etl-surveydb-harmonization-platform worker'), true);
|
|
127
|
+
assert.equal(JOBS_SIGNAL_RE.test('fix toolbar padding'), false);
|
|
128
|
+
assert.equal(
|
|
129
|
+
sessionJobsSignalText({ paceSignal: null, slug: 'my-slug', openspecChange: 'chg' }),
|
|
130
|
+
'my-slug chg',
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function makeSessionDir(root) {
|
|
135
|
+
const dir = path.join(root, '.forge', 'sessions', 's1');
|
|
136
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
137
|
+
return dir;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
test('runIntegrityChecks: missing spine always fails (not keyword-gated)', () => {
|
|
141
|
+
const cwd = tmp('forge-int-clean-');
|
|
142
|
+
try {
|
|
143
|
+
const sessionDir = makeSessionDir(cwd);
|
|
144
|
+
const result = runIntegrityChecks({
|
|
145
|
+
cwd,
|
|
146
|
+
sessionDir,
|
|
147
|
+
session: { slug: 'fix-toolbar', openspecChange: null },
|
|
148
|
+
});
|
|
149
|
+
assert.equal(result.ok, false);
|
|
150
|
+
assert.match(result.problems.join('\n'), /spine\.json required/);
|
|
151
|
+
} finally {
|
|
152
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('runIntegrityChecks: notApplicable spine allows sync-only without product-loop', () => {
|
|
157
|
+
const cwd = tmp('forge-int-na-sync-');
|
|
158
|
+
try {
|
|
159
|
+
const sessionDir = makeSessionDir(cwd);
|
|
160
|
+
fs.writeFileSync(
|
|
161
|
+
path.join(sessionDir, 'spine.json'),
|
|
162
|
+
`${JSON.stringify(
|
|
163
|
+
{ rows: [], notApplicable: 'sync HTTP only — no async producer/consumer' },
|
|
164
|
+
null,
|
|
165
|
+
2,
|
|
166
|
+
)}\n`,
|
|
167
|
+
'utf8',
|
|
168
|
+
);
|
|
169
|
+
const result = runIntegrityChecks({
|
|
170
|
+
cwd,
|
|
171
|
+
sessionDir,
|
|
172
|
+
session: { slug: 'fix-toolbar', openspecChange: null },
|
|
173
|
+
});
|
|
174
|
+
assert.equal(result.ok, true);
|
|
175
|
+
} finally {
|
|
176
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('runIntegrityChecks: unresolved deferral fails', () => {
|
|
181
|
+
const cwd = tmp('forge-int-defer-');
|
|
182
|
+
try {
|
|
183
|
+
const sessionDir = makeSessionDir(cwd);
|
|
184
|
+
fs.writeFileSync(
|
|
185
|
+
path.join(sessionDir, 'spine.json'),
|
|
186
|
+
`${JSON.stringify({ rows: [], notApplicable: 'sync only' }, null, 2)}\n`,
|
|
187
|
+
'utf8',
|
|
188
|
+
);
|
|
189
|
+
addDeferral(sessionDir, { task: '9.2', reason: 'later' });
|
|
190
|
+
const result = runIntegrityChecks({
|
|
191
|
+
cwd,
|
|
192
|
+
sessionDir,
|
|
193
|
+
session: { slug: 'fix-toolbar', openspecChange: null },
|
|
194
|
+
});
|
|
195
|
+
assert.equal(result.ok, false);
|
|
196
|
+
assert.match(result.problems.join('\n'), /unresolved deferrals: 9\.2/);
|
|
197
|
+
} finally {
|
|
198
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('runIntegrityChecks: empty slug without spine still fails', () => {
|
|
203
|
+
const cwd = tmp('forge-int-jobs-');
|
|
204
|
+
try {
|
|
205
|
+
const sessionDir = makeSessionDir(cwd);
|
|
206
|
+
const result = runIntegrityChecks({
|
|
207
|
+
cwd,
|
|
208
|
+
sessionDir,
|
|
209
|
+
session: { slug: 'add-harmonization-platform', openspecChange: null },
|
|
210
|
+
});
|
|
211
|
+
assert.equal(result.ok, false);
|
|
212
|
+
assert.match(result.problems.join('\n'), /spine\.json required/);
|
|
213
|
+
} finally {
|
|
214
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('runIntegrityChecks: spine rows demand product-loop evidence', () => {
|
|
219
|
+
const cwd = tmp('forge-int-loop-');
|
|
220
|
+
try {
|
|
221
|
+
const sessionDir = makeSessionDir(cwd);
|
|
222
|
+
const spineFile = path.join(sessionDir, 'spine.json');
|
|
223
|
+
fs.writeFileSync(
|
|
224
|
+
spineFile,
|
|
225
|
+
`${JSON.stringify({ change: null, notApplicable: null, rows: [validRow()] }, null, 2)}\n`,
|
|
226
|
+
'utf8',
|
|
227
|
+
);
|
|
228
|
+
const session = { slug: 'wire-worker-jobs', openspecChange: null };
|
|
229
|
+
|
|
230
|
+
let result = runIntegrityChecks({ cwd, sessionDir, session });
|
|
231
|
+
assert.equal(result.ok, false);
|
|
232
|
+
assert.match(result.problems.join('\n'), /verify-evidence\.md missing/);
|
|
233
|
+
|
|
234
|
+
const evidenceFile = path.join(sessionDir, 'verify-evidence.md');
|
|
235
|
+
fs.writeFileSync(evidenceFile, '# Verify\n\ntier 3 green\n', 'utf8');
|
|
236
|
+
result = runIntegrityChecks({ cwd, sessionDir, session });
|
|
237
|
+
assert.equal(result.ok, false);
|
|
238
|
+
assert.match(result.problems.join('\n'), /no "Product loop" section/);
|
|
239
|
+
|
|
240
|
+
fs.writeFileSync(evidenceFile, '# Verify\n\n## Product loop\n\nBLOCKED: no compose here\n', 'utf8');
|
|
241
|
+
result = runIntegrityChecks({ cwd, sessionDir, session });
|
|
242
|
+
assert.equal(result.ok, false);
|
|
243
|
+
assert.match(result.problems.join('\n'), /BLOCKED/);
|
|
244
|
+
|
|
245
|
+
fs.writeFileSync(
|
|
246
|
+
evidenceFile,
|
|
247
|
+
'# Verify\n\n## Product loop\n\ningest x3 -> analyze -> ratify -> run@R: output differs from baseline\n',
|
|
248
|
+
'utf8',
|
|
249
|
+
);
|
|
250
|
+
result = runIntegrityChecks({ cwd, sessionDir, session });
|
|
251
|
+
assert.equal(result.ok, true);
|
|
252
|
+
} finally {
|
|
253
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('runIntegrityChecks: invalid spine fails even without jobs signal', () => {
|
|
258
|
+
const cwd = tmp('forge-int-badspine-');
|
|
259
|
+
try {
|
|
260
|
+
const sessionDir = makeSessionDir(cwd);
|
|
261
|
+
fs.writeFileSync(
|
|
262
|
+
path.join(sessionDir, 'spine.json'),
|
|
263
|
+
`${JSON.stringify({ rows: [validRow({ runtimeOwner: '' })] }, null, 2)}\n`,
|
|
264
|
+
'utf8',
|
|
265
|
+
);
|
|
266
|
+
const result = runIntegrityChecks({
|
|
267
|
+
cwd,
|
|
268
|
+
sessionDir,
|
|
269
|
+
session: { slug: 'fix-toolbar', openspecChange: null },
|
|
270
|
+
});
|
|
271
|
+
assert.equal(result.ok, false);
|
|
272
|
+
assert.match(result.problems.join('\n'), /spine: .*runtimeOwner/);
|
|
273
|
+
} finally {
|
|
274
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('runIntegrityChecks: notApplicable spine passes without evidence demands', () => {
|
|
279
|
+
const cwd = tmp('forge-int-na-');
|
|
280
|
+
try {
|
|
281
|
+
const sessionDir = makeSessionDir(cwd);
|
|
282
|
+
fs.writeFileSync(
|
|
283
|
+
path.join(sessionDir, 'spine.json'),
|
|
284
|
+
`${JSON.stringify({ rows: [], notApplicable: 'docs-only change' }, null, 2)}\n`,
|
|
285
|
+
'utf8',
|
|
286
|
+
);
|
|
287
|
+
const result = runIntegrityChecks({
|
|
288
|
+
cwd,
|
|
289
|
+
sessionDir,
|
|
290
|
+
session: { slug: 'wire-worker-jobs', openspecChange: null },
|
|
291
|
+
});
|
|
292
|
+
assert.equal(result.ok, true);
|
|
293
|
+
} finally {
|
|
294
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Shared workspace loader for repo-level scripts.
|
|
4
|
+
*
|
|
5
|
+
* Reads the root `package.json` `workspaces` globs, expands any trailing `/*`
|
|
6
|
+
* form, and returns `{ name, dir, scripts }` for every member package.json that
|
|
7
|
+
* declares a `name`. `dir` is repo-relative with forward slashes. Unreadable or
|
|
8
|
+
* name-less member manifests are skipped.
|
|
9
|
+
*
|
|
10
|
+
* Extracted from the duplicate copies that lived in
|
|
11
|
+
* `scripts/review/signals.mjs` and `scripts/code-health/apply-gate.mjs`; both
|
|
12
|
+
* (and `scripts/code-health/detect.mjs`) now import it from here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} repoRoot
|
|
20
|
+
* @returns {Array<{ name: string, dir: string, scripts: Record<string, string> }>}
|
|
21
|
+
*/
|
|
22
|
+
export function loadWorkspaces(repoRoot) {
|
|
23
|
+
const rootPkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
|
24
|
+
const globs = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : [];
|
|
25
|
+
/** @type {string[]} */
|
|
26
|
+
const dirs = [];
|
|
27
|
+
|
|
28
|
+
for (const glob of globs) {
|
|
29
|
+
if (glob.endsWith('/*')) {
|
|
30
|
+
const base = glob.slice(0, -2);
|
|
31
|
+
const baseDir = path.join(repoRoot, base);
|
|
32
|
+
if (!fs.existsSync(baseDir)) continue;
|
|
33
|
+
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
|
34
|
+
if (entry.isDirectory()) dirs.push(`${base}/${entry.name}`);
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
dirs.push(glob);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const packages = [];
|
|
42
|
+
for (const dir of dirs) {
|
|
43
|
+
const pkgPath = path.join(repoRoot, dir, 'package.json');
|
|
44
|
+
if (!fs.existsSync(pkgPath)) continue;
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
47
|
+
if (pkg.name) {
|
|
48
|
+
packages.push({ name: pkg.name, dir: dir.replace(/\\/g, '/'), scripts: pkg.scripts ?? {} });
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
/* skip unreadable package.json */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return packages;
|
|
55
|
+
}
|
package/src/lib.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Shared helpers for Forge session management.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const REPO_ROOT = process.cwd();
|
|
11
|
+
export const FORGE_DIR = path.join(REPO_ROOT, '.forge');
|
|
12
|
+
export const SESSIONS_DIR = path.join(FORGE_DIR, 'sessions');
|
|
13
|
+
export const ACTIVE_FILE = path.join(FORGE_DIR, 'active.json');
|
|
14
|
+
export const RETENTION_DAYS = 14;
|
|
15
|
+
|
|
16
|
+
export function ensureForgeLayout() {
|
|
17
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function slugify(input) {
|
|
21
|
+
return String(input)
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
24
|
+
.replace(/^-+|-+$/g, '')
|
|
25
|
+
.slice(0, 48) || 'session';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function utcCompactNow() {
|
|
29
|
+
return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function randomSuffix(bytes = 3) {
|
|
33
|
+
return crypto.randomBytes(bytes).toString('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function makeSessionId(slug) {
|
|
37
|
+
return `${utcCompactNow()}-${slugify(slug)}-${randomSuffix()}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sessionPath(sessionId) {
|
|
41
|
+
return path.join(SESSIONS_DIR, sessionId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readJson(filePath) {
|
|
45
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function writeJson(filePath, data) {
|
|
49
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
50
|
+
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readActive() {
|
|
54
|
+
if (!fs.existsSync(ACTIVE_FILE)) return null;
|
|
55
|
+
try {
|
|
56
|
+
return readJson(ACTIVE_FILE);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function writeActive(sessionId) {
|
|
63
|
+
writeJson(ACTIVE_FILE, {
|
|
64
|
+
sessionId,
|
|
65
|
+
sessionPath: path.relative(REPO_ROOT, sessionPath(sessionId)).replace(/\\/g, '/'),
|
|
66
|
+
updatedAt: new Date().toISOString(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function clearActive() {
|
|
71
|
+
if (fs.existsSync(ACTIVE_FILE)) fs.unlinkSync(ACTIVE_FILE);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function defaultSession(sessionId, slug) {
|
|
75
|
+
const now = new Date().toISOString();
|
|
76
|
+
return {
|
|
77
|
+
id: sessionId,
|
|
78
|
+
slug: slugify(slug),
|
|
79
|
+
createdAt: now,
|
|
80
|
+
updatedAt: now,
|
|
81
|
+
phase: 'triage',
|
|
82
|
+
planType: null,
|
|
83
|
+
openspecChange: null,
|
|
84
|
+
forgeSkipped: false,
|
|
85
|
+
cursorChatId: null,
|
|
86
|
+
tasksTotal: 0,
|
|
87
|
+
tasksComplete: 0,
|
|
88
|
+
/** Requested pace: auto | thorough | standard | brisk | lite */
|
|
89
|
+
pace: 'auto',
|
|
90
|
+
/** Concrete pace after auto resolve or explicit pin */
|
|
91
|
+
resolvedPace: null,
|
|
92
|
+
paceReason: null,
|
|
93
|
+
paceSignal: null,
|
|
94
|
+
pacePinned: false,
|
|
95
|
+
preferencesOverride: null,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function defaultStatus(session) {
|
|
100
|
+
return {
|
|
101
|
+
sessionId: session.id,
|
|
102
|
+
phase: session.phase,
|
|
103
|
+
planType: session.planType,
|
|
104
|
+
openspecChange: session.openspecChange,
|
|
105
|
+
tasksTotal: session.tasksTotal,
|
|
106
|
+
tasksComplete: session.tasksComplete,
|
|
107
|
+
pace: session.pace ?? null,
|
|
108
|
+
resolvedPace: session.resolvedPace ?? null,
|
|
109
|
+
paceReason: session.paceReason ?? null,
|
|
110
|
+
updatedAt: session.updatedAt,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function scaffoldSessionDirs(dir) {
|
|
115
|
+
for (const sub of ['brainstorm', 'tasks', 'reviews']) {
|
|
116
|
+
fs.mkdirSync(path.join(dir, sub), { recursive: true });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function loadSession(sessionId) {
|
|
121
|
+
const dir = sessionPath(sessionId);
|
|
122
|
+
const file = path.join(dir, 'session.json');
|
|
123
|
+
if (!fs.existsSync(file)) {
|
|
124
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
125
|
+
}
|
|
126
|
+
return { dir, session: readJson(file) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function saveSession(dir, session) {
|
|
130
|
+
session.updatedAt = new Date().toISOString();
|
|
131
|
+
writeJson(path.join(dir, 'session.json'), session);
|
|
132
|
+
writeJson(path.join(dir, 'status.json'), defaultStatus(session));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function sessionAgeDays(session) {
|
|
136
|
+
const created = new Date(session.createdAt).getTime();
|
|
137
|
+
return (Date.now() - created) / (1000 * 60 * 60 * 24);
|
|
138
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"billing": "included",
|
|
3
|
+
"agents": {
|
|
4
|
+
"cursor": {
|
|
5
|
+
"included": {
|
|
6
|
+
"fast": "inherit",
|
|
7
|
+
"standard": "inherit",
|
|
8
|
+
"capable": "inherit"
|
|
9
|
+
},
|
|
10
|
+
"metered": {
|
|
11
|
+
"fast": "gpt-5.6-terra-medium",
|
|
12
|
+
"standard": "claude-sonnet-5-thinking-high",
|
|
13
|
+
"capable": "claude-opus-4-8-thinking-high"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"claude-code": {
|
|
17
|
+
"included": {
|
|
18
|
+
"fast": "haiku",
|
|
19
|
+
"standard": "sonnet",
|
|
20
|
+
"capable": "opus"
|
|
21
|
+
},
|
|
22
|
+
"metered": {
|
|
23
|
+
"fast": "haiku",
|
|
24
|
+
"standard": "sonnet",
|
|
25
|
+
"capable": "opus"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"codex": {
|
|
29
|
+
"included": {
|
|
30
|
+
"fast": "inherit",
|
|
31
|
+
"standard": "inherit",
|
|
32
|
+
"capable": "inherit"
|
|
33
|
+
},
|
|
34
|
+
"metered": {
|
|
35
|
+
"fast": "inherit",
|
|
36
|
+
"standard": "inherit",
|
|
37
|
+
"capable": "inherit"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Create a new Forge session under .forge/sessions/ and set it active.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* forge new mercury-console-validation
|
|
7
|
+
* forge new <slug> [--chat-id <id>] [--signal <text>]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
defaultSession,
|
|
12
|
+
defaultStatus,
|
|
13
|
+
ensureForgeLayout,
|
|
14
|
+
FORGE_DIR,
|
|
15
|
+
makeSessionId,
|
|
16
|
+
saveSession,
|
|
17
|
+
scaffoldSessionDirs,
|
|
18
|
+
sessionPath,
|
|
19
|
+
writeActive,
|
|
20
|
+
} from './lib.mjs';
|
|
21
|
+
import { resolveSessionPaceFields } from './preferences.mjs';
|
|
22
|
+
import { warnIfDoctorFails } from './doctor.mjs';
|
|
23
|
+
|
|
24
|
+
function usage() {
|
|
25
|
+
process.stderr.write(
|
|
26
|
+
'Usage: forge new <slug> [--chat-id <id>] [--signal <text>]\n',
|
|
27
|
+
);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const args = process.argv.slice(2);
|
|
32
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') usage();
|
|
33
|
+
|
|
34
|
+
const slug = args[0];
|
|
35
|
+
let cursorChatId = null;
|
|
36
|
+
let signalText = null;
|
|
37
|
+
for (let i = 1; i < args.length; i += 1) {
|
|
38
|
+
if (args[i] === '--chat-id' && args[i + 1]) {
|
|
39
|
+
cursorChatId = args[i + 1];
|
|
40
|
+
i += 1;
|
|
41
|
+
} else if (args[i] === '--signal' && args[i + 1]) {
|
|
42
|
+
signalText = args[i + 1];
|
|
43
|
+
i += 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
ensureForgeLayout();
|
|
48
|
+
warnIfDoctorFails({ cwd: process.cwd() });
|
|
49
|
+
|
|
50
|
+
const sessionId = makeSessionId(slug);
|
|
51
|
+
const dir = sessionPath(sessionId);
|
|
52
|
+
scaffoldSessionDirs(dir);
|
|
53
|
+
|
|
54
|
+
const session = defaultSession(sessionId, slug);
|
|
55
|
+
if (cursorChatId) session.cursorChatId = cursorChatId;
|
|
56
|
+
|
|
57
|
+
const paceFields = resolveSessionPaceFields({
|
|
58
|
+
forgeDir: FORGE_DIR,
|
|
59
|
+
slug: session.slug,
|
|
60
|
+
signalText: signalText || session.slug,
|
|
61
|
+
});
|
|
62
|
+
Object.assign(session, paceFields);
|
|
63
|
+
|
|
64
|
+
saveSession(dir, session);
|
|
65
|
+
writeActive(sessionId);
|
|
66
|
+
|
|
67
|
+
process.stdout.write(
|
|
68
|
+
`${JSON.stringify(
|
|
69
|
+
{
|
|
70
|
+
sessionId,
|
|
71
|
+
dir,
|
|
72
|
+
session: defaultStatus(session),
|
|
73
|
+
pace: {
|
|
74
|
+
requested: session.pace,
|
|
75
|
+
resolved: session.resolvedPace,
|
|
76
|
+
reason: session.paceReason,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
null,
|
|
80
|
+
2,
|
|
81
|
+
)}\n`,
|
|
82
|
+
);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# OpenSpec Forge overlays
|
|
2
|
+
|
|
3
|
+
Vendor OpenSpec skills and `/opsx:*` commands are upgraded in place. **Do not hand-edit** them.
|
|
4
|
+
|
|
5
|
+
Forge adaptations live here and are applied by:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
forge overlay
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Run after refreshing OpenSpec skills/commands from upstream (usually via `forge init --overlay`).
|
|
12
|
+
|
|
13
|
+
| Overlay | Targets |
|
|
14
|
+
| ------- | ------- |
|
|
15
|
+
| `opsx-apply-implement-step.md` | Step 6 in `opsx-apply` / `opsx/apply` commands |
|
|
16
|
+
| `opsx-apply-completion-step.md` | Step 7 “all done” bullet in same commands |
|
|
17
|
+
| `openspec-apply-change-footer.md` | Footer on `openspec-apply-change/SKILL.md` (all agents) |
|
|
18
|
+
|
|
19
|
+
**Forge-owned (never overwritten):** `/forge:apply` command, `forge/` skill tree.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
|
|
2
|
+
---
|
|
3
|
+
|
|
4
|
+
<!-- forgekit:openspec-overlay:start -->
|
|
5
|
+
|
|
6
|
+
## Forge overlay (re-applied by `forge overlay`)
|
|
7
|
+
|
|
8
|
+
**Do not implement tasks inline in coordinator context.**
|
|
9
|
+
|
|
10
|
+
1. Prefer **`/forge:apply`** for OpenSpec implement + verify + review.
|
|
11
|
+
2. Otherwise follow vendor steps 1–5 above, then **`{{PHASES_IMPLEMENT}}`** for step 6+.
|
|
12
|
+
3. Re-apply this overlay after upgrading OpenSpec skills: `forge overlay`
|
|
13
|
+
|
|
14
|
+
<!-- forgekit:openspec-overlay:end -->
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- If all done: proceed to Forge [verify]({{PHASES_VERIFY}}) and [review]({{PHASES_REVIEW}}) when in a Forge session (or user invoked `/forge:apply`), then suggest `/opsx:archive`
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
**REQUIRED (Forge):** Wrap this loop in the implement phase —
|
|
2
|
+
`{{PHASES_IMPLEMENT}}`.
|
|
3
|
+
Use bundled `skills/subagent-driven-development` + `skills/test-driven-development` per task.
|
|
4
|
+
Prefer **`/forge:apply`** over bare `/opsx:apply` — same OpenSpec CLI steps plus verify and review.
|
|
5
|
+
|
|
6
|
+
For each pending task:
|
|
7
|
+
- Show which task is being worked on
|
|
8
|
+
- Dispatch **implementer** subagent (TDD first), then **spec** + **quality** reviewers
|
|
9
|
+
- Keep changes minimal and focused
|
|
10
|
+
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
|
11
|
+
- Continue to next task
|