@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,104 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
parseArgs,
|
|
8
|
+
installSkillsToAgents,
|
|
9
|
+
listInstallStatus,
|
|
10
|
+
uninstallSkillsFromAgents,
|
|
11
|
+
updateOutdatedSkills,
|
|
12
|
+
readInstallStamp,
|
|
13
|
+
FORGEKIT_STAMP,
|
|
14
|
+
SKILL_IDS,
|
|
15
|
+
AGENT_IDS,
|
|
16
|
+
} from './install.mjs';
|
|
17
|
+
|
|
18
|
+
test('parseArgs supports multi skills and agents', () => {
|
|
19
|
+
const opts = parseArgs([
|
|
20
|
+
'--skills',
|
|
21
|
+
'forge,thorough-code-review',
|
|
22
|
+
'--agents',
|
|
23
|
+
'cursor,claude',
|
|
24
|
+
'--force',
|
|
25
|
+
]);
|
|
26
|
+
assert.deepEqual(opts.skills, ['forge', 'thorough-code-review']);
|
|
27
|
+
assert.deepEqual(opts.agents, ['cursor', 'claude']);
|
|
28
|
+
assert.equal(opts.force, true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('parseArgs accepts --skill singular and shorthand agents', () => {
|
|
32
|
+
const opts = parseArgs(['--skill', 'forge', '--cursor', '--codex']);
|
|
33
|
+
assert.deepEqual(opts.skills, ['forge']);
|
|
34
|
+
assert.deepEqual(opts.agents, ['cursor', 'codex']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('parseArgs --all-skills / --all-agents / --update / --uninstall', () => {
|
|
38
|
+
const opts = parseArgs(['--all-skills', '--all-agents', '--update']);
|
|
39
|
+
assert.equal(opts.allSkills, true);
|
|
40
|
+
assert.equal(opts.allAgents, true);
|
|
41
|
+
assert.equal(opts.update, true);
|
|
42
|
+
assert.equal(parseArgs(['--uninstall']).uninstall, true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('installSkillsToAgents installs and stamps .forgekit.json', () => {
|
|
46
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-install-'));
|
|
47
|
+
try {
|
|
48
|
+
const results = installSkillsToAgents(['forge'], ['cursor', 'claude'], {
|
|
49
|
+
home,
|
|
50
|
+
force: true,
|
|
51
|
+
});
|
|
52
|
+
assert.equal(results.length, 2);
|
|
53
|
+
assert.ok(results.every((r) => r.status === 'installed'));
|
|
54
|
+
const dest = path.join(home, '.cursor', 'skills', 'forge');
|
|
55
|
+
assert.ok(fs.existsSync(path.join(dest, 'SKILL.md')));
|
|
56
|
+
assert.ok(fs.existsSync(path.join(dest, FORGEKIT_STAMP)));
|
|
57
|
+
const stamp = readInstallStamp(dest);
|
|
58
|
+
assert.equal(stamp.skill, 'forge');
|
|
59
|
+
assert.ok(stamp.contentHash);
|
|
60
|
+
assert.ok(stamp.version);
|
|
61
|
+
|
|
62
|
+
const again = installSkillsToAgents(['forge'], ['cursor'], { home });
|
|
63
|
+
assert.equal(again[0].status, 'exists');
|
|
64
|
+
} finally {
|
|
65
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('listInstallStatus covers every skill×agent', () => {
|
|
70
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-list-'));
|
|
71
|
+
try {
|
|
72
|
+
const rows = listInstallStatus({ home });
|
|
73
|
+
assert.equal(rows.length, SKILL_IDS.length * AGENT_IDS.length);
|
|
74
|
+
assert.ok(rows.every((r) => r.status === 'missing'));
|
|
75
|
+
} finally {
|
|
76
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('uninstallSkillsFromAgents removes installed dirs', () => {
|
|
81
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-uninst-'));
|
|
82
|
+
try {
|
|
83
|
+
installSkillsToAgents(['forge'], ['cursor'], { home, force: true });
|
|
84
|
+
const results = uninstallSkillsFromAgents(['forge'], ['cursor'], { home });
|
|
85
|
+
assert.equal(results[0].status, 'removed');
|
|
86
|
+
assert.ok(!fs.existsSync(path.join(home, '.cursor', 'skills', 'forge')));
|
|
87
|
+
} finally {
|
|
88
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('updateOutdatedSkills refreshes unversioned installs', () => {
|
|
93
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-upd-'));
|
|
94
|
+
try {
|
|
95
|
+
installSkillsToAgents(['forge'], ['cursor'], { home, force: true });
|
|
96
|
+
const dest = path.join(home, '.cursor', 'skills', 'forge');
|
|
97
|
+
fs.unlinkSync(path.join(dest, FORGEKIT_STAMP));
|
|
98
|
+
const { results } = updateOutdatedSkills({ home });
|
|
99
|
+
assert.ok(results.some((r) => r.skill === 'forge' && r.status === 'installed'));
|
|
100
|
+
assert.ok(fs.existsSync(path.join(dest, FORGEKIT_STAMP)));
|
|
101
|
+
} finally {
|
|
102
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forge integrity check — mechanical gate behind `forge phase done|finish`.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* forge integrity-check [--session <id>]
|
|
7
|
+
*
|
|
8
|
+
* Fails (exit 1) when:
|
|
9
|
+
* - deferrals are unresolved
|
|
10
|
+
* - spine.json is required (jobs/workers in scope) but missing or invalid
|
|
11
|
+
* - a spine with rows exists but verify-evidence.md lacks a product-loop
|
|
12
|
+
* section, or contains an explicit BLOCKED marker
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { loadSession, readActive } from './lib.mjs';
|
|
16
|
+
import { runIntegrityChecks } from './integrity.mjs';
|
|
17
|
+
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
if (args[0] === '--help') {
|
|
20
|
+
process.stdout.write('Usage: forge integrity-check [--session <id>]\n');
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let sessionId = null;
|
|
25
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
26
|
+
if (args[i] === '--session' && args[i + 1]) {
|
|
27
|
+
sessionId = args[i + 1];
|
|
28
|
+
i += 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!sessionId) {
|
|
33
|
+
const active = readActive();
|
|
34
|
+
sessionId = active?.sessionId;
|
|
35
|
+
}
|
|
36
|
+
if (!sessionId) {
|
|
37
|
+
process.stderr.write('No active session. Run forge new first.\n');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { dir, session } = loadSession(sessionId);
|
|
42
|
+
const result = runIntegrityChecks({ sessionDir: dir, session });
|
|
43
|
+
|
|
44
|
+
process.stdout.write(
|
|
45
|
+
JSON.stringify(
|
|
46
|
+
{
|
|
47
|
+
sessionId,
|
|
48
|
+
ok: result.ok,
|
|
49
|
+
problems: result.problems,
|
|
50
|
+
spineFile: result.spineFile,
|
|
51
|
+
spineExists: result.spineExists,
|
|
52
|
+
},
|
|
53
|
+
null,
|
|
54
|
+
2,
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
process.stdout.write('\n');
|
|
58
|
+
process.exit(result.ok ? 0 : 1);
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge runtime-integrity mechanics: spine matrix, deferral registry,
|
|
3
|
+
* and the integrity checks that gate `forge phase done|finish`.
|
|
4
|
+
*
|
|
5
|
+
* Spine matrix — `spine.json` in the change dir (or session dir when the
|
|
6
|
+
* session has no tracked change). One row per capability/REQ cluster:
|
|
7
|
+
* library → runtime owner → writes → reads → UI consumer → evidence.
|
|
8
|
+
* Library-only rows (missing runtime owner / writes / evidence) fail
|
|
9
|
+
* validation, so "wire later" cannot be checkboxed past `forge phase done`.
|
|
10
|
+
*
|
|
11
|
+
* Deferral registry — `deferrals.json` in the session dir. Reviewers may only
|
|
12
|
+
* accept "wiring deferred" when a registered deferral names the open task;
|
|
13
|
+
* unresolved deferrals block done/finish.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { readJson, writeJson } from './lib.mjs';
|
|
19
|
+
import { DEFAULT_SPECS_DIR, resolveProjectPlanEngine } from './plan-engine.mjs';
|
|
20
|
+
|
|
21
|
+
/** Signals that a change involves jobs/workers and therefore needs a spine. */
|
|
22
|
+
export const JOBS_SIGNAL_RE =
|
|
23
|
+
/\b(worker|workers|job|jobs|queue|queues|pipeline|pipelines|etl|orchestration|handler|handlers|cron|scheduler|daemon|ingest|dispatch)\b/i;
|
|
24
|
+
|
|
25
|
+
/** Row fields that must be filled (reads/uiConsumer accept "N/A"). */
|
|
26
|
+
export const SPINE_ROW_REQUIRED = Object.freeze([
|
|
27
|
+
'capability',
|
|
28
|
+
'library',
|
|
29
|
+
'runtimeOwner',
|
|
30
|
+
'writes',
|
|
31
|
+
'reads',
|
|
32
|
+
'uiConsumer',
|
|
33
|
+
'evidence',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const SPINE_FILE = 'spine.json';
|
|
37
|
+
const DEFERRALS_FILE = 'deferrals.json';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {unknown} value
|
|
41
|
+
*/
|
|
42
|
+
function isNonEmptyString(value) {
|
|
43
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the change directory for a session (openspec or specs engine).
|
|
48
|
+
* Returns null when the session has no tracked change.
|
|
49
|
+
*
|
|
50
|
+
* @param {{ cwd?: string, session?: Record<string, unknown> | null }} opts
|
|
51
|
+
*/
|
|
52
|
+
export function resolveChangeDir(opts = {}) {
|
|
53
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
54
|
+
const session = opts.session ?? null;
|
|
55
|
+
const change = session && isNonEmptyString(session.openspecChange) ? session.openspecChange : null;
|
|
56
|
+
if (!change) return null;
|
|
57
|
+
|
|
58
|
+
const openspecDir = path.join(cwd, 'openspec', 'changes', change);
|
|
59
|
+
if (session.planType === 'openspec') return openspecDir;
|
|
60
|
+
|
|
61
|
+
let specsRoot = DEFAULT_SPECS_DIR;
|
|
62
|
+
try {
|
|
63
|
+
specsRoot = resolveProjectPlanEngine(cwd, { useUserDefault: false }).dir;
|
|
64
|
+
} catch {
|
|
65
|
+
// keep default
|
|
66
|
+
}
|
|
67
|
+
const specsDir = path.join(cwd, specsRoot, 'changes', change);
|
|
68
|
+
if (session.planType === 'specs') return specsDir;
|
|
69
|
+
|
|
70
|
+
// planType unknown — prefer whichever exists
|
|
71
|
+
if (fs.existsSync(openspecDir)) return openspecDir;
|
|
72
|
+
if (fs.existsSync(specsDir)) return specsDir;
|
|
73
|
+
return openspecDir;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Path to spine.json: change dir when available, else session dir.
|
|
78
|
+
*
|
|
79
|
+
* @param {{ cwd?: string, session?: Record<string, unknown> | null, sessionDir?: string }} opts
|
|
80
|
+
*/
|
|
81
|
+
export function spinePath(opts = {}) {
|
|
82
|
+
const changeDir = resolveChangeDir(opts);
|
|
83
|
+
if (changeDir) return path.join(changeDir, SPINE_FILE);
|
|
84
|
+
if (opts.sessionDir) return path.join(opts.sessionDir, SPINE_FILE);
|
|
85
|
+
throw new Error('Cannot resolve spine.json location: no change and no session dir');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {{ change?: string | null }} [opts]
|
|
90
|
+
*/
|
|
91
|
+
export function spineTemplate(opts = {}) {
|
|
92
|
+
return {
|
|
93
|
+
change: opts.change ?? null,
|
|
94
|
+
notApplicable: null,
|
|
95
|
+
rows: [
|
|
96
|
+
{
|
|
97
|
+
capability: '<REQ id or capability cluster, e.g. REQ-GOV-01 matching>',
|
|
98
|
+
library: '<module path, e.g. services/etl-core/matcher.py>',
|
|
99
|
+
runtimeOwner: '<production caller, e.g. worker job analyze_study>',
|
|
100
|
+
writes: '<artifact/collection, e.g. study_proposals>',
|
|
101
|
+
reads: '<consumed inputs, or N/A>',
|
|
102
|
+
uiConsumer: '<UI/API surface reading the writes, or N/A>',
|
|
103
|
+
evidence: '<tier-2/E2E evidence path proving the wired path>',
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Scaffold spine.json (refuses to overwrite unless force).
|
|
111
|
+
*
|
|
112
|
+
* @param {{ file: string, change?: string | null, force?: boolean }} opts
|
|
113
|
+
*/
|
|
114
|
+
export function initSpine(opts) {
|
|
115
|
+
if (fs.existsSync(opts.file) && !opts.force) {
|
|
116
|
+
throw new Error(`spine.json already exists: ${opts.file} (use --force to overwrite)`);
|
|
117
|
+
}
|
|
118
|
+
writeJson(opts.file, spineTemplate({ change: opts.change }));
|
|
119
|
+
return opts.file;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validate a spine document.
|
|
124
|
+
*
|
|
125
|
+
* Valid when either:
|
|
126
|
+
* - `notApplicable` is a non-empty string (honest opt-out, e.g. docs-only), or
|
|
127
|
+
* - `rows` is a non-empty array where every required cell is filled and no
|
|
128
|
+
* cell still contains scaffold placeholders (`<...>`).
|
|
129
|
+
*
|
|
130
|
+
* @param {unknown} doc
|
|
131
|
+
* @returns {{ ok: boolean, problems: string[] }}
|
|
132
|
+
*/
|
|
133
|
+
export function validateSpine(doc) {
|
|
134
|
+
/** @type {string[]} */
|
|
135
|
+
const problems = [];
|
|
136
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
137
|
+
return { ok: false, problems: ['spine.json is not an object'] };
|
|
138
|
+
}
|
|
139
|
+
const spine = /** @type {Record<string, unknown>} */ (doc);
|
|
140
|
+
|
|
141
|
+
if (isNonEmptyString(spine.notApplicable)) {
|
|
142
|
+
return { ok: true, problems: [] };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const rows = spine.rows;
|
|
146
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
problems: ['spine.rows is empty — add one row per capability, or set notApplicable with a reason'],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
rows.forEach((row, i) => {
|
|
154
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
|
155
|
+
problems.push(`row ${i + 1}: not an object`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const r = /** @type {Record<string, unknown>} */ (row);
|
|
159
|
+
for (const field of SPINE_ROW_REQUIRED) {
|
|
160
|
+
const value = r[field];
|
|
161
|
+
if (!isNonEmptyString(value)) {
|
|
162
|
+
problems.push(`row ${i + 1} (${r.capability ?? '?'}): missing ${field}`);
|
|
163
|
+
} else if (/^<.*>$/.test(value.trim())) {
|
|
164
|
+
problems.push(`row ${i + 1} (${r.capability ?? '?'}): ${field} still has scaffold placeholder`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return { ok: problems.length === 0, problems };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {string} sessionDir
|
|
174
|
+
*/
|
|
175
|
+
export function deferralsPath(sessionDir) {
|
|
176
|
+
return path.join(sessionDir, DEFERRALS_FILE);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string} sessionDir
|
|
181
|
+
* @returns {{ deferrals: Array<{ task: string, reason: string, createdAt: string, resolvedAt: string | null }> }}
|
|
182
|
+
*/
|
|
183
|
+
export function loadDeferrals(sessionDir) {
|
|
184
|
+
const file = deferralsPath(sessionDir);
|
|
185
|
+
if (!fs.existsSync(file)) return { deferrals: [] };
|
|
186
|
+
const doc = readJson(file);
|
|
187
|
+
return { deferrals: Array.isArray(doc?.deferrals) ? doc.deferrals : [] };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {string} sessionDir
|
|
192
|
+
* @param {{ task: string, reason: string }} entry
|
|
193
|
+
*/
|
|
194
|
+
export function addDeferral(sessionDir, entry) {
|
|
195
|
+
if (!isNonEmptyString(entry.task)) throw new Error('Deferral requires --task <id>');
|
|
196
|
+
if (!isNonEmptyString(entry.reason)) throw new Error('Deferral requires --reason "<why>"');
|
|
197
|
+
const doc = loadDeferrals(sessionDir);
|
|
198
|
+
if (doc.deferrals.some((d) => d.task === entry.task && !d.resolvedAt)) {
|
|
199
|
+
throw new Error(`Deferral for task ${entry.task} already open`);
|
|
200
|
+
}
|
|
201
|
+
doc.deferrals.push({
|
|
202
|
+
task: entry.task,
|
|
203
|
+
reason: entry.reason,
|
|
204
|
+
createdAt: new Date().toISOString(),
|
|
205
|
+
resolvedAt: null,
|
|
206
|
+
});
|
|
207
|
+
writeJson(deferralsPath(sessionDir), doc);
|
|
208
|
+
return doc;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {string} sessionDir
|
|
213
|
+
* @param {string} task
|
|
214
|
+
*/
|
|
215
|
+
export function resolveDeferral(sessionDir, task) {
|
|
216
|
+
const doc = loadDeferrals(sessionDir);
|
|
217
|
+
const open = doc.deferrals.find((d) => d.task === task && !d.resolvedAt);
|
|
218
|
+
if (!open) throw new Error(`No open deferral for task ${task}`);
|
|
219
|
+
open.resolvedAt = new Date().toISOString();
|
|
220
|
+
writeJson(deferralsPath(sessionDir), doc);
|
|
221
|
+
return doc;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {string} sessionDir
|
|
226
|
+
*/
|
|
227
|
+
export function openDeferrals(sessionDir) {
|
|
228
|
+
return loadDeferrals(sessionDir).deferrals.filter((d) => !d.resolvedAt);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* @param {Record<string, unknown> | null | undefined} session
|
|
233
|
+
*/
|
|
234
|
+
export function sessionJobsSignalText(session) {
|
|
235
|
+
return [session?.paceSignal, session?.slug, session?.openspecChange]
|
|
236
|
+
.filter((v) => isNonEmptyString(v))
|
|
237
|
+
.join(' ');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Run the mechanical integrity checks for a session.
|
|
242
|
+
*
|
|
243
|
+
* Checks:
|
|
244
|
+
* 1. No unresolved deferrals.
|
|
245
|
+
* 2. spine.json — **always required** (filled rows, or `notApplicable` with a
|
|
246
|
+
* reason). Keyword sniffing is not enough to decide; missing spine is how
|
|
247
|
+
* library-only platforms checkbox past gaps.
|
|
248
|
+
* 3. verify-evidence.md — when a spine has real rows (not notApplicable): must
|
|
249
|
+
* exist and contain a product-loop section; an explicit BLOCKED marker means
|
|
250
|
+
* the change cannot be done. Sync-only work should prefer `notApplicable`
|
|
251
|
+
* over inventing a fake loop.
|
|
252
|
+
*
|
|
253
|
+
* @param {{ cwd?: string, sessionDir: string, session: Record<string, unknown> }} opts
|
|
254
|
+
* @returns {{ ok: boolean, problems: string[], spineFile: string, spineExists: boolean }}
|
|
255
|
+
*/
|
|
256
|
+
export function runIntegrityChecks(opts) {
|
|
257
|
+
/** @type {string[]} */
|
|
258
|
+
const problems = [];
|
|
259
|
+
const { sessionDir, session } = opts;
|
|
260
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
261
|
+
|
|
262
|
+
const open = openDeferrals(sessionDir);
|
|
263
|
+
if (open.length > 0) {
|
|
264
|
+
problems.push(
|
|
265
|
+
`unresolved deferrals: ${open.map((d) => `${d.task} (${d.reason})`).join('; ')} — resolve via forge defer resolve --task <id>`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const spineFile = spinePath({ cwd, session, sessionDir });
|
|
270
|
+
const spineExists = fs.existsSync(spineFile);
|
|
271
|
+
|
|
272
|
+
/** @type {ReturnType<typeof validateSpine> | null} */
|
|
273
|
+
let spineResult = null;
|
|
274
|
+
let spineHasRows = false;
|
|
275
|
+
if (!spineExists) {
|
|
276
|
+
problems.push(
|
|
277
|
+
`spine.json required at ${spineFile} — run forge spine init, then fill rows (or set notApplicable with a reason). Spine is mandatory for every change so capability→runtime wiring cannot be skipped by accident.`,
|
|
278
|
+
);
|
|
279
|
+
} else {
|
|
280
|
+
try {
|
|
281
|
+
const doc = readJson(spineFile);
|
|
282
|
+
spineResult = validateSpine(doc);
|
|
283
|
+
spineHasRows =
|
|
284
|
+
Array.isArray(doc?.rows) &&
|
|
285
|
+
doc.rows.length > 0 &&
|
|
286
|
+
!isNonEmptyString(doc?.notApplicable);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
spineResult = {
|
|
289
|
+
ok: false,
|
|
290
|
+
problems: [`spine.json unreadable: ${err instanceof Error ? err.message : err}`],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (!spineResult.ok) {
|
|
294
|
+
problems.push(...spineResult.problems.map((p) => `spine: ${p}`));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (spineExists && spineHasRows) {
|
|
299
|
+
const evidenceFile = path.join(sessionDir, 'verify-evidence.md');
|
|
300
|
+
if (!fs.existsSync(evidenceFile)) {
|
|
301
|
+
problems.push(
|
|
302
|
+
'verify-evidence.md missing — spine rows require product-loop evidence (or use notApplicable for sync-only work)',
|
|
303
|
+
);
|
|
304
|
+
} else {
|
|
305
|
+
const body = fs.readFileSync(evidenceFile, 'utf8');
|
|
306
|
+
if (/\bBLOCKED\b/.test(body)) {
|
|
307
|
+
problems.push('verify-evidence.md contains BLOCKED — change cannot be marked done while E2E is blocked');
|
|
308
|
+
} else if (!/product[- ]loop/i.test(body)) {
|
|
309
|
+
problems.push(
|
|
310
|
+
'verify-evidence.md has no "Product loop" section — record the closed producer→consumer loop (or BLOCKED). Sync-only changes should use spine notApplicable instead.',
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { ok: problems.length === 0, problems, spineFile, spineExists };
|
|
317
|
+
}
|