@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
package/src/paths.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Resolve forgekit asset roots (skills, templates).
|
|
4
|
+
*
|
|
5
|
+
* Search order for assets:
|
|
6
|
+
* 1. FORGEKIT_ROOT env (dev / linked monorepo)
|
|
7
|
+
* 2. Monorepo root (packages/cli/../../..)
|
|
8
|
+
* 3. Vendored copy inside the published package (packages/cli/vendor)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
/** @returns {string} Absolute path to packages/cli */
|
|
19
|
+
export function cliPackageRoot() {
|
|
20
|
+
return path.resolve(__dirname, '..');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Candidate forgekit roots that may contain skills/ and templates/.
|
|
25
|
+
* @returns {string[]}
|
|
26
|
+
*/
|
|
27
|
+
export function forgekitRootCandidates() {
|
|
28
|
+
const roots = [];
|
|
29
|
+
if (process.env.FORGEKIT_ROOT) roots.push(process.env.FORGEKIT_ROOT);
|
|
30
|
+
// Monorepo: packages/cli/src → ../../../
|
|
31
|
+
roots.push(path.resolve(__dirname, '..', '..', '..'));
|
|
32
|
+
// Published package: vendor/ next to src/
|
|
33
|
+
roots.push(path.join(cliPackageRoot(), 'vendor'));
|
|
34
|
+
return [...new Set(roots.map((r) => path.resolve(r)))];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} relativePath e.g. 'skills/forge' or 'templates/project'
|
|
39
|
+
* @param {{ requireFile?: string }} [opts] file that must exist under the candidate
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function resolveAsset(relativePath, opts = {}) {
|
|
43
|
+
const requireFile = opts.requireFile;
|
|
44
|
+
const tried = [];
|
|
45
|
+
for (const root of forgekitRootCandidates()) {
|
|
46
|
+
const candidate = path.join(root, relativePath);
|
|
47
|
+
tried.push(candidate);
|
|
48
|
+
const check = requireFile ? path.join(candidate, requireFile) : candidate;
|
|
49
|
+
if (fs.existsSync(check)) return candidate;
|
|
50
|
+
}
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Asset not found: ${relativePath}${requireFile ? ` (${requireFile})` : ''}.\nTried:\n ${tried.join('\n ')}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @returns {string} package version from packages/cli/package.json
|
|
58
|
+
*/
|
|
59
|
+
export function packageVersion() {
|
|
60
|
+
const pkgPath = path.join(cliPackageRoot(), 'package.json');
|
|
61
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
62
|
+
return pkg.version ?? '0.0.0';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stable content hash of a directory (sorted relative paths + file bytes).
|
|
67
|
+
* @param {string} dir
|
|
68
|
+
* @returns {string} sha256 hex
|
|
69
|
+
*/
|
|
70
|
+
export function hashDirectory(dir) {
|
|
71
|
+
const hash = createHash('sha256');
|
|
72
|
+
/** @type {string[]} */
|
|
73
|
+
const files = [];
|
|
74
|
+
const walk = (d, prefix = '') => {
|
|
75
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
76
|
+
if (entry.name === '.forgekit.json') continue;
|
|
77
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
78
|
+
const full = path.join(d, entry.name);
|
|
79
|
+
if (entry.isDirectory()) walk(full, rel);
|
|
80
|
+
else files.push(rel);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
walk(dir);
|
|
84
|
+
files.sort();
|
|
85
|
+
for (const rel of files) {
|
|
86
|
+
hash.update(rel);
|
|
87
|
+
hash.update('\0');
|
|
88
|
+
hash.update(fs.readFileSync(path.join(dir, ...rel.split('/'))));
|
|
89
|
+
hash.update('\0');
|
|
90
|
+
}
|
|
91
|
+
return hash.digest('hex');
|
|
92
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Planning engine resolution + scaffolding for forgekit.
|
|
4
|
+
*
|
|
5
|
+
* Engines:
|
|
6
|
+
* openspec — vendor OpenSpec CLI (`openspec/changes/<name>/`)
|
|
7
|
+
* specs — built-in markdown engine (`specs/changes/<name>/`), same inner
|
|
8
|
+
* layout as OpenSpec so phases / archive→ADR / later migration
|
|
9
|
+
* stay compatible.
|
|
10
|
+
*
|
|
11
|
+
* User default: ~/.forgekit/config.json → { plan: { engine } }
|
|
12
|
+
* Project: <repo>/.forge/config.json → { plan: { engine, dir } }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import {
|
|
20
|
+
loadProjectConfig,
|
|
21
|
+
loadUserConfig,
|
|
22
|
+
saveProjectConfig,
|
|
23
|
+
saveUserConfig,
|
|
24
|
+
} from './config.mjs';
|
|
25
|
+
|
|
26
|
+
export const PLAN_ENGINES = Object.freeze(['openspec', 'specs']);
|
|
27
|
+
export const DEFAULT_SPECS_DIR = 'specs';
|
|
28
|
+
|
|
29
|
+
export const OPENSPEC_PACKAGE = '@fission-ai/openspec';
|
|
30
|
+
export const OPENSPEC_INSTALL_CMD = `npm install -g ${OPENSPEC_PACKAGE}`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {unknown} value
|
|
34
|
+
* @returns {{ engine?: string, dir?: string } | null}
|
|
35
|
+
*/
|
|
36
|
+
function asPlan(value) {
|
|
37
|
+
if (!value || typeof value !== 'object') return null;
|
|
38
|
+
return /** @type {{ engine?: string, dir?: string }} */ (value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} engine
|
|
43
|
+
*/
|
|
44
|
+
export function assertPlanEngine(engine) {
|
|
45
|
+
if (!PLAN_ENGINES.includes(engine)) {
|
|
46
|
+
throw new Error(`Unknown plan engine: ${engine}. Known: ${PLAN_ENGINES.join(', ')}`);
|
|
47
|
+
}
|
|
48
|
+
return engine;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} cwd
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function hasOpenSpecConfig(cwd) {
|
|
56
|
+
return fs.existsSync(path.join(cwd, 'openspec', 'config.yaml'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} [home]
|
|
61
|
+
* @returns {string | null}
|
|
62
|
+
*/
|
|
63
|
+
export function loadUserPlanEngine(home = os.homedir()) {
|
|
64
|
+
const cfg = loadUserConfig(home);
|
|
65
|
+
const engine = asPlan(cfg.plan)?.engine;
|
|
66
|
+
return PLAN_ENGINES.includes(engine) ? engine : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Merge { plan: { engine } } into ~/.forgekit/config.json, preserving other keys.
|
|
71
|
+
* @param {string} engine
|
|
72
|
+
* @param {string} [home]
|
|
73
|
+
*/
|
|
74
|
+
export function saveUserPlanEngine(engine, home = os.homedir()) {
|
|
75
|
+
assertPlanEngine(engine);
|
|
76
|
+
return saveUserConfig({ plan: { engine } }, home);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Merge { plan: { engine, dir? } } into <cwd>/.forge/config.json, preserving adr etc.
|
|
81
|
+
* @param {string} cwd
|
|
82
|
+
* @param {{ engine: string, dir?: string }} plan
|
|
83
|
+
*/
|
|
84
|
+
export function writeProjectPlanConfig(cwd, plan) {
|
|
85
|
+
assertPlanEngine(plan.engine);
|
|
86
|
+
const current = loadProjectConfig(cwd);
|
|
87
|
+
const curPlan = asPlan(current.plan);
|
|
88
|
+
/** @type {{ engine: string, dir?: string }} */
|
|
89
|
+
const nextPlan = { engine: plan.engine };
|
|
90
|
+
if (plan.engine === 'specs') {
|
|
91
|
+
nextPlan.dir = plan.dir ?? curPlan?.dir ?? DEFAULT_SPECS_DIR;
|
|
92
|
+
}
|
|
93
|
+
return saveProjectConfig(cwd, { plan: nextPlan }, { replaceKeys: ['plan'] });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Effective plan engine for a project.
|
|
98
|
+
*
|
|
99
|
+
* Precedence: project config → openspec/config.yaml detection → user default
|
|
100
|
+
* → 'openspec'.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} cwd
|
|
103
|
+
* @param {{ home?: string, useUserDefault?: boolean }} [opts]
|
|
104
|
+
* @returns {{ engine: string, dir: string, source: string }}
|
|
105
|
+
*/
|
|
106
|
+
export function resolveProjectPlanEngine(cwd, opts = {}) {
|
|
107
|
+
const project = loadProjectConfig(cwd);
|
|
108
|
+
const projectPlan = asPlan(project.plan);
|
|
109
|
+
if (projectPlan && PLAN_ENGINES.includes(projectPlan.engine)) {
|
|
110
|
+
return {
|
|
111
|
+
engine: /** @type {string} */ (projectPlan.engine),
|
|
112
|
+
dir: projectPlan.dir ?? DEFAULT_SPECS_DIR,
|
|
113
|
+
source: 'project',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (hasOpenSpecConfig(cwd)) {
|
|
117
|
+
return { engine: 'openspec', dir: DEFAULT_SPECS_DIR, source: 'detected' };
|
|
118
|
+
}
|
|
119
|
+
if (opts.useUserDefault !== false) {
|
|
120
|
+
const userEngine = loadUserPlanEngine(opts.home);
|
|
121
|
+
if (userEngine) {
|
|
122
|
+
return { engine: userEngine, dir: DEFAULT_SPECS_DIR, source: 'user' };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { engine: 'openspec', dir: DEFAULT_SPECS_DIR, source: 'default' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const SPECS_README = (dir) => `# \`${dir}/\` — Forge specs (built-in planning engine)
|
|
129
|
+
|
|
130
|
+
OpenSpec-compatible change tracking without the OpenSpec CLI. Managed by the
|
|
131
|
+
Forge workflow (see the \`forge\` skill, \`phases/plan-specs.md\`).
|
|
132
|
+
|
|
133
|
+
\`\`\`
|
|
134
|
+
${dir}/
|
|
135
|
+
changes/<change-name>/
|
|
136
|
+
proposal.md # Why / What Changes / Impact
|
|
137
|
+
design.md # optional — context, decisions, risks
|
|
138
|
+
tasks.md # ## groups with - [ ] task checkboxes
|
|
139
|
+
changes/archive/YYYY-MM-DD-<change-name>/ # archived on finish
|
|
140
|
+
\`\`\`
|
|
141
|
+
|
|
142
|
+
Conventions (kept identical to OpenSpec so migration stays trivial):
|
|
143
|
+
|
|
144
|
+
- One change per unit of substantial work; kebab-case change names.
|
|
145
|
+
- \`tasks.md\` uses \`##\` section groups and \`- [ ]\` checkboxes; Forge counts
|
|
146
|
+
and reviews per group.
|
|
147
|
+
- On finish, move the change dir into \`changes/archive/\` with a date prefix,
|
|
148
|
+
then follow the project ADR policy if enabled.
|
|
149
|
+
|
|
150
|
+
Migrating to OpenSpec later: run \`openspec init\`, then move
|
|
151
|
+
\`${dir}/changes/*\` into \`openspec/changes/\`.
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Scaffold the specs engine directory structure.
|
|
156
|
+
* @param {string} cwd
|
|
157
|
+
* @param {{ dir?: string, force?: boolean }} [opts]
|
|
158
|
+
*/
|
|
159
|
+
export function scaffoldSpecs(cwd, opts = {}) {
|
|
160
|
+
const dir = opts.dir ?? DEFAULT_SPECS_DIR;
|
|
161
|
+
/** @type {{ file: string, status: string }[]} */
|
|
162
|
+
const files = [];
|
|
163
|
+
|
|
164
|
+
const ensureDir = (rel) => {
|
|
165
|
+
fs.mkdirSync(path.join(cwd, ...rel.split('/')), { recursive: true });
|
|
166
|
+
};
|
|
167
|
+
const writeFile = (rel, body) => {
|
|
168
|
+
const dest = path.join(cwd, ...rel.split('/'));
|
|
169
|
+
if (fs.existsSync(dest) && !opts.force) {
|
|
170
|
+
files.push({ file: rel, status: 'skipped' });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
fs.writeFileSync(dest, body, 'utf8');
|
|
174
|
+
files.push({ file: rel, status: 'written' });
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
ensureDir(`${dir}/changes/archive`);
|
|
178
|
+
writeFile(`${dir}/README.md`, SPECS_README(dir));
|
|
179
|
+
writeFile(`${dir}/changes/archive/.gitkeep`, '');
|
|
180
|
+
|
|
181
|
+
return { dir, files };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @returns {{ ok: boolean, version: string | null }}
|
|
186
|
+
*/
|
|
187
|
+
export function checkOpenSpecCliQuick(runCommand) {
|
|
188
|
+
const run =
|
|
189
|
+
runCommand ??
|
|
190
|
+
((cmd, args) => {
|
|
191
|
+
let result = spawnSync(cmd, args, { encoding: 'utf8', shell: false });
|
|
192
|
+
if (result.error && process.platform === 'win32') {
|
|
193
|
+
result = spawnSync([cmd, ...args].join(' '), { encoding: 'utf8', shell: true });
|
|
194
|
+
}
|
|
195
|
+
return { status: result.status, stdout: result.stdout || '' };
|
|
196
|
+
});
|
|
197
|
+
const attempt = run('openspec', ['--version']);
|
|
198
|
+
if (attempt.status === 0) {
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
version: String(attempt.stdout || '').trim().split(/\r?\n/)[0] || 'unknown',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return { ok: false, version: null };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Install the OpenSpec CLI (if missing) and run `openspec init` in the project.
|
|
209
|
+
* Interactive: inherits stdio so `openspec init` can prompt.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} cwd
|
|
212
|
+
* @param {{ runCommand?: Function, interactive?: boolean }} [opts]
|
|
213
|
+
* @returns {{ ok: boolean, steps: { step: string, ok: boolean, detail?: string }[] }}
|
|
214
|
+
*/
|
|
215
|
+
export function setupOpenSpec(cwd, opts = {}) {
|
|
216
|
+
/** @type {{ step: string, ok: boolean, detail?: string }[]} */
|
|
217
|
+
const steps = [];
|
|
218
|
+
|
|
219
|
+
const runInherit =
|
|
220
|
+
opts.runCommand ??
|
|
221
|
+
((cmd, args) => {
|
|
222
|
+
let result = spawnSync(cmd, args, { stdio: 'inherit', shell: false, cwd });
|
|
223
|
+
if (result.error && process.platform === 'win32') {
|
|
224
|
+
result = spawnSync([cmd, ...args].join(' '), {
|
|
225
|
+
stdio: 'inherit',
|
|
226
|
+
shell: true,
|
|
227
|
+
cwd,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return { status: result.status, error: result.error };
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const cli = checkOpenSpecCliQuick(opts.runCommand);
|
|
234
|
+
if (!cli.ok) {
|
|
235
|
+
const install = runInherit('npm', ['install', '-g', OPENSPEC_PACKAGE]);
|
|
236
|
+
const ok = install.status === 0;
|
|
237
|
+
steps.push({
|
|
238
|
+
step: OPENSPEC_INSTALL_CMD,
|
|
239
|
+
ok,
|
|
240
|
+
detail: ok ? undefined : String(install.error ?? `exit ${install.status}`),
|
|
241
|
+
});
|
|
242
|
+
if (!ok) return { ok: false, steps };
|
|
243
|
+
} else {
|
|
244
|
+
steps.push({ step: `openspec CLI present (${cli.version})`, ok: true });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (hasOpenSpecConfig(cwd)) {
|
|
248
|
+
steps.push({ step: 'openspec/config.yaml already present', ok: true });
|
|
249
|
+
return { ok: true, steps };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const init = runInherit('openspec', ['init']);
|
|
253
|
+
const initOk = init.status === 0;
|
|
254
|
+
steps.push({
|
|
255
|
+
step: 'openspec init',
|
|
256
|
+
ok: initOk,
|
|
257
|
+
detail: initOk ? undefined : String(init.error ?? `exit ${init.status}`),
|
|
258
|
+
});
|
|
259
|
+
return { ok: initOk && hasOpenSpecConfig(cwd), steps };
|
|
260
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
DEFAULT_SPECS_DIR,
|
|
8
|
+
PLAN_ENGINES,
|
|
9
|
+
hasOpenSpecConfig,
|
|
10
|
+
loadUserPlanEngine,
|
|
11
|
+
resolveProjectPlanEngine,
|
|
12
|
+
saveUserPlanEngine,
|
|
13
|
+
scaffoldSpecs,
|
|
14
|
+
setupOpenSpec,
|
|
15
|
+
writeProjectPlanConfig,
|
|
16
|
+
} from './plan-engine.mjs';
|
|
17
|
+
import { loadProjectConfig, loadUserConfig, saveUserConfig } from './adr.mjs';
|
|
18
|
+
import { parseArgs as parseInstallArgs } from './install.mjs';
|
|
19
|
+
import { parseArgs as parseInitArgs, initProject } from './init.mjs';
|
|
20
|
+
import { runDoctorChecks } from './doctor.mjs';
|
|
21
|
+
|
|
22
|
+
function tmpdir(prefix) {
|
|
23
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('user plan engine round-trip preserves adr config', () => {
|
|
27
|
+
const home = tmpdir('forgekit-plan-user-');
|
|
28
|
+
try {
|
|
29
|
+
saveUserConfig({ adr: { enabled: true, dir: 'docs/adr' } }, home);
|
|
30
|
+
saveUserPlanEngine('specs', home);
|
|
31
|
+
assert.equal(loadUserPlanEngine(home), 'specs');
|
|
32
|
+
assert.equal(loadUserConfig(home).adr.enabled, true);
|
|
33
|
+
saveUserPlanEngine('openspec', home);
|
|
34
|
+
assert.equal(loadUserPlanEngine(home), 'openspec');
|
|
35
|
+
assert.throws(() => saveUserPlanEngine('bogus', home), /Unknown plan engine/);
|
|
36
|
+
} finally {
|
|
37
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('writeProjectPlanConfig preserves adr block and sets dir for specs', () => {
|
|
42
|
+
const cwd = tmpdir('forgekit-plan-proj-');
|
|
43
|
+
try {
|
|
44
|
+
fs.mkdirSync(path.join(cwd, '.forge'), { recursive: true });
|
|
45
|
+
fs.writeFileSync(
|
|
46
|
+
path.join(cwd, '.forge', 'config.json'),
|
|
47
|
+
JSON.stringify({ adr: { enabled: true, dir: 'docs/adr' } }),
|
|
48
|
+
'utf8',
|
|
49
|
+
);
|
|
50
|
+
writeProjectPlanConfig(cwd, { engine: 'specs' });
|
|
51
|
+
const cfg = loadProjectConfig(cwd);
|
|
52
|
+
assert.equal(cfg.plan.engine, 'specs');
|
|
53
|
+
assert.equal(cfg.plan.dir, DEFAULT_SPECS_DIR);
|
|
54
|
+
assert.equal(cfg.adr.enabled, true);
|
|
55
|
+
|
|
56
|
+
writeProjectPlanConfig(cwd, { engine: 'openspec' });
|
|
57
|
+
assert.equal(loadProjectConfig(cwd).plan.engine, 'openspec');
|
|
58
|
+
assert.equal(loadProjectConfig(cwd).plan.dir, undefined);
|
|
59
|
+
} finally {
|
|
60
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('resolveProjectPlanEngine precedence: project > detection > user > default', () => {
|
|
65
|
+
const cwd = tmpdir('forgekit-plan-res-');
|
|
66
|
+
const home = tmpdir('forgekit-plan-res-home-');
|
|
67
|
+
try {
|
|
68
|
+
// default
|
|
69
|
+
assert.equal(
|
|
70
|
+
resolveProjectPlanEngine(cwd, { home }).engine,
|
|
71
|
+
'openspec',
|
|
72
|
+
);
|
|
73
|
+
assert.equal(resolveProjectPlanEngine(cwd, { home }).source, 'default');
|
|
74
|
+
|
|
75
|
+
// user default
|
|
76
|
+
saveUserPlanEngine('specs', home);
|
|
77
|
+
const viaUser = resolveProjectPlanEngine(cwd, { home });
|
|
78
|
+
assert.equal(viaUser.engine, 'specs');
|
|
79
|
+
assert.equal(viaUser.source, 'user');
|
|
80
|
+
|
|
81
|
+
// useUserDefault=false ignores user config
|
|
82
|
+
assert.equal(
|
|
83
|
+
resolveProjectPlanEngine(cwd, { home, useUserDefault: false }).source,
|
|
84
|
+
'default',
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// openspec detection beats user default
|
|
88
|
+
fs.mkdirSync(path.join(cwd, 'openspec'), { recursive: true });
|
|
89
|
+
fs.writeFileSync(path.join(cwd, 'openspec', 'config.yaml'), 'name: x\n', 'utf8');
|
|
90
|
+
assert.equal(hasOpenSpecConfig(cwd), true);
|
|
91
|
+
const detected = resolveProjectPlanEngine(cwd, { home });
|
|
92
|
+
assert.equal(detected.engine, 'openspec');
|
|
93
|
+
assert.equal(detected.source, 'detected');
|
|
94
|
+
|
|
95
|
+
// project config beats detection
|
|
96
|
+
writeProjectPlanConfig(cwd, { engine: 'specs', dir: 'specs' });
|
|
97
|
+
const viaProject = resolveProjectPlanEngine(cwd, { home });
|
|
98
|
+
assert.equal(viaProject.engine, 'specs');
|
|
99
|
+
assert.equal(viaProject.source, 'project');
|
|
100
|
+
} finally {
|
|
101
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
102
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('scaffoldSpecs writes README + changes/archive', () => {
|
|
107
|
+
const cwd = tmpdir('forgekit-specs-scaffold-');
|
|
108
|
+
try {
|
|
109
|
+
const result = scaffoldSpecs(cwd, { force: true });
|
|
110
|
+
assert.equal(result.dir, DEFAULT_SPECS_DIR);
|
|
111
|
+
assert.ok(fs.existsSync(path.join(cwd, 'specs', 'README.md')));
|
|
112
|
+
assert.ok(fs.existsSync(path.join(cwd, 'specs', 'changes', 'archive')));
|
|
113
|
+
const readme = fs.readFileSync(path.join(cwd, 'specs', 'README.md'), 'utf8');
|
|
114
|
+
assert.match(readme, /changes\/<change-name>/);
|
|
115
|
+
assert.match(readme, /tasks\.md/);
|
|
116
|
+
|
|
117
|
+
// second run without force skips
|
|
118
|
+
const again = scaffoldSpecs(cwd);
|
|
119
|
+
assert.ok(again.files.some((f) => f.status === 'skipped'));
|
|
120
|
+
} finally {
|
|
121
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('setupOpenSpec runs install + init via injected runner', () => {
|
|
126
|
+
const cwd = tmpdir('forgekit-openspec-setup-');
|
|
127
|
+
try {
|
|
128
|
+
/** @type {string[]} */
|
|
129
|
+
const calls = [];
|
|
130
|
+
const runCommand = (cmd, args) => {
|
|
131
|
+
const line = [cmd, ...args].join(' ');
|
|
132
|
+
calls.push(line);
|
|
133
|
+
if (line === 'openspec --version') return { status: 1, stdout: '' };
|
|
134
|
+
if (line.startsWith('npm install -g')) return { status: 0, stdout: '' };
|
|
135
|
+
if (line === 'openspec init') {
|
|
136
|
+
fs.mkdirSync(path.join(cwd, 'openspec'), { recursive: true });
|
|
137
|
+
fs.writeFileSync(path.join(cwd, 'openspec', 'config.yaml'), 'x: 1\n', 'utf8');
|
|
138
|
+
return { status: 0, stdout: '' };
|
|
139
|
+
}
|
|
140
|
+
return { status: 1, stdout: '' };
|
|
141
|
+
};
|
|
142
|
+
const result = setupOpenSpec(cwd, { runCommand });
|
|
143
|
+
assert.equal(result.ok, true);
|
|
144
|
+
assert.ok(calls.some((c) => c.startsWith('npm install -g')));
|
|
145
|
+
assert.ok(calls.includes('openspec init'));
|
|
146
|
+
} finally {
|
|
147
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('setupOpenSpec reports failure when install fails', () => {
|
|
152
|
+
const cwd = tmpdir('forgekit-openspec-fail-');
|
|
153
|
+
try {
|
|
154
|
+
const runCommand = () => ({ status: 1, stdout: '' });
|
|
155
|
+
const result = setupOpenSpec(cwd, { runCommand });
|
|
156
|
+
assert.equal(result.ok, false);
|
|
157
|
+
assert.ok(result.steps.some((s) => !s.ok));
|
|
158
|
+
} finally {
|
|
159
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('install parseArgs --openspec / --no-openspec', () => {
|
|
164
|
+
assert.equal(parseInstallArgs(['--openspec']).openspec, true);
|
|
165
|
+
assert.equal(parseInstallArgs(['--no-openspec']).openspec, false);
|
|
166
|
+
assert.equal(parseInstallArgs([]).openspec, null);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('init parseArgs --openspec / --no-openspec', () => {
|
|
170
|
+
assert.equal(parseInitArgs(['--openspec']).openspec, true);
|
|
171
|
+
assert.equal(parseInitArgs(['--no-openspec']).openspec, false);
|
|
172
|
+
assert.equal(parseInitArgs([]).openspec, null);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('initProject planEngine=specs scaffolds and writes config', () => {
|
|
176
|
+
const cwd = tmpdir('forgekit-init-specs-');
|
|
177
|
+
try {
|
|
178
|
+
const report = initProject(['codex'], {
|
|
179
|
+
cwd,
|
|
180
|
+
force: true,
|
|
181
|
+
planEngine: 'specs',
|
|
182
|
+
});
|
|
183
|
+
assert.equal(report.plan.engine, 'specs');
|
|
184
|
+
assert.ok(fs.existsSync(path.join(cwd, 'specs', 'README.md')));
|
|
185
|
+
assert.equal(loadProjectConfig(cwd).plan.engine, 'specs');
|
|
186
|
+
} finally {
|
|
187
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('initProject planEngine=openspec records engine without scaffolding specs', () => {
|
|
192
|
+
const cwd = tmpdir('forgekit-init-os-');
|
|
193
|
+
try {
|
|
194
|
+
const report = initProject(['codex'], {
|
|
195
|
+
cwd,
|
|
196
|
+
force: true,
|
|
197
|
+
planEngine: 'openspec',
|
|
198
|
+
});
|
|
199
|
+
assert.equal(report.plan.engine, 'openspec');
|
|
200
|
+
assert.equal(report.plan.configured, false);
|
|
201
|
+
assert.ok(!fs.existsSync(path.join(cwd, 'specs')));
|
|
202
|
+
assert.equal(loadProjectConfig(cwd).plan.engine, 'openspec');
|
|
203
|
+
} finally {
|
|
204
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('doctor: specs engine checks specs layout instead of OpenSpec CLI', () => {
|
|
209
|
+
const cwd = tmpdir('forgekit-doctor-specs-');
|
|
210
|
+
try {
|
|
211
|
+
writeProjectPlanConfig(cwd, { engine: 'specs' });
|
|
212
|
+
|
|
213
|
+
const missing = runDoctorChecks({ cwd });
|
|
214
|
+
assert.equal(missing.engine, 'specs');
|
|
215
|
+
assert.equal(missing.ok, false);
|
|
216
|
+
assert.match(missing.checks.project.message, /specs\/changes/);
|
|
217
|
+
assert.equal(missing.checks.cli.ok, true);
|
|
218
|
+
assert.equal(missing.checks.cli.skipped, true);
|
|
219
|
+
|
|
220
|
+
scaffoldSpecs(cwd);
|
|
221
|
+
const present = runDoctorChecks({ cwd });
|
|
222
|
+
assert.equal(present.ok, true);
|
|
223
|
+
} finally {
|
|
224
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('doctor: openspec engine unchanged for openspec projects', () => {
|
|
229
|
+
const cwd = tmpdir('forgekit-doctor-os-');
|
|
230
|
+
try {
|
|
231
|
+
const report = runDoctorChecks({
|
|
232
|
+
cwd,
|
|
233
|
+
existsSync: () => true,
|
|
234
|
+
runCommand: () => ({ status: 0, stdout: '1.0.0', stderr: '' }),
|
|
235
|
+
});
|
|
236
|
+
assert.equal(report.engine, 'openspec');
|
|
237
|
+
assert.equal(report.ok, true);
|
|
238
|
+
} finally {
|
|
239
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('PLAN_ENGINES is the closed engine set', () => {
|
|
244
|
+
assert.deepEqual([...PLAN_ENGINES], ['openspec', 'specs']);
|
|
245
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pace": "auto",
|
|
3
|
+
"integrity": {
|
|
4
|
+
"forbidStubs": true,
|
|
5
|
+
"specsBeatNarrowTasks": true,
|
|
6
|
+
"requireE2E": "when-jobs-or-workers"
|
|
7
|
+
},
|
|
8
|
+
"presets": {
|
|
9
|
+
"thorough": {
|
|
10
|
+
"review": {
|
|
11
|
+
"perTask": "always",
|
|
12
|
+
"final": "always",
|
|
13
|
+
"depth": "full",
|
|
14
|
+
"maxRounds": 3
|
|
15
|
+
},
|
|
16
|
+
"verify": {
|
|
17
|
+
"tier3": "full-workspace"
|
|
18
|
+
},
|
|
19
|
+
"models": {
|
|
20
|
+
"bias": "default"
|
|
21
|
+
},
|
|
22
|
+
"brainstorm": {
|
|
23
|
+
"depth": "full"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"standard": {
|
|
27
|
+
"review": {
|
|
28
|
+
"perTask": "per-group",
|
|
29
|
+
"final": "always",
|
|
30
|
+
"depth": "full",
|
|
31
|
+
"maxRounds": 2
|
|
32
|
+
},
|
|
33
|
+
"verify": {
|
|
34
|
+
"tier3": "full-workspace"
|
|
35
|
+
},
|
|
36
|
+
"models": {
|
|
37
|
+
"bias": "default"
|
|
38
|
+
},
|
|
39
|
+
"brainstorm": {
|
|
40
|
+
"depth": "full"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"brisk": {
|
|
44
|
+
"review": {
|
|
45
|
+
"perTask": "high-risk-only",
|
|
46
|
+
"final": "high-risk-only",
|
|
47
|
+
"depth": "spec-only",
|
|
48
|
+
"maxRounds": 1
|
|
49
|
+
},
|
|
50
|
+
"verify": {
|
|
51
|
+
"tier3": "affected-only"
|
|
52
|
+
},
|
|
53
|
+
"models": {
|
|
54
|
+
"bias": "prefer-fast"
|
|
55
|
+
},
|
|
56
|
+
"brainstorm": {
|
|
57
|
+
"depth": "short"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"lite": {
|
|
61
|
+
"review": {
|
|
62
|
+
"perTask": "never",
|
|
63
|
+
"final": "never",
|
|
64
|
+
"depth": "spec-only",
|
|
65
|
+
"maxRounds": 0
|
|
66
|
+
},
|
|
67
|
+
"verify": {
|
|
68
|
+
"tier3": "audit-tier2-only"
|
|
69
|
+
},
|
|
70
|
+
"models": {
|
|
71
|
+
"bias": "prefer-fast"
|
|
72
|
+
},
|
|
73
|
+
"brainstorm": {
|
|
74
|
+
"depth": "minimal"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|