forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
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 +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- 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 +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -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/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -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 +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- 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/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const { stableStringify } = require('../kernel/evaluators');
|
|
6
|
+
|
|
7
|
+
const SURFACE_KINDS = new Set(['path', 'command', 'schema']);
|
|
8
|
+
|
|
9
|
+
function sha256(value) {
|
|
10
|
+
return `sha256:${crypto.createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function hashManifest(manifest) {
|
|
14
|
+
const { manifest_hash: _manifestHash, ...payload } = manifest;
|
|
15
|
+
return sha256(stableStringify(payload));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assert(condition, message) {
|
|
19
|
+
if (!condition) throw new Error(`Invalid risk manifest: ${message}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertUniqueIds(entries, label) {
|
|
23
|
+
const ids = new Set();
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
assert(entry && typeof entry.id === 'string' && entry.id.length > 0, `${label} id is required`);
|
|
26
|
+
assert(!ids.has(entry.id), `duplicate ${label} id "${entry.id}"`);
|
|
27
|
+
ids.add(entry.id);
|
|
28
|
+
}
|
|
29
|
+
return ids;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizePathValue(value) {
|
|
33
|
+
return value.replaceAll('\\', '/').replace(/^\.\//, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function matchesPathRoot(value, root) {
|
|
37
|
+
const normalizedValue = normalizePathValue(value);
|
|
38
|
+
const normalizedRoot = normalizePathValue(root).replace(/\/$/, '');
|
|
39
|
+
return normalizedValue === normalizedRoot || normalizedValue.startsWith(`${normalizedRoot}/`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pathSelectorsOverlap(left, right) {
|
|
43
|
+
const leftIsDirectory = left.endsWith('/');
|
|
44
|
+
const rightIsDirectory = right.endsWith('/');
|
|
45
|
+
if (!leftIsDirectory && !rightIsDirectory) return normalizePathValue(left) === normalizePathValue(right);
|
|
46
|
+
if (leftIsDirectory && matchesPathRoot(right.replace(/\/$/, ''), left)) return true;
|
|
47
|
+
return rightIsDirectory && matchesPathRoot(left.replace(/\/$/, ''), right);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function selectorsOverlap(left, right) {
|
|
51
|
+
if (left.kind !== right.kind) return false;
|
|
52
|
+
if (left.kind === 'path') return pathSelectorsOverlap(left.prefix, right.prefix);
|
|
53
|
+
return left.prefix.startsWith(right.prefix) || right.prefix.startsWith(left.prefix);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateCommandReferences(entries, label, commandIds) {
|
|
57
|
+
const ids = assertUniqueIds(entries, label);
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
assert(Array.isArray(entry.command_ids) && entry.command_ids.length > 0,
|
|
60
|
+
`${label} "${entry.id}" needs command_ids`);
|
|
61
|
+
assert(entry.command_ids.every((id) => commandIds.has(id)), `${label} "${entry.id}" references an unknown command`);
|
|
62
|
+
}
|
|
63
|
+
return ids;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateCommandRegistry(manifest) {
|
|
67
|
+
assert(Array.isArray(manifest.commands) && manifest.commands.length > 0, 'commands must be non-empty');
|
|
68
|
+
assert(Array.isArray(manifest.gates) && manifest.gates.length > 0, 'gates must be non-empty');
|
|
69
|
+
assert(Array.isArray(manifest.lanes) && manifest.lanes.length > 0, 'lanes must be non-empty');
|
|
70
|
+
const commandIds = assertUniqueIds(manifest.commands, 'command');
|
|
71
|
+
for (const command of manifest.commands) {
|
|
72
|
+
assert(typeof command.executable === 'string' && command.executable.length > 0,
|
|
73
|
+
`command "${command.id}" needs an executable`);
|
|
74
|
+
assert(Array.isArray(command.argv) && command.argv.every((argument) => typeof argument === 'string'),
|
|
75
|
+
`command "${command.id}" must use an argv array`);
|
|
76
|
+
assert(!Object.hasOwn(command, 'shell'), `command "${command.id}" cannot declare a shell string`);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
gateIds: validateCommandReferences(manifest.gates, 'gate', commandIds),
|
|
80
|
+
laneIds: validateCommandReferences(manifest.lanes, 'lane', commandIds),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateRisks(risks, gateIds) {
|
|
85
|
+
const riskIds = assertUniqueIds(risks, 'risk');
|
|
86
|
+
for (const risk of risks) {
|
|
87
|
+
assert(/^S[0-3]$/.test(risk.severity), `risk "${risk.id}" has invalid severity`);
|
|
88
|
+
assert(typeof risk.non_quarantinable === 'boolean', `risk "${risk.id}" must declare non_quarantinable`);
|
|
89
|
+
assert(Array.isArray(risk.gate_ids) && risk.gate_ids.length > 0,
|
|
90
|
+
`risk "${risk.id}" must own at least one gate`);
|
|
91
|
+
assert(risk.gate_ids.every((id) => gateIds.has(id)), `risk "${risk.id}" references an unknown gate`);
|
|
92
|
+
}
|
|
93
|
+
return riskIds;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateOwner(owner, riskIds, laneIds) {
|
|
97
|
+
assert(typeof owner.product === 'string' && owner.product.length > 0, `owner "${owner.id}" needs product`);
|
|
98
|
+
assert(typeof owner.package === 'string' && owner.package.length > 0, `owner "${owner.id}" needs package`);
|
|
99
|
+
assert(Array.isArray(owner.risk_ids) && owner.risk_ids.length > 0, `owner "${owner.id}" needs risk_ids`);
|
|
100
|
+
assert(owner.risk_ids.every((id) => riskIds.has(id)), `owner "${owner.id}" references an unknown risk`);
|
|
101
|
+
assert(Array.isArray(owner.canonical_test_ids) && owner.canonical_test_ids.length > 0,
|
|
102
|
+
`owner "${owner.id}" needs canonical_test_ids`);
|
|
103
|
+
assert(Array.isArray(owner.lanes) && owner.lanes.length > 0, `owner "${owner.id}" needs lanes`);
|
|
104
|
+
assert(owner.lanes.every((id) => laneIds.has(id)), `owner "${owner.id}" references an unknown lane`);
|
|
105
|
+
assert(Array.isArray(owner.dependent_routes), `owner "${owner.id}" needs dependent_routes`);
|
|
106
|
+
assert(Array.isArray(owner.platform_runtime_additions), `owner "${owner.id}" needs platform_runtime_additions`);
|
|
107
|
+
assert(Array.isArray(owner.selectors) && owner.selectors.length > 0, `owner "${owner.id}" needs selectors`);
|
|
108
|
+
assert(Array.isArray(owner.package_roots) && owner.package_roots.length > 0,
|
|
109
|
+
`owner "${owner.id}" needs package_roots`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function collectOwnershipMappings(owners, riskIds, laneIds) {
|
|
113
|
+
assertUniqueIds(owners, 'owner');
|
|
114
|
+
const selectors = [];
|
|
115
|
+
const packageRoots = [];
|
|
116
|
+
for (const owner of owners) {
|
|
117
|
+
validateOwner(owner, riskIds, laneIds);
|
|
118
|
+
for (const selector of owner.selectors) {
|
|
119
|
+
assert(SURFACE_KINDS.has(selector.kind), `owner "${owner.id}" has invalid selector kind`);
|
|
120
|
+
assert(typeof selector.prefix === 'string' && selector.prefix.length > 0,
|
|
121
|
+
`owner "${owner.id}" has an empty selector prefix`);
|
|
122
|
+
selectors.push({ ...selector, owner_id: owner.id });
|
|
123
|
+
}
|
|
124
|
+
for (const prefix of owner.package_roots) {
|
|
125
|
+
assert(typeof prefix === 'string' && prefix.length > 0, `owner "${owner.id}" has an empty package root`);
|
|
126
|
+
packageRoots.push({ prefix, owner_id: owner.id });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { selectors, packageRoots };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function assertOwnershipMappingsDoNotOverlap(selectors, packageRoots) {
|
|
133
|
+
for (let index = 0; index < selectors.length; index += 1) {
|
|
134
|
+
for (let peer = index + 1; peer < selectors.length; peer += 1) {
|
|
135
|
+
assert(!selectorsOverlap(selectors[index], selectors[peer]),
|
|
136
|
+
`selectors overlap between "${selectors[index].owner_id}" and "${selectors[peer].owner_id}"`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (let index = 0; index < packageRoots.length; index += 1) {
|
|
140
|
+
for (let peer = index + 1; peer < packageRoots.length; peer += 1) {
|
|
141
|
+
const left = packageRoots[index];
|
|
142
|
+
const right = packageRoots[peer];
|
|
143
|
+
assert(!(matchesPathRoot(left.prefix, right.prefix) || matchesPathRoot(right.prefix, left.prefix)),
|
|
144
|
+
`package roots overlap between "${left.owner_id}" and "${right.owner_id}"`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const REQUIRED_CONSERVATIVE_GATES = ['G0', 'G1', 'G3', 'G6'];
|
|
150
|
+
|
|
151
|
+
function validateFallback(manifest, laneIds, gateIds) {
|
|
152
|
+
assert(manifest.unknown_owner_fallback?.lane === 'repository-baseline',
|
|
153
|
+
'unknown_owner_fallback must select repository-baseline');
|
|
154
|
+
assert(laneIds.has(manifest.unknown_owner_fallback?.lane), 'unknown_owner_fallback references an unknown lane');
|
|
155
|
+
assert(laneIds.has('contract-baseline') && laneIds.has('affected-platform-baseline'),
|
|
156
|
+
'conservative fallback lanes must be executable');
|
|
157
|
+
assert(Array.isArray(manifest.unknown_owner_fallback.required_gates)
|
|
158
|
+
&& manifest.unknown_owner_fallback.required_gates.length > 0,
|
|
159
|
+
'unknown_owner_fallback must declare required_gates');
|
|
160
|
+
for (const gateId of REQUIRED_CONSERVATIVE_GATES) {
|
|
161
|
+
assert(gateIds.has(gateId), `conservative fallback references unknown gate ${gateId}`);
|
|
162
|
+
assert(manifest.unknown_owner_fallback.required_gates.includes(gateId),
|
|
163
|
+
`conservative fallback must require ${gateId}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function validateManifest(manifest, { verifyHash = true } = {}) {
|
|
168
|
+
assert(manifest && typeof manifest === 'object' && !Array.isArray(manifest), 'root must be an object');
|
|
169
|
+
assert(manifest.schema_id === 'forge.validation.risk-manifest.v1', 'unsupported schema_id');
|
|
170
|
+
assert(Number.isInteger(manifest.revision) && manifest.revision > 0, 'revision must be a positive integer');
|
|
171
|
+
assert(/^sha256:[a-f0-9]{64}$/.test(manifest.source_hash), 'source_hash must be a SHA-256 digest');
|
|
172
|
+
assert(typeof manifest.generator_version === 'string' && manifest.generator_version.length > 0,
|
|
173
|
+
'generator_version is required');
|
|
174
|
+
assert(Array.isArray(manifest.risks) && manifest.risks.length > 0, 'risks must be non-empty');
|
|
175
|
+
assert(Array.isArray(manifest.owners) && manifest.owners.length > 0, 'owners must be non-empty');
|
|
176
|
+
|
|
177
|
+
const { gateIds, laneIds } = validateCommandRegistry(manifest);
|
|
178
|
+
const riskIds = validateRisks(manifest.risks, gateIds);
|
|
179
|
+
const mappings = collectOwnershipMappings(manifest.owners, riskIds, laneIds);
|
|
180
|
+
assertOwnershipMappingsDoNotOverlap(mappings.selectors, mappings.packageRoots);
|
|
181
|
+
validateFallback(manifest, laneIds, gateIds);
|
|
182
|
+
|
|
183
|
+
if (verifyHash) {
|
|
184
|
+
assert(/^sha256:[a-f0-9]{64}$/.test(manifest.manifest_hash), 'manifest_hash must be a SHA-256 digest');
|
|
185
|
+
assert(manifest.manifest_hash === hashManifest(manifest), 'manifest hash mismatch');
|
|
186
|
+
}
|
|
187
|
+
return manifest;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function loadRiskManifest(input) {
|
|
191
|
+
const manifest = typeof input === 'string'
|
|
192
|
+
? JSON.parse(fs.readFileSync(input, 'utf8'))
|
|
193
|
+
: structuredClone(input);
|
|
194
|
+
return validateManifest(manifest);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeSurface(surface) {
|
|
198
|
+
assert(surface && SURFACE_KINDS.has(surface.kind), 'changed surface has an invalid kind');
|
|
199
|
+
assert(typeof surface.value === 'string' && surface.value.trim().length > 0,
|
|
200
|
+
'changed surface value is required');
|
|
201
|
+
const value = surface.kind === 'path'
|
|
202
|
+
? normalizePathValue(surface.value.trim())
|
|
203
|
+
: surface.value.trim();
|
|
204
|
+
return { kind: surface.kind, value };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function uniqueSorted(values) {
|
|
208
|
+
return [...new Set(values)].sort(compareText);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function compareText(left, right) {
|
|
212
|
+
if (left === right) return 0;
|
|
213
|
+
return left < right ? -1 : 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizeChangedSurfaces(changedSurfaces) {
|
|
217
|
+
assert(Array.isArray(changedSurfaces), 'changedSurfaces must be an array');
|
|
218
|
+
const keyed = new Map();
|
|
219
|
+
for (const surface of changedSurfaces) {
|
|
220
|
+
const normalized = normalizeSurface(surface);
|
|
221
|
+
keyed.set(`${normalized.kind}\0${normalized.value}`, normalized);
|
|
222
|
+
}
|
|
223
|
+
return [...keyed.values()].sort((left, right) => compareText(left.kind, right.kind)
|
|
224
|
+
|| compareText(left.value, right.value));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function ownerForExactSurface(manifest, surface) {
|
|
228
|
+
for (const owner of manifest.owners) {
|
|
229
|
+
const selector = owner.selectors.find((candidate) => candidate.kind === surface.kind
|
|
230
|
+
&& (surface.kind === 'path'
|
|
231
|
+
? (candidate.prefix.endsWith('/')
|
|
232
|
+
? matchesPathRoot(surface.value, candidate.prefix)
|
|
233
|
+
: surface.value === normalizePathValue(candidate.prefix))
|
|
234
|
+
: surface.value.startsWith(candidate.prefix)));
|
|
235
|
+
if (selector) return { owner, selector };
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function ownerForKnownPackage(manifest, surface) {
|
|
241
|
+
if (surface.kind !== 'path') return undefined;
|
|
242
|
+
return manifest.owners.find((owner) => owner.package_roots.some((root) => matchesPathRoot(surface.value, root)));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function ownerRisks(manifest, owners) {
|
|
246
|
+
const ids = new Set(owners.flatMap((owner) => owner.risk_ids));
|
|
247
|
+
return manifest.risks.filter((risk) => ids.has(risk.id));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function resolveCommands(manifest, gateIds, laneIds) {
|
|
251
|
+
const selectedDefinitions = [
|
|
252
|
+
...manifest.gates.filter((gate) => gateIds.includes(gate.id)),
|
|
253
|
+
...manifest.lanes.filter((lane) => laneIds.includes(lane.id)),
|
|
254
|
+
];
|
|
255
|
+
const commandIds = uniqueSorted(selectedDefinitions.flatMap((definition) => definition.command_ids));
|
|
256
|
+
const commandsById = new Map(manifest.commands.map((command) => [command.id, command]));
|
|
257
|
+
return commandIds.map((id) => {
|
|
258
|
+
const command = commandsById.get(id);
|
|
259
|
+
return { id: command.id, executable: command.executable, argv: [...command.argv] };
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function selectValidation({ manifest, changedSurfaces }) {
|
|
264
|
+
validateManifest(manifest);
|
|
265
|
+
const surfaces = normalizeChangedSurfaces(changedSurfaces);
|
|
266
|
+
const selections = surfaces.map((surface) => ({
|
|
267
|
+
surface,
|
|
268
|
+
exact: ownerForExactSurface(manifest, surface),
|
|
269
|
+
package: ownerForKnownPackage(manifest, surface),
|
|
270
|
+
}));
|
|
271
|
+
const unowned = selections.filter((selection) => !selection.exact && !selection.package)
|
|
272
|
+
.map((selection) => selection.surface);
|
|
273
|
+
const ambiguous = selections.filter((selection) => !selection.exact && selection.package);
|
|
274
|
+
const exactOwners = selections.map((selection) => selection.exact?.owner).filter(Boolean);
|
|
275
|
+
const packageOwners = ambiguous.map((selection) => selection.package);
|
|
276
|
+
const owners = [...new Map([...exactOwners, ...packageOwners].map((owner) => [owner.id, owner])).values()]
|
|
277
|
+
.sort((left, right) => compareText(left.id, right.id));
|
|
278
|
+
const risks = ownerRisks(manifest, owners);
|
|
279
|
+
|
|
280
|
+
let status = 'exact';
|
|
281
|
+
let targetedPassAllowed = surfaces.length > 0;
|
|
282
|
+
let lanes = owners.flatMap((owner) => owner.lanes);
|
|
283
|
+
let requiredGates = risks.flatMap((risk) => risk.gate_ids);
|
|
284
|
+
|
|
285
|
+
if (unowned.length > 0 || surfaces.length === 0) {
|
|
286
|
+
status = 'repository-baseline';
|
|
287
|
+
targetedPassAllowed = false;
|
|
288
|
+
lanes = [manifest.unknown_owner_fallback.lane];
|
|
289
|
+
requiredGates = manifest.unknown_owner_fallback.required_gates;
|
|
290
|
+
} else if (ambiguous.length > 0) {
|
|
291
|
+
status = 'conservative-package';
|
|
292
|
+
targetedPassAllowed = false;
|
|
293
|
+
lanes.push('contract-baseline', 'affected-platform-baseline');
|
|
294
|
+
requiredGates.push('G0', 'G1', 'G3', 'G6');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const selectedLanes = uniqueSorted(lanes);
|
|
298
|
+
const selectedGates = uniqueSorted(requiredGates);
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
schema_id: 'forge.validation.selection.v1',
|
|
302
|
+
manifest_revision: manifest.revision,
|
|
303
|
+
manifest_digest: manifest.manifest_hash,
|
|
304
|
+
status,
|
|
305
|
+
targeted_pass_allowed: targetedPassAllowed,
|
|
306
|
+
changed_surfaces: surfaces,
|
|
307
|
+
unowned_surfaces: unowned,
|
|
308
|
+
matched_selectors: selections.filter((selection) => selection.exact).map((selection) => ({
|
|
309
|
+
surface: selection.surface,
|
|
310
|
+
owner_id: selection.exact.owner.id,
|
|
311
|
+
selector: selection.exact.selector,
|
|
312
|
+
})),
|
|
313
|
+
owner_ids: owners.map((owner) => owner.id),
|
|
314
|
+
owner_selections: owners.map((owner) => ({
|
|
315
|
+
owner_id: owner.id,
|
|
316
|
+
product: owner.product,
|
|
317
|
+
package: owner.package,
|
|
318
|
+
risk_ids: uniqueSorted(owner.risk_ids),
|
|
319
|
+
lanes: uniqueSorted(owner.lanes),
|
|
320
|
+
canonical_test_ids: uniqueSorted(owner.canonical_test_ids),
|
|
321
|
+
dependent_routes: uniqueSorted(owner.dependent_routes),
|
|
322
|
+
platform_runtime_additions: uniqueSorted(owner.platform_runtime_additions),
|
|
323
|
+
})),
|
|
324
|
+
risk_ids: uniqueSorted(risks.map((risk) => risk.id)),
|
|
325
|
+
required_gates: selectedGates,
|
|
326
|
+
lanes: selectedLanes,
|
|
327
|
+
dependent_routes: uniqueSorted(owners.flatMap((owner) => owner.dependent_routes)),
|
|
328
|
+
platform_runtime_additions: uniqueSorted(owners.flatMap((owner) => owner.platform_runtime_additions)),
|
|
329
|
+
test_ids: uniqueSorted(owners.flatMap((owner) => owner.canonical_test_ids)),
|
|
330
|
+
commands: resolveCommands(manifest, selectedGates, selectedLanes),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
module.exports = {
|
|
335
|
+
hashManifest,
|
|
336
|
+
loadRiskManifest,
|
|
337
|
+
selectValidation,
|
|
338
|
+
validateManifest,
|
|
339
|
+
};
|
|
@@ -10,6 +10,11 @@ const {
|
|
|
10
10
|
readWorkflowState,
|
|
11
11
|
} = require('./state');
|
|
12
12
|
const { loadState, WORKFLOW_STATE_FILENAME } = require('./state-manager');
|
|
13
|
+
const {
|
|
14
|
+
findPlanWorkFolder,
|
|
15
|
+
readPlanSnapshot,
|
|
16
|
+
reconcilePlanAuthority,
|
|
17
|
+
} = require('./plan-authority');
|
|
13
18
|
|
|
14
19
|
const STATELESS_ENTRY_STAGES = new Set(['plan', 'dev', 'validate', 'verify']);
|
|
15
20
|
|
|
@@ -204,6 +209,42 @@ async function resolveKernelContext({ kernelDriver, activeIssueId, branch, proje
|
|
|
204
209
|
return { driver, issueId };
|
|
205
210
|
}
|
|
206
211
|
|
|
212
|
+
function linkedWorkFolder(driver, issueId, branch) {
|
|
213
|
+
if (!driver || typeof driver.listWorktrees !== 'function') return null;
|
|
214
|
+
try {
|
|
215
|
+
const row = (driver.listWorktrees() || []).find((entry) => entry?.issue_id === issueId
|
|
216
|
+
&& (!branch || entry.branch === branch) && (entry.state === 'active' || entry.state == null));
|
|
217
|
+
return row?.work_folder || null;
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function planRepairCommand(branch, issueId) {
|
|
224
|
+
const normalized = String(branch || '').replace(/^[^/]+\//, '').split('-').filter(Boolean).join(' ');
|
|
225
|
+
return `forge plan ${JSON.stringify(normalized || 'feature')} --issue ${issueId}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function enforcePlanAuthorityForDev({ driver, issueId, projectRoot, branch }) {
|
|
229
|
+
if (!projectRoot || !driver || typeof driver.loadPlanSnapshot !== 'function') return;
|
|
230
|
+
const snapshot = readPlanSnapshot(driver, issueId);
|
|
231
|
+
const slug = String(branch || '').replace(/^[^/]+\//, '');
|
|
232
|
+
const workFolder = linkedWorkFolder(driver, issueId, branch)
|
|
233
|
+
|| (!snapshot ? findPlanWorkFolder(projectRoot, slug) : null);
|
|
234
|
+
// Pre-0.1.0 issues without either a snapshot or a linked repository folder are
|
|
235
|
+
// intentionally unmanaged for compatibility. Once either side exists, the
|
|
236
|
+
// authority contract is strict and all missing/drift cases fail closed.
|
|
237
|
+
if (!snapshot && !workFolder) return;
|
|
238
|
+
reconcilePlanAuthority({
|
|
239
|
+
driver,
|
|
240
|
+
issueId,
|
|
241
|
+
projectRoot,
|
|
242
|
+
workFolder,
|
|
243
|
+
mode: 'dev',
|
|
244
|
+
repairCommand: planRepairCommand(branch, issueId),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
207
248
|
function getOverrideInput(flags = {}) {
|
|
208
249
|
if (Object.hasOwn(flags, 'overrideStage')) {
|
|
209
250
|
return flags.overrideStage;
|
|
@@ -416,6 +457,9 @@ async function enforceStageEntry({
|
|
|
416
457
|
// gate below requires the predecessor to be done, not merely entered).
|
|
417
458
|
const finish = (result) => {
|
|
418
459
|
if (kernelActive) {
|
|
460
|
+
if (stageId === 'dev') {
|
|
461
|
+
enforcePlanAuthorityForDev({ driver, issueId, projectRoot, branch: branch || detectBranchName(projectRoot) });
|
|
462
|
+
}
|
|
419
463
|
recordStageRunSafe(driver, issueId, stageId, 'start', warn);
|
|
420
464
|
invalidateDownstreamStages(driver, issueId, stageId, warn);
|
|
421
465
|
result.recordCompletion = () => recordStageRunSafe(driver, issueId, stageId, 'complete', warn);
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const SCHEMA = 'forge.plan.v1';
|
|
8
|
+
const ARTIFACT_FILES = Object.freeze(['plan.md', 'tasks.md']);
|
|
9
|
+
|
|
10
|
+
function sha256(content) {
|
|
11
|
+
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readUtf8(absolutePath) {
|
|
15
|
+
try {
|
|
16
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(absolutePath));
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw new Error(`Plan artifact must be valid UTF-8: ${absolutePath} (${error.message})`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeRelativePath(projectRoot, candidate) {
|
|
23
|
+
const absolute = path.resolve(projectRoot, candidate);
|
|
24
|
+
const relative = path.relative(projectRoot, absolute);
|
|
25
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
26
|
+
throw new Error('Plan artifact paths must stay inside the project root');
|
|
27
|
+
}
|
|
28
|
+
return relative.split(path.sep).join('/');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function artifactPath(projectRoot, relativePath) {
|
|
32
|
+
return path.join(projectRoot, ...relativePath.split('/'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isContained(root, candidate) {
|
|
36
|
+
const relative = path.relative(root, candidate);
|
|
37
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve every existing component below the project root. Refuse links/reparse
|
|
41
|
+
// points even when their target happens to remain inside the repo: allowing them
|
|
42
|
+
// would make a later target swap bypass the containment decision.
|
|
43
|
+
function assertSafeArtifactPath(projectRoot, absolutePath) {
|
|
44
|
+
const rootAbsolute = path.resolve(projectRoot);
|
|
45
|
+
const candidateAbsolute = path.resolve(absolutePath);
|
|
46
|
+
if (!isContained(rootAbsolute, candidateAbsolute)) {
|
|
47
|
+
throw new Error('Plan artifact path escapes the project root');
|
|
48
|
+
}
|
|
49
|
+
const rootReal = fs.realpathSync.native(rootAbsolute);
|
|
50
|
+
const segments = path.relative(rootAbsolute, candidateAbsolute).split(path.sep).filter(Boolean);
|
|
51
|
+
let current = rootAbsolute;
|
|
52
|
+
for (const segment of segments) {
|
|
53
|
+
current = path.join(current, segment);
|
|
54
|
+
if (!fs.existsSync(current)) break;
|
|
55
|
+
const stat = fs.lstatSync(current);
|
|
56
|
+
if (stat.isSymbolicLink()) {
|
|
57
|
+
throw new Error('Plan artifact path contains a symbolic link or reparse point');
|
|
58
|
+
}
|
|
59
|
+
const currentReal = fs.realpathSync.native(current);
|
|
60
|
+
if (!isContained(rootReal, currentReal)) {
|
|
61
|
+
throw new Error('Plan artifact path resolves outside the project root');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function snapshotDigest(artifacts) {
|
|
67
|
+
const canonical = JSON.stringify(artifacts.map(({ path: artifact, sha256: digest }) => ({ path: artifact, sha256: digest })));
|
|
68
|
+
return sha256(canonical);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createSnapshot({ projectRoot, workFolder }) {
|
|
72
|
+
const folder = normalizeRelativePath(projectRoot, workFolder);
|
|
73
|
+
const artifacts = ARTIFACT_FILES.map((filename) => {
|
|
74
|
+
const relativePath = `${folder}/${filename}`;
|
|
75
|
+
const absolutePath = artifactPath(projectRoot, relativePath);
|
|
76
|
+
assertSafeArtifactPath(projectRoot, absolutePath);
|
|
77
|
+
if (!fs.existsSync(absolutePath)) return null;
|
|
78
|
+
if (!fs.lstatSync(absolutePath).isFile()) {
|
|
79
|
+
throw new Error(`Plan artifact must be a regular file: ${relativePath}`);
|
|
80
|
+
}
|
|
81
|
+
const content = readUtf8(absolutePath);
|
|
82
|
+
return { path: relativePath, content, sha256: sha256(content) };
|
|
83
|
+
});
|
|
84
|
+
if (artifacts.some((entry) => entry === null)) return null;
|
|
85
|
+
return { schema: SCHEMA, digest: snapshotDigest(artifacts), artifacts };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function findPlanWorkFolder(projectRoot, featureSlug) {
|
|
89
|
+
if (!projectRoot || !featureSlug) return null;
|
|
90
|
+
const workRoot = path.join(projectRoot, 'docs', 'work');
|
|
91
|
+
if (!fs.existsSync(workRoot)) return null;
|
|
92
|
+
const suffix = `-${featureSlug}`;
|
|
93
|
+
const matches = fs.readdirSync(workRoot, { withFileTypes: true })
|
|
94
|
+
.filter((entry) => entry.isDirectory() && (entry.name === featureSlug || entry.name.endsWith(suffix)))
|
|
95
|
+
.map((entry) => `docs/work/${entry.name}`)
|
|
96
|
+
.filter((folder) => ARTIFACT_FILES.every((filename) => fs.existsSync(artifactPath(projectRoot, `${folder}/${filename}`))))
|
|
97
|
+
.sort();
|
|
98
|
+
return matches.length === 1 ? matches[0] : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateSnapshot(snapshot) {
|
|
102
|
+
if (!snapshot || snapshot.schema !== SCHEMA || !Array.isArray(snapshot.artifacts)) {
|
|
103
|
+
throw new Error('Kernel plan snapshot is invalid');
|
|
104
|
+
}
|
|
105
|
+
if (snapshot.artifacts.length !== ARTIFACT_FILES.length) {
|
|
106
|
+
throw new Error('Kernel plan snapshot is invalid');
|
|
107
|
+
}
|
|
108
|
+
const expectedNames = new Set(ARTIFACT_FILES);
|
|
109
|
+
const observedNames = new Set();
|
|
110
|
+
for (const artifact of snapshot.artifacts) {
|
|
111
|
+
if (!artifact || typeof artifact.path !== 'string' || typeof artifact.content !== 'string'
|
|
112
|
+
|| sha256(artifact.content) !== artifact.sha256) {
|
|
113
|
+
throw new Error('Kernel plan snapshot is invalid');
|
|
114
|
+
}
|
|
115
|
+
const filename = path.posix.basename(artifact.path);
|
|
116
|
+
if (!expectedNames.has(filename) || observedNames.has(filename)) {
|
|
117
|
+
throw new Error('Kernel plan snapshot is invalid');
|
|
118
|
+
}
|
|
119
|
+
observedNames.add(filename);
|
|
120
|
+
}
|
|
121
|
+
if (snapshot.digest !== snapshotDigest(snapshot.artifacts)) {
|
|
122
|
+
throw new Error('Kernel plan snapshot is invalid');
|
|
123
|
+
}
|
|
124
|
+
return snapshot;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readPlanSnapshot(driver, issueId) {
|
|
128
|
+
if (!driver || typeof driver.loadPlanSnapshot !== 'function') return null;
|
|
129
|
+
const snapshot = driver.loadPlanSnapshot({ issue_id: issueId }, {});
|
|
130
|
+
return snapshot ? validateSnapshot(snapshot) : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function inspectArtifacts(projectRoot, snapshot) {
|
|
134
|
+
const missing = [];
|
|
135
|
+
const drifted = [];
|
|
136
|
+
for (const artifact of snapshot.artifacts) {
|
|
137
|
+
const relative = normalizeRelativePath(projectRoot, artifact.path);
|
|
138
|
+
const absolute = artifactPath(projectRoot, relative);
|
|
139
|
+
assertSafeArtifactPath(projectRoot, absolute);
|
|
140
|
+
if (!fs.existsSync(absolute)) {
|
|
141
|
+
missing.push({ ...artifact, absolute });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const content = readUtf8(absolute);
|
|
145
|
+
if (sha256(content) !== artifact.sha256) drifted.push(artifact.path);
|
|
146
|
+
}
|
|
147
|
+
return { missing, drifted };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function materializeMissing(projectRoot, missing) {
|
|
151
|
+
const staged = [];
|
|
152
|
+
const created = [];
|
|
153
|
+
try {
|
|
154
|
+
for (const artifact of missing) {
|
|
155
|
+
assertSafeArtifactPath(projectRoot, artifact.absolute);
|
|
156
|
+
fs.mkdirSync(path.dirname(artifact.absolute), { recursive: true });
|
|
157
|
+
assertSafeArtifactPath(projectRoot, artifact.absolute);
|
|
158
|
+
const temporary = `${artifact.absolute}.forge-${randomUUID()}.tmp`;
|
|
159
|
+
fs.writeFileSync(temporary, artifact.content, { encoding: 'utf8', flag: 'wx' });
|
|
160
|
+
staged.push({ temporary, destination: artifact.absolute });
|
|
161
|
+
}
|
|
162
|
+
for (const entry of staged) {
|
|
163
|
+
// link is create-only (fails EEXIST), unlike rename which may overwrite a
|
|
164
|
+
// file another process materialized after our drift check.
|
|
165
|
+
fs.linkSync(entry.temporary, entry.destination);
|
|
166
|
+
fs.rmSync(entry.temporary, { force: true });
|
|
167
|
+
created.push(entry.destination);
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
for (const entry of staged) {
|
|
171
|
+
if (fs.existsSync(entry.temporary)) fs.rmSync(entry.temporary, { force: true });
|
|
172
|
+
}
|
|
173
|
+
for (const destination of created) {
|
|
174
|
+
if (fs.existsSync(destination)) fs.rmSync(destination, { force: true });
|
|
175
|
+
}
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function guidance(message, repairCommand) {
|
|
181
|
+
return `${message} ${repairCommand}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function reconcilePlanAuthority({
|
|
185
|
+
driver,
|
|
186
|
+
issueId,
|
|
187
|
+
projectRoot,
|
|
188
|
+
workFolder,
|
|
189
|
+
mode = 'dev',
|
|
190
|
+
repairCommand = `forge plan "<feature>" --issue ${issueId}`,
|
|
191
|
+
} = {}) {
|
|
192
|
+
if (!driver || !issueId || !projectRoot) {
|
|
193
|
+
throw new Error('Plan authority requires a Kernel driver, issue id, and project root');
|
|
194
|
+
}
|
|
195
|
+
const existing = readPlanSnapshot(driver, issueId);
|
|
196
|
+
if (!existing) {
|
|
197
|
+
const snapshot = workFolder ? createSnapshot({ projectRoot, workFolder }) : null;
|
|
198
|
+
if (!snapshot) {
|
|
199
|
+
throw new Error(guidance('Plan authority is missing. Create plan.md and tasks.md, then run:', repairCommand));
|
|
200
|
+
}
|
|
201
|
+
if (typeof driver.recordPlanSnapshotTransition !== 'function') {
|
|
202
|
+
throw new Error('Kernel driver cannot persist plan authority');
|
|
203
|
+
}
|
|
204
|
+
driver.recordPlanSnapshotTransition({ issue_id: issueId, snapshot }, {});
|
|
205
|
+
return { status: 'captured', snapshot };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const { missing, drifted } = inspectArtifacts(projectRoot, existing);
|
|
209
|
+
if (drifted.length > 0) {
|
|
210
|
+
throw new Error(guidance('Plan artifacts drifted from Kernel authority. Reconcile the files, then run:', repairCommand));
|
|
211
|
+
}
|
|
212
|
+
if (missing.length > 0) {
|
|
213
|
+
materializeMissing(projectRoot, missing);
|
|
214
|
+
return { status: 'materialized', snapshot: existing };
|
|
215
|
+
}
|
|
216
|
+
return { status: mode === 'plan' ? 'reconciled' : 'verified', snapshot: existing };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
SCHEMA,
|
|
221
|
+
createSnapshot,
|
|
222
|
+
findPlanWorkFolder,
|
|
223
|
+
readPlanSnapshot,
|
|
224
|
+
reconcilePlanAuthority,
|
|
225
|
+
};
|