forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5
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/AGENTS.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +21 -1
- package/bin/forge.js +16 -369
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +9 -4
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +40 -0
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +184 -44
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +92 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/beads-nudge.js +0 -91
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/commands/worktree.js
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
const { execFileSync, spawnSync } = require('node:child_process');
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
|
-
const { detectDefaultBranch } = require('../
|
|
6
|
+
const { detectDefaultBranch } = require('../git-defaults');
|
|
7
|
+
const {
|
|
8
|
+
resolveRepoRelativePath,
|
|
9
|
+
assertNoAncestorSymlinkEscape,
|
|
10
|
+
assertNoSymlinkEscape,
|
|
11
|
+
} = require('../protected-state-surfaces');
|
|
7
12
|
|
|
8
13
|
/**
|
|
9
14
|
* Forge Worktree Command
|
|
@@ -42,12 +47,13 @@ function detectPackageManager(projectRoot, fsApi) {
|
|
|
42
47
|
/**
|
|
43
48
|
* Check if a git branch already exists.
|
|
44
49
|
* @param {string} branchName - Branch name to check
|
|
50
|
+
* @param {string} projectRoot - Repo root to run git in
|
|
45
51
|
* @param {Function} runFile - execFileSync function (for DI)
|
|
46
52
|
* @returns {boolean}
|
|
47
53
|
*/
|
|
48
|
-
function branchExists(branchName, runFile) {
|
|
54
|
+
function branchExists(branchName, projectRoot, runFile) {
|
|
49
55
|
try {
|
|
50
|
-
const output = runFile('git', ['branch', '--list', branchName], { stdio: 'pipe' });
|
|
56
|
+
const output = runFile('git', ['-C', projectRoot, 'branch', '--list', branchName], { stdio: 'pipe' });
|
|
51
57
|
return output.toString().trim().length > 0;
|
|
52
58
|
} catch (_err) { /* intentional: branch doesn't exist */ // NOSONAR S2486
|
|
53
59
|
return false;
|
|
@@ -99,35 +105,118 @@ function isLinkPermissionError(error) {
|
|
|
99
105
|
return ['EPERM', 'EACCES', 'ENOSYS', 'UV_EPERM'].includes(error && error.code);
|
|
100
106
|
}
|
|
101
107
|
|
|
108
|
+
// Yarn is probe-less here: a Plug'n'Play install produces no node_modules tree to
|
|
109
|
+
// inspect, and `yarn check` was removed in Berry, so there is nothing to verify.
|
|
110
|
+
const PROBE_LESS_MANAGERS = new Set(['yarn']);
|
|
111
|
+
|
|
102
112
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* @param {string}
|
|
107
|
-
* @param {Function} spawnFn - spawnSync-compatible function
|
|
113
|
+
* Direct dependencies the worktree declares. Optional/peer deps are excluded —
|
|
114
|
+
* they may legitimately be absent. An unreadable package.json yields none, so an
|
|
115
|
+
* unverifiable worktree is left alone rather than force-reinstalled.
|
|
116
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
108
117
|
* @param {object} fsApi - fs module (for DI)
|
|
109
|
-
* @returns {
|
|
110
|
-
* @throws {Error} when the install cannot be spawned or exits non-zero.
|
|
118
|
+
* @returns {string[]} Declared package names
|
|
111
119
|
*/
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
function declaredDependencies(worktreePath, fsApi) {
|
|
121
|
+
try {
|
|
122
|
+
const pkg = JSON.parse(fsApi.readFileSync(path.join(worktreePath, 'package.json'), 'utf8'));
|
|
123
|
+
return [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})];
|
|
124
|
+
} catch (_err) { /* intentional: no readable package.json → nothing to verify */ // NOSONAR S2486
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Verify the install actually landed: every declared direct dependency must be a
|
|
131
|
+
* real package in the worktree's node_modules.
|
|
132
|
+
*
|
|
133
|
+
* The package managers cannot answer this. `bun pm ls`, `bun list`, `bun pm why`
|
|
134
|
+
* and `bun install --dry-run` all read the LOCKFILE and exit 0 while packages are
|
|
135
|
+
* physically missing; `pnpm ls --depth=0` does the same; `bun -e require.resolve`
|
|
136
|
+
* is defeated by Bun's auto-install. Only `npm ls --depth=0` exits non-zero, so a
|
|
137
|
+
* filesystem check is the one probe that holds for every manager.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
140
|
+
* @param {object} fsApi - fs module (for DI)
|
|
141
|
+
* @returns {boolean} true when nothing is missing
|
|
142
|
+
*/
|
|
143
|
+
function dependenciesPresent(worktreePath, fsApi) {
|
|
144
|
+
const deps = declaredDependencies(worktreePath, fsApi);
|
|
145
|
+
return deps.every((name) => fsApi.existsSync(path.join(worktreePath, 'node_modules', name, 'package.json')));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const STDERR_DETAIL_LIMIT = 400;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The install's captured stderr, trimmed and bounded — stdio 'pipe' otherwise
|
|
152
|
+
* hides why the install failed.
|
|
153
|
+
* @param {object} result - spawnSync result
|
|
154
|
+
* @returns {string} A leading-newline detail block, or '' when there is nothing
|
|
155
|
+
*/
|
|
156
|
+
function stderrDetail(result) {
|
|
157
|
+
const text = result && result.stderr ? String(result.stderr).trim() : '';
|
|
158
|
+
if (!text) return '';
|
|
159
|
+
return `\n${text.length > STDERR_DETAIL_LIMIT ? `${text.slice(0, STDERR_DETAIL_LIMIT)}...` : text}`;
|
|
160
|
+
}
|
|
115
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Spawn one install, surfacing spawn errors, kills, and non-zero exits.
|
|
164
|
+
* @param {string} pkgManager - Detected package manager command
|
|
165
|
+
* @param {string[]} args - Install args (e.g. ['install'] or ['install', '--force'])
|
|
166
|
+
* @param {string} worktreePath - cwd for the install
|
|
167
|
+
* @param {Function} spawnFn - spawnSync-compatible function
|
|
168
|
+
* @throws {Error} when the install cannot be spawned, is killed, or exits non-zero.
|
|
169
|
+
*/
|
|
170
|
+
function spawnInstall(pkgManager, args, worktreePath, spawnFn) {
|
|
116
171
|
// shell:true on Windows: npm/pnpm/yarn are .cmd shims that cannot be spawned
|
|
117
172
|
// directly (ENOENT / EINVAL). pkgManager comes from lockfile detection (fixed
|
|
118
173
|
// set) and args are hardcoded, so no user input reaches the shell.
|
|
119
|
-
const result = spawnFn(pkgManager,
|
|
174
|
+
const result = spawnFn(pkgManager, args, {
|
|
120
175
|
cwd: worktreePath,
|
|
121
176
|
stdio: 'pipe',
|
|
122
177
|
shell: process.platform === 'win32',
|
|
123
178
|
});
|
|
179
|
+
const label = `${pkgManager} ${args.join(' ')}`;
|
|
124
180
|
if (result && result.error) {
|
|
125
|
-
throw new Error(`Dependency install failed: could not run '${
|
|
181
|
+
throw new Error(`Dependency install failed: could not run '${label}' in ${worktreePath}: ${result.error.message}${stderrDetail(result)}`);
|
|
182
|
+
}
|
|
183
|
+
// A killed install (OOM, SIGTERM, CI cancel) reports status null with a signal
|
|
184
|
+
// set — never a non-zero code — so it must be checked on its own.
|
|
185
|
+
if (result && result.signal) {
|
|
186
|
+
throw new Error(`Dependency install failed: '${label}' was killed by ${result.signal} in ${worktreePath}${stderrDetail(result)}`);
|
|
126
187
|
}
|
|
127
188
|
if (result && typeof result.status === 'number' && result.status !== 0) {
|
|
128
|
-
throw new Error(`Dependency install failed: '${
|
|
189
|
+
throw new Error(`Dependency install failed: '${label}' exited with code ${result.status} in ${worktreePath}${stderrDetail(result)}`);
|
|
129
190
|
}
|
|
130
|
-
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Run package install in the new worktree if a package manager is detected, then
|
|
195
|
+
* VERIFY it. A stale shared store makes a plain install report "no changes" while
|
|
196
|
+
* leaving packages and binaries missing, so the exit code alone is not evidence —
|
|
197
|
+
* on a failed probe, reinstall once with --force and re-probe.
|
|
198
|
+
* @param {string} worktreePath - Absolute path to the new worktree
|
|
199
|
+
* @param {string} projectRoot - Absolute path to the project root
|
|
200
|
+
* @param {Function} spawnFn - spawnSync-compatible function
|
|
201
|
+
* @param {object} fsApi - fs module (for DI)
|
|
202
|
+
* @returns {{ linked: boolean, installed: boolean, healed: boolean }}
|
|
203
|
+
* @throws {Error} when the install fails, or the tree is still broken after --force.
|
|
204
|
+
*/
|
|
205
|
+
function runInstall(worktreePath, projectRoot, spawnFn, fsApi) {
|
|
206
|
+
const pkgManager = detectPackageManager(projectRoot, fsApi);
|
|
207
|
+
if (!pkgManager) return { linked: false, installed: false, healed: false };
|
|
208
|
+
|
|
209
|
+
spawnInstall(pkgManager, ['install'], worktreePath, spawnFn);
|
|
210
|
+
|
|
211
|
+
if (PROBE_LESS_MANAGERS.has(pkgManager) || dependenciesPresent(worktreePath, fsApi)) {
|
|
212
|
+
return { linked: false, installed: true, healed: false };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
spawnInstall(pkgManager, ['install', '--force'], worktreePath, spawnFn);
|
|
216
|
+
if (!dependenciesPresent(worktreePath, fsApi)) {
|
|
217
|
+
throw new Error(`Dependency install could not be verified: packages are still missing in ${worktreePath} after '${pkgManager} install --force'. Run it manually there and inspect the output.`);
|
|
218
|
+
}
|
|
219
|
+
return { linked: false, installed: true, healed: true };
|
|
131
220
|
}
|
|
132
221
|
|
|
133
222
|
/**
|
|
@@ -142,7 +231,7 @@ function runInstall(worktreePath, projectRoot, spawnFn, fsApi) {
|
|
|
142
231
|
* @param {Function} deps.spawnFn - spawnSync-compatible function
|
|
143
232
|
* @param {object} deps.fsApi - fs module (for DI)
|
|
144
233
|
* @param {string} deps.platform - process.platform value
|
|
145
|
-
* @returns {{ linked: boolean, installed: boolean }}
|
|
234
|
+
* @returns {{ linked: boolean, installed: boolean, healed: boolean }}
|
|
146
235
|
* @throws {Error} when linking fails for a non-privilege reason or install fails.
|
|
147
236
|
*/
|
|
148
237
|
function setupWorktreeDeps(worktreePath, projectRoot, { spawnFn, fsApi, platform }) {
|
|
@@ -150,14 +239,14 @@ function setupWorktreeDeps(worktreePath, projectRoot, { spawnFn, fsApi, platform
|
|
|
150
239
|
const destModules = path.join(worktreePath, 'node_modules');
|
|
151
240
|
|
|
152
241
|
// Already populated (e.g. git checkout carried it) — nothing to do.
|
|
153
|
-
if (fsApi.existsSync(destModules)) return { linked: false, installed: false };
|
|
242
|
+
if (fsApi.existsSync(destModules)) return { linked: false, installed: false, healed: false };
|
|
154
243
|
|
|
155
244
|
// Fast path: link to the shared install when the main repo has one.
|
|
156
245
|
if (fsApi.existsSync(srcModules)) {
|
|
157
246
|
const linkType = platform === 'win32' ? 'junction' : 'dir';
|
|
158
247
|
try {
|
|
159
248
|
fsApi.symlinkSync(srcModules, destModules, linkType);
|
|
160
|
-
return { linked: true, installed: false };
|
|
249
|
+
return { linked: true, installed: false, healed: false };
|
|
161
250
|
} catch (error) {
|
|
162
251
|
// Degrade to a real install only when the link was refused for lack of
|
|
163
252
|
// privilege/support; any other failure is surfaced to the caller.
|
|
@@ -168,6 +257,72 @@ function setupWorktreeDeps(worktreePath, projectRoot, { spawnFn, fsApi, platform
|
|
|
168
257
|
return runInstall(worktreePath, projectRoot, spawnFn, fsApi);
|
|
169
258
|
}
|
|
170
259
|
|
|
260
|
+
function readExistingWorktreeLinkage(driver, worktreePath, reuse) {
|
|
261
|
+
if (!reuse || !driver || typeof driver.getWorktreeLinkage !== 'function') return null;
|
|
262
|
+
return driver.getWorktreeLinkage({ path: worktreePath }) || null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function detectLinkageConflict({ projectRoot, existing, issueId, workFolder, reuse }) {
|
|
266
|
+
if (!reuse || !existing) return null;
|
|
267
|
+
const existingIssue = existing.issue_id || null;
|
|
268
|
+
const existingFolder = existing.work_folder || null;
|
|
269
|
+
const folderChanged = workFolder && existingFolder
|
|
270
|
+
&& path.resolve(projectRoot, workFolder) !== path.resolve(projectRoot, existingFolder);
|
|
271
|
+
if (!((issueId && existingIssue && issueId !== existingIssue) || folderChanged)) return null;
|
|
272
|
+
return {
|
|
273
|
+
issueId: issueId || existingIssue,
|
|
274
|
+
workFolder: workFolder || existingFolder,
|
|
275
|
+
reason: 'worktree linkage mismatch; existing issue/work-folder was not changed',
|
|
276
|
+
conflict: true,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function isAbsoluteWorkFolder(workFolder) {
|
|
281
|
+
return path.isAbsolute(workFolder) || path.posix.isAbsolute(workFolder) || path.win32.isAbsolute(workFolder);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function markerPathConflict(reason) {
|
|
285
|
+
return { conflict: true, reason: `worktree linkage marker path rejected: ${reason}` };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function validateMarkerTarget(projectRoot, workFolder, fsApi) {
|
|
289
|
+
if (!workFolder) return null;
|
|
290
|
+
if (isAbsoluteWorkFolder(workFolder)) return markerPathConflict('work-folder must be repo-relative');
|
|
291
|
+
|
|
292
|
+
const resolved = resolveRepoRelativePath(projectRoot, workFolder);
|
|
293
|
+
if (!resolved.insideRoot) return markerPathConflict('work-folder escapes project root');
|
|
294
|
+
const ancestorEscape = assertNoAncestorSymlinkEscape(resolved.root, resolved.target);
|
|
295
|
+
if (ancestorEscape) return markerPathConflict(ancestorEscape.reason);
|
|
296
|
+
|
|
297
|
+
const statSync = fsApi.statSync || fs.statSync;
|
|
298
|
+
const folderStat = fsApi.existsSync(resolved.target) ? statSync.call(fsApi, resolved.target) : null;
|
|
299
|
+
if (folderStat && !folderStat.isDirectory()) return markerPathConflict('work-folder is not a directory');
|
|
300
|
+
const markerPath = path.join(resolved.target, '.forge-issue');
|
|
301
|
+
const markerEscape = assertNoSymlinkEscape(resolved.root, markerPath);
|
|
302
|
+
if (markerEscape) {
|
|
303
|
+
return markerPathConflict(markerEscape.reason);
|
|
304
|
+
}
|
|
305
|
+
return { folderAbs: resolved.target, markerPath, folderExists: Boolean(folderStat) };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function reconcileIssueMarker({ target, linkedIssue, linkedFolder, fsApi }) {
|
|
309
|
+
if (!target || !linkedIssue || !target.folderExists) return null;
|
|
310
|
+
let markerIssue = null;
|
|
311
|
+
try {
|
|
312
|
+
if (fsApi.existsSync(target.markerPath)) markerIssue = fsApi.readFileSync(target.markerPath, 'utf8').trim() || null;
|
|
313
|
+
} catch { /* unreadable marker — let the write below repair it */ }
|
|
314
|
+
if (markerIssue && markerIssue !== linkedIssue) {
|
|
315
|
+
return {
|
|
316
|
+
issueId: linkedIssue,
|
|
317
|
+
workFolder: linkedFolder,
|
|
318
|
+
reason: 'worktree linkage mismatch; existing issue marker was not changed',
|
|
319
|
+
conflict: true,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
fsApi.writeFileSync(target.markerPath, `${linkedIssue}\n`, 'utf8');
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
171
326
|
/**
|
|
172
327
|
* Best-effort: record the issue → worktree → work-folder linkage in the Kernel so
|
|
173
328
|
* `forge worktree list` and orientation READ it instead of guessing the work-folder by
|
|
@@ -185,30 +340,44 @@ function setupWorktreeDeps(worktreePath, projectRoot, { spawnFn, fsApi, platform
|
|
|
185
340
|
* @param {string|null} params.issueId
|
|
186
341
|
* @param {string|null} params.workFolder - Repo-relative work-folder path.
|
|
187
342
|
* @param {object} params.opts - DI options (may inject `_kernelDriver`, `_exec`, `_fs`).
|
|
343
|
+
* @param {boolean} [params.reuse] - Validate existing linkage before reconciling.
|
|
188
344
|
* @returns {Promise<{registered: boolean, issueId: string|null, workFolder: string|null, reason?: string}>}
|
|
189
345
|
*/
|
|
190
|
-
async function registerWorktreeLinkage({ projectRoot, worktreePath, branch, issueId, workFolder, opts }) {
|
|
346
|
+
async function registerWorktreeLinkage({ projectRoot, worktreePath, branch, issueId, workFolder, opts, reuse = false }) {
|
|
191
347
|
const runFile = opts._exec || execFileSync;
|
|
192
348
|
const fsApi = opts._fs || fs;
|
|
193
349
|
try {
|
|
194
|
-
// Drop the folder → issue marker first — deterministic even if the DB write fails.
|
|
195
|
-
if (workFolder && issueId) {
|
|
196
|
-
const folderAbs = path.resolve(projectRoot, workFolder);
|
|
197
|
-
fsApi.mkdirSync(folderAbs, { recursive: true });
|
|
198
|
-
fsApi.writeFileSync(path.join(folderAbs, '.forge-issue'), `${issueId}\n`, 'utf8');
|
|
199
|
-
}
|
|
200
|
-
|
|
201
350
|
let driver = opts._kernelDriver;
|
|
202
351
|
if (!driver) {
|
|
203
352
|
// No reachable git repo → no shared Kernel; skip quietly (orientation still works
|
|
204
353
|
// off the folder heuristic). Avoids spawning `git` in a non-repo directory.
|
|
205
|
-
if (
|
|
206
|
-
|
|
354
|
+
if (fsApi.existsSync(path.join(projectRoot, '.git'))) {
|
|
355
|
+
// Lazy require: keep the worktree command light and avoid paying kernel setup
|
|
356
|
+
// when linkage is not needed. Migrated deps guarantee the 007 columns exist.
|
|
357
|
+
const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
|
|
358
|
+
driver = (await buildMigratedKernelIssueDeps({ projectRoot })).kernelDriver;
|
|
207
359
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const existing = readExistingWorktreeLinkage(driver, worktreePath, reuse);
|
|
363
|
+
const linkageConflict = detectLinkageConflict({ projectRoot, existing, issueId, workFolder, reuse });
|
|
364
|
+
if (linkageConflict) return { registered: false, ...linkageConflict };
|
|
365
|
+
|
|
366
|
+
const existingIssue = existing?.issue_id || null;
|
|
367
|
+
const existingFolder = existing?.work_folder || null;
|
|
368
|
+
const linkedIssue = issueId || (reuse ? existingIssue : null);
|
|
369
|
+
const linkedFolder = workFolder || (reuse ? existingFolder : null);
|
|
370
|
+
const markerTarget = validateMarkerTarget(projectRoot, linkedFolder, fsApi);
|
|
371
|
+
if (markerTarget?.conflict) {
|
|
372
|
+
return { registered: false, issueId: linkedIssue || null, workFolder: linkedFolder || null, ...markerTarget };
|
|
373
|
+
}
|
|
374
|
+
const markerConflict = reconcileIssueMarker({ target: markerTarget, linkedIssue, linkedFolder, fsApi });
|
|
375
|
+
if (markerConflict) {
|
|
376
|
+
return { registered: false, ...markerConflict };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (!driver) {
|
|
380
|
+
return { registered: false, issueId: linkedIssue || null, workFolder: linkedFolder || null, reason: 'no git repository' };
|
|
212
381
|
}
|
|
213
382
|
|
|
214
383
|
const { resolveGitCommonDir } = require('../kernel/broker');
|
|
@@ -219,13 +388,13 @@ async function registerWorktreeLinkage({ projectRoot, worktreePath, branch, issu
|
|
|
219
388
|
path: worktreePath,
|
|
220
389
|
branch,
|
|
221
390
|
actor: process.env.FORGE_ACTOR || null,
|
|
222
|
-
issue_id:
|
|
223
|
-
work_folder:
|
|
391
|
+
issue_id: linkedIssue || null,
|
|
392
|
+
work_folder: linkedFolder || null,
|
|
224
393
|
registered_at: new Date().toISOString(),
|
|
225
394
|
state: 'active',
|
|
226
395
|
});
|
|
227
396
|
|
|
228
|
-
return { registered: true, issueId:
|
|
397
|
+
return { registered: true, issueId: linkedIssue || null, workFolder: linkedFolder || null };
|
|
229
398
|
} catch (error) {
|
|
230
399
|
// Non-fatal: keep the worktree usable. Only warn when the caller explicitly asked
|
|
231
400
|
// for linkage (--issue/--work-folder); a plain `worktree create` stays quiet.
|
|
@@ -331,7 +500,10 @@ async function handleCreate(slug, flags, projectRoot, opts) {
|
|
|
331
500
|
// A detached worktree reports the literal "HEAD" — don't persist that; keep branchName.
|
|
332
501
|
if (head && head !== 'HEAD') existingBranch = head;
|
|
333
502
|
} catch (_error) { /* not a resolvable worktree HEAD — fall back to branchName */ }
|
|
334
|
-
const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: existingBranch, issueId, workFolder, opts });
|
|
503
|
+
const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: existingBranch, issueId, workFolder, opts, reuse: true });
|
|
504
|
+
if (linkage.conflict) {
|
|
505
|
+
return { success: false, reused: true, error: linkage.reason, worktreePath, linkage };
|
|
506
|
+
}
|
|
335
507
|
const backing = await autoFileBackingIssue({ projectRoot, worktreePath, branch: existingBranch, issueId, opts });
|
|
336
508
|
return {
|
|
337
509
|
success: true,
|
|
@@ -350,13 +522,13 @@ async function handleCreate(slug, flags, projectRoot, opts) {
|
|
|
350
522
|
// the repo's DEFAULT branch — NOT the checkout's current HEAD — so the worktree
|
|
351
523
|
// never silently inherits unrelated WIP commits (B2). An existing branch is
|
|
352
524
|
// checked out as-is (no base applies).
|
|
353
|
-
const hasBranch = branchExists(branchName, runFile);
|
|
525
|
+
const hasBranch = branchExists(branchName, projectRoot, runFile);
|
|
354
526
|
let base = null;
|
|
355
527
|
if (hasBranch) {
|
|
356
|
-
runFile('git', ['worktree', 'add', worktreePath, branchName], { stdio: 'pipe' });
|
|
528
|
+
runFile('git', ['-C', projectRoot, 'worktree', 'add', worktreePath, branchName], { stdio: 'pipe' });
|
|
357
529
|
} else {
|
|
358
530
|
base = explicitBase || resolveDefaultBase(projectRoot, runFile);
|
|
359
|
-
runFile('git', ['worktree', 'add', worktreePath, '-b', branchName, base], { stdio: 'pipe' });
|
|
531
|
+
runFile('git', ['-C', projectRoot, 'worktree', 'add', worktreePath, '-b', branchName, base], { stdio: 'pipe' });
|
|
360
532
|
}
|
|
361
533
|
|
|
362
534
|
// Step 3: Populate node_modules (link to the shared install, else install).
|
|
@@ -375,11 +547,16 @@ async function handleCreate(slug, flags, projectRoot, opts) {
|
|
|
375
547
|
}
|
|
376
548
|
|
|
377
549
|
const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: branchName, issueId, workFolder, opts });
|
|
550
|
+
if (linkage.conflict) {
|
|
551
|
+
return { success: false, error: linkage.reason, worktreePath, branch: branchName, linkage };
|
|
552
|
+
}
|
|
378
553
|
const backing = await autoFileBackingIssue({ projectRoot, worktreePath, branch: branchName, issueId, opts });
|
|
379
554
|
|
|
380
555
|
// Report the base so the fork point is never silent. `base` is null when an
|
|
381
556
|
// existing branch was checked out (no fork happened).
|
|
382
557
|
const baseNote = base ? `based on ${base}` : `existing branch ${branchName}`;
|
|
558
|
+
// Never let a heal be silent — a stale store is worth knowing about.
|
|
559
|
+
const healNote = deps.healed ? ' Healed dependencies (store was stale): reinstalled with --force.' : '';
|
|
383
560
|
return {
|
|
384
561
|
success: true,
|
|
385
562
|
worktreePath,
|
|
@@ -387,9 +564,10 @@ async function handleCreate(slug, flags, projectRoot, opts) {
|
|
|
387
564
|
base,
|
|
388
565
|
depsLinked: deps.linked,
|
|
389
566
|
depsInstalled: deps.installed,
|
|
567
|
+
depsHealed: deps.healed,
|
|
390
568
|
linkage,
|
|
391
569
|
backing,
|
|
392
|
-
output: `Created worktree ${worktreePath} on ${branchName} (${baseNote})
|
|
570
|
+
output: `Created worktree ${worktreePath} on ${branchName} (${baseNote}).${healNote}`,
|
|
393
571
|
};
|
|
394
572
|
}
|
|
395
573
|
|
|
@@ -406,12 +584,38 @@ async function handleList(projectRoot, opts) {
|
|
|
406
584
|
const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
|
|
407
585
|
driver = (await buildMigratedKernelIssueDeps({ projectRoot })).kernelDriver;
|
|
408
586
|
}
|
|
409
|
-
|
|
587
|
+
// `forge worktree remove` / `forge clean` delete the checkout but do not yet
|
|
588
|
+
// reconcile the kernel_worktrees registry (issue ceeef92f), so the raw registry
|
|
589
|
+
// can still list already-removed paths. Filter to worktrees that still exist on
|
|
590
|
+
// disk so `forge worktree list` never surfaces stale entries agents could act on.
|
|
591
|
+
const existsSync = (opts._fs && opts._fs.existsSync) || require('node:fs').existsSync;
|
|
592
|
+
const worktrees = driver.listWorktrees({}).filter((w) => w && w.path && existsSync(w.path));
|
|
593
|
+
return { success: true, worktrees, output: renderWorktreeList(worktrees) };
|
|
410
594
|
} catch (error) {
|
|
411
595
|
return { success: false, error: error.message, worktrees: [] };
|
|
412
596
|
}
|
|
413
597
|
}
|
|
414
598
|
|
|
599
|
+
/**
|
|
600
|
+
* Render the worktree registry as human-readable text for the CLI. The dispatcher
|
|
601
|
+
* prints only `result.output`, so without this `forge worktree list` is silent.
|
|
602
|
+
* @param {Array<object>} worktrees - Rows from driver.listWorktrees()
|
|
603
|
+
* @returns {string} One line per worktree (path, branch, linked issue, state).
|
|
604
|
+
*/
|
|
605
|
+
function renderWorktreeList(worktrees) {
|
|
606
|
+
if (!Array.isArray(worktrees) || worktrees.length === 0) {
|
|
607
|
+
return 'No worktrees registered.';
|
|
608
|
+
}
|
|
609
|
+
const lines = worktrees.map((w) => {
|
|
610
|
+
const parts = [w.path || '(unknown path)'];
|
|
611
|
+
if (w.branch) parts.push(`branch: ${w.branch}`);
|
|
612
|
+
if (w.issue_id) parts.push(`issue: ${w.issue_id}`);
|
|
613
|
+
if (w.state) parts.push(w.state);
|
|
614
|
+
return ` ${parts.join(' | ')}`;
|
|
615
|
+
});
|
|
616
|
+
return `Registered worktrees (${worktrees.length}):\n${lines.join('\n')}`;
|
|
617
|
+
}
|
|
618
|
+
|
|
415
619
|
/**
|
|
416
620
|
* Handle the "remove" subcommand.
|
|
417
621
|
* @param {string} slug - Worktree slug
|
|
@@ -161,6 +161,55 @@ const PLAN_SUBSKILL_DEFINITIONS = [
|
|
|
161
161
|
|
|
162
162
|
const PLAN_SUBSKILL_IDS = new Set(PLAN_SUBSKILL_DEFINITIONS.map(definition => definition.id));
|
|
163
163
|
|
|
164
|
+
// The `smith` orchestrator composes the stage skills end-to-end. Its sub-skills
|
|
165
|
+
// are the stage skill NAMES (each a real `skills/<name>/` dir), unlike plan's
|
|
166
|
+
// fine-grained internal planning phases. Both resolve through the ONE generic
|
|
167
|
+
// registry below (keyed by owning skill), so composition is no longer hardcoded
|
|
168
|
+
// to plan. See docs/work kernel-native-skills composition epic (a0776e61) +
|
|
169
|
+
// smith-orchestrator (7da81cbd).
|
|
170
|
+
const SMITH_SUBSKILL_DEFINITIONS = ['plan', 'dev', 'validate', 'ship', 'review', 'verify']
|
|
171
|
+
.map(stage => ({ id: stage, label: `${stage} stage`, owner: 'smith' }));
|
|
172
|
+
|
|
173
|
+
// Skills that `plan` COMPOSES at the whole-skill level (its SKILL.md `subskills:`
|
|
174
|
+
// frontmatter), distinct from the fine-grained plan.* planning micro-phases in
|
|
175
|
+
// PLAN_SUBSKILL_DEFINITIONS. A composed whole-skill is an advertised sub-skill
|
|
176
|
+
// that must both VALIDATE and RESOLVE, but it is NOT a partialInvocation
|
|
177
|
+
// micro-phase (it maps to no runtime-graph action), so it lives OUTSIDE
|
|
178
|
+
// PLAN_SUBSKILL_IDS (which governs planning.template.only/skip).
|
|
179
|
+
const PLAN_COMPOSED_SUBSKILLS = ['research'];
|
|
180
|
+
const PLAN_COMPOSED_SUBSKILL_DEFINITIONS = PLAN_COMPOSED_SUBSKILLS.map(id => ({
|
|
181
|
+
id,
|
|
182
|
+
label: `${id.charAt(0).toUpperCase()}${id.slice(1)} (composed skill)`,
|
|
183
|
+
kind: 'composed-skill',
|
|
184
|
+
owner: 'plan',
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
// Generic per-skill sub-skill registry, keyed by owning skill id. Generalizes
|
|
188
|
+
// plan's previously hardcoded PLAN_SUBSKILL_DEFINITIONS / validatePlanSubSkillList
|
|
189
|
+
// so any composing skill (plan, smith, ...) declares its sub-skill set through a
|
|
190
|
+
// single mechanism. This registry is the SINGLE source for BOTH validation (ids)
|
|
191
|
+
// and resolution (definitions), so getSubSkillDefinitions and validateSubSkillList
|
|
192
|
+
// can never disagree. plan's set = its planning micro-phases + composed whole-skills.
|
|
193
|
+
const SUBSKILL_REGISTRY = {
|
|
194
|
+
plan: [...PLAN_SUBSKILL_DEFINITIONS, ...PLAN_COMPOSED_SUBSKILL_DEFINITIONS],
|
|
195
|
+
smith: SMITH_SUBSKILL_DEFINITIONS,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Validation id sets DERIVED from the registry definitions — guarantees
|
|
199
|
+
// validate-vs-resolve parity for every owner (a subskill that validates also
|
|
200
|
+
// resolves, and vice versa).
|
|
201
|
+
const SUBSKILL_IDS_BY_OWNER = Object.fromEntries(
|
|
202
|
+
Object.entries(SUBSKILL_REGISTRY).map(([owner, defs]) => [owner, new Set(defs.map(d => d.id))])
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
function getSubSkillDefinitions(owner) {
|
|
206
|
+
return SUBSKILL_REGISTRY[owner] ? [...SUBSKILL_REGISTRY[owner]] : [];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getSubSkillIds(owner) {
|
|
210
|
+
return new Set(SUBSKILL_IDS_BY_OWNER[owner] || []);
|
|
211
|
+
}
|
|
212
|
+
|
|
164
213
|
function planningSubSkillFromDefinition(definition) {
|
|
165
214
|
return PlanningSubSkill({
|
|
166
215
|
id: definition.id,
|
|
@@ -485,7 +534,7 @@ const RESOLVED_RUNTIME_GRAPH = {
|
|
|
485
534
|
id: 'adapter.issue',
|
|
486
535
|
kind: 'issue',
|
|
487
536
|
label: 'Issue tracking adapter',
|
|
488
|
-
config: { primary: '
|
|
537
|
+
config: { primary: 'kernel', mirrors: ['github'] },
|
|
489
538
|
}),
|
|
490
539
|
Adapter({
|
|
491
540
|
id: 'adapter.harness',
|
|
@@ -777,31 +826,51 @@ function assertStringList(value, errors, code, message) {
|
|
|
777
826
|
return valid;
|
|
778
827
|
}
|
|
779
828
|
|
|
780
|
-
function
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
829
|
+
function subSkillCodesForOwner(owner) {
|
|
830
|
+
return owner === 'plan'
|
|
831
|
+
? { listCode: 'INVALID_PLAN_SUBSKILL_LIST', unknownCode: 'UNKNOWN_PLAN_SUBSKILL', noun: 'planning sub-skill' }
|
|
832
|
+
: { listCode: 'INVALID_SUBSKILL_LIST', unknownCode: 'UNKNOWN_SUBSKILL', noun: `${owner} sub-skill` };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Core list validator against an explicit known-id Set. The two callers below
|
|
836
|
+
// deliberately pass DIFFERENT id sets so the two contracts never leak into each
|
|
837
|
+
// other (see validatePlanSubSkillList).
|
|
838
|
+
function validateAgainstKnownIds(knownIds, codes, value, errors, path) {
|
|
839
|
+
const { listCode, unknownCode, noun } = codes;
|
|
840
|
+
if (!assertStringList(value, errors, listCode, `${path} must be a list of ${noun} IDs.`)) {
|
|
787
841
|
return undefined;
|
|
788
842
|
}
|
|
789
843
|
|
|
790
844
|
const normalized = value.map(item => item.trim());
|
|
791
845
|
let hasUnknown = false;
|
|
792
846
|
for (const id of normalized) {
|
|
793
|
-
if (!
|
|
847
|
+
if (!knownIds.has(id)) {
|
|
794
848
|
hasUnknown = true;
|
|
795
849
|
errors.push({
|
|
796
|
-
code:
|
|
797
|
-
message: `Unknown
|
|
850
|
+
code: unknownCode,
|
|
851
|
+
message: `Unknown ${noun} '${id}' in ${CONFIG_SOURCE}.`,
|
|
798
852
|
});
|
|
799
853
|
}
|
|
800
854
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
855
|
+
return hasUnknown ? undefined : normalized;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Generic COMPOSITION validator, keyed by owning skill — validates a skill's
|
|
859
|
+
// ADVERTISED (SKILL.md frontmatter) subskills. For `plan` the known-id set is the
|
|
860
|
+
// composition union (plan.* micro-phases + composed whole-skills like research).
|
|
861
|
+
function validateSubSkillList(owner, value, errors, path) {
|
|
862
|
+
const knownIds = SUBSKILL_IDS_BY_OWNER[owner] || new Set();
|
|
863
|
+
return validateAgainstKnownIds(knownIds, subSkillCodesForOwner(owner), value, errors, path);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// partialInvocation validator (.forge/config.yaml planning.template.only/skip).
|
|
867
|
+
// SEPARATE contract from composition: it MUST accept ONLY the fine-grained plan.*
|
|
868
|
+
// MICRO-PHASE ids (PLAN_SUBSKILL_IDS), never composed whole-skill ids like
|
|
869
|
+
// `research` — a composed skill maps to no runtime-graph action, so `only:
|
|
870
|
+
// [research]` would be silent dead config. Validates against PLAN_SUBSKILL_IDS,
|
|
871
|
+
// NOT the composition union, so it fails closed on composed ids.
|
|
872
|
+
function validatePlanSubSkillList(value, errors, path) {
|
|
873
|
+
return validateAgainstKnownIds(PLAN_SUBSKILL_IDS, subSkillCodesForOwner('plan'), value, errors, path);
|
|
805
874
|
}
|
|
806
875
|
|
|
807
876
|
function applyPlanningMode(template, nextTemplate, errors) {
|
|
@@ -968,6 +1037,11 @@ module.exports = {
|
|
|
968
1037
|
PlanningSubSkill,
|
|
969
1038
|
Role,
|
|
970
1039
|
ROLE_IDS,
|
|
1040
|
+
SUBSKILL_REGISTRY,
|
|
1041
|
+
getSubSkillDefinitions,
|
|
1042
|
+
getSubSkillIds,
|
|
1043
|
+
validateSubSkillList,
|
|
1044
|
+
validatePlanSubSkillList,
|
|
971
1045
|
loadRuntimeGraphConfig,
|
|
972
1046
|
lintRuntimeGraphConfig,
|
|
973
1047
|
resolveRuntimeGraph,
|