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,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const {
|
|
6
|
+
DEFAULT_MANIFEST_DIR,
|
|
7
|
+
defaultGetProcessIdentity,
|
|
8
|
+
isValidManifest,
|
|
9
|
+
readProcessManifest,
|
|
10
|
+
} = require('../../scripts/process-tree');
|
|
11
|
+
|
|
12
|
+
function nonEmptyString(value) {
|
|
13
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function positivePid(value) {
|
|
17
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function verifiedManifestForClaim(claim, manifest) {
|
|
21
|
+
const sessionId = nonEmptyString(claim?.session_id);
|
|
22
|
+
const worktreeId = nonEmptyString(claim?.worktree_id);
|
|
23
|
+
if (!nonEmptyString(claim?.id) || !nonEmptyString(claim?.issue_id) || !sessionId || !worktreeId) return null;
|
|
24
|
+
if (claim.state != null && claim.state !== 'active') return null;
|
|
25
|
+
if (!isValidManifest(manifest) || manifest.token !== sessionId) return null;
|
|
26
|
+
|
|
27
|
+
const owner = manifest.owner;
|
|
28
|
+
const pid = positivePid(owner?.pid);
|
|
29
|
+
const identity = nonEmptyString(owner?.identity);
|
|
30
|
+
const startedAt = nonEmptyString(owner?.startedAt);
|
|
31
|
+
if (!pid || !identity || identity.startsWith('unverified:') || !startedAt || !Array.isArray(manifest.children)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { sessionId, worktreeId, pid, identity, startedAt };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function defaultIsProcessAlive(pid) {
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0);
|
|
41
|
+
return true;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return error?.code === 'EPERM';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createManifestReader(options = {}) {
|
|
48
|
+
const fsApi = options.fsApi || fs;
|
|
49
|
+
const processApi = options.processApi || process;
|
|
50
|
+
const manifestDir = options.manifestDir || DEFAULT_MANIFEST_DIR;
|
|
51
|
+
return async (sessionId) => {
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = fsApi.readdirSync(manifestDir, { withFileTypes: true });
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const matches = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const name = typeof entry === 'string' ? entry : entry?.name;
|
|
61
|
+
if (!name || !name.endsWith('.json')) continue;
|
|
62
|
+
if (typeof entry !== 'string' && entry.isFile && !entry.isFile()) continue;
|
|
63
|
+
const manifest = readProcessManifest(path.join(manifestDir, name), fsApi, processApi);
|
|
64
|
+
if (manifest?.token === sessionId) matches.push(manifest);
|
|
65
|
+
}
|
|
66
|
+
return matches.length === 1 ? matches[0] : null;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createWorktreeInspector(driver, fsApi = fs) {
|
|
71
|
+
return async (worktreeId, claim) => {
|
|
72
|
+
let rows;
|
|
73
|
+
try {
|
|
74
|
+
rows = await driver.listWorktrees({});
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const linked = (Array.isArray(rows) ? rows : []).filter(row => row
|
|
79
|
+
&& row.issue_id === claim.issue_id
|
|
80
|
+
&& row.state === 'active'
|
|
81
|
+
&& (row.id === worktreeId || path.basename(row.path || '') === worktreeId));
|
|
82
|
+
if (linked.length !== 1 || !nonEmptyString(linked[0].path)) return null;
|
|
83
|
+
try {
|
|
84
|
+
if (typeof fsApi.lstatSync === 'function') {
|
|
85
|
+
fsApi.lstatSync(linked[0].path);
|
|
86
|
+
return { exists: true, row: linked[0] };
|
|
87
|
+
}
|
|
88
|
+
return { exists: fsApi.existsSync(linked[0].path), row: linked[0] };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
|
|
91
|
+
return { exists: false, row: linked[0] };
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function createMissingPathVerifier(fsApi = fs) {
|
|
99
|
+
return (worktreePath) => {
|
|
100
|
+
try {
|
|
101
|
+
if (typeof fsApi.lstatSync === 'function') fsApi.lstatSync(worktreePath);
|
|
102
|
+
else if (fsApi.existsSync(worktreePath)) return false;
|
|
103
|
+
else return true;
|
|
104
|
+
return false;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function reconcileClaims(options = {}) {
|
|
112
|
+
const claims = Array.isArray(options.claims) ? options.claims : [];
|
|
113
|
+
const readManifest = options.readManifest;
|
|
114
|
+
const worktreeExists = options.worktreeExists;
|
|
115
|
+
const isProcessAlive = options.isProcessAlive;
|
|
116
|
+
const getProcessIdentity = options.getProcessIdentity;
|
|
117
|
+
const releaseClaim = options.releaseClaim;
|
|
118
|
+
const released = [];
|
|
119
|
+
const attempted = new Set();
|
|
120
|
+
|
|
121
|
+
if (![readManifest, worktreeExists, isProcessAlive, getProcessIdentity, releaseClaim]
|
|
122
|
+
.every(dependency => typeof dependency === 'function')) {
|
|
123
|
+
return { examined: claims.length, released };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const claim of claims) {
|
|
127
|
+
if (!nonEmptyString(claim?.id) || attempted.has(claim.id)) continue;
|
|
128
|
+
|
|
129
|
+
let manifest;
|
|
130
|
+
try {
|
|
131
|
+
manifest = await readManifest(claim.session_id, claim);
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const verified = verifiedManifestForClaim(claim, manifest);
|
|
136
|
+
if (!verified) continue;
|
|
137
|
+
|
|
138
|
+
let worktreeInspection;
|
|
139
|
+
try {
|
|
140
|
+
worktreeInspection = await worktreeExists(verified.worktreeId, claim);
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const worktreeMissing = worktreeInspection === false
|
|
145
|
+
|| worktreeInspection?.exists === false;
|
|
146
|
+
if (!worktreeMissing) continue;
|
|
147
|
+
|
|
148
|
+
let ownerAlive;
|
|
149
|
+
try {
|
|
150
|
+
ownerAlive = await isProcessAlive(verified.pid);
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ownerAlive !== false) {
|
|
155
|
+
if (ownerAlive !== true) continue;
|
|
156
|
+
let currentIdentity;
|
|
157
|
+
try {
|
|
158
|
+
currentIdentity = await getProcessIdentity(verified.pid);
|
|
159
|
+
} catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// A matching owner is live. A mismatch is PID reuse or otherwise
|
|
163
|
+
// unverifiable; neither authorizes releasing its claim.
|
|
164
|
+
if (currentIdentity !== verified.identity) continue;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
attempted.add(claim.id);
|
|
169
|
+
const evidence = Object.freeze({
|
|
170
|
+
claim_id: claim.id,
|
|
171
|
+
issue_id: claim.issue_id,
|
|
172
|
+
session_id: verified.sessionId,
|
|
173
|
+
worktree_id: verified.worktreeId,
|
|
174
|
+
manifest_version: manifest.version,
|
|
175
|
+
manifest_token: manifest.token,
|
|
176
|
+
owner_pid: verified.pid,
|
|
177
|
+
owner_identity: verified.identity,
|
|
178
|
+
owner_started_at: verified.startedAt,
|
|
179
|
+
run_state: 'dead',
|
|
180
|
+
worktree_state: 'missing',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
if (await releaseClaim(claim, evidence, worktreeInspection)) released.push(claim.id);
|
|
185
|
+
} catch {
|
|
186
|
+
// A failed release is not evidence that authority changed.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { examined: claims.length, released };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function reconcileKernelClaims(options = {}) {
|
|
194
|
+
const driver = options.driver;
|
|
195
|
+
if (!driver || typeof driver.listWorktrees !== 'function'
|
|
196
|
+
|| typeof driver.releaseExactClaimIfWorktreeMissing !== 'function') {
|
|
197
|
+
return { examined: 0, released: [] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let claims;
|
|
201
|
+
try {
|
|
202
|
+
if (typeof driver.listActiveClaims === 'function') {
|
|
203
|
+
claims = await driver.listActiveClaims(options.context || {}, options.config || {});
|
|
204
|
+
} else {
|
|
205
|
+
const response = await driver.issueOperation('claims', [], options.context || {}, options.config || {});
|
|
206
|
+
claims = response?.data?.claims;
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
return { examined: 0, released: [] };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return reconcileClaims({
|
|
213
|
+
claims,
|
|
214
|
+
readManifest: options.readManifest || createManifestReader(options),
|
|
215
|
+
worktreeExists: options.worktreeExists || createWorktreeInspector(driver, options.fsApi || fs),
|
|
216
|
+
isProcessAlive: options.isProcessAlive || defaultIsProcessAlive,
|
|
217
|
+
getProcessIdentity: options.getProcessIdentity || (pid => defaultGetProcessIdentity(pid, options)),
|
|
218
|
+
releaseClaim: async (claim, evidence, inspection) => {
|
|
219
|
+
const released = await driver.releaseExactClaimIfWorktreeMissing(
|
|
220
|
+
claim,
|
|
221
|
+
inspection?.row,
|
|
222
|
+
createMissingPathVerifier(options.fsApi || fs),
|
|
223
|
+
evidence,
|
|
224
|
+
options.config || {},
|
|
225
|
+
);
|
|
226
|
+
if (released && typeof options.onEvidence === 'function') options.onEvidence(evidence);
|
|
227
|
+
return released;
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
createManifestReader,
|
|
234
|
+
createWorktreeInspector,
|
|
235
|
+
reconcileClaims,
|
|
236
|
+
reconcileKernelClaims,
|
|
237
|
+
verifiedManifestForClaim,
|
|
238
|
+
};
|
|
@@ -113,7 +113,18 @@ async function buildMigratedKernelIssueDeps(options = {}) {
|
|
|
113
113
|
databasePath: deps.kernelDatabasePath,
|
|
114
114
|
driver: deps.kernelDriver,
|
|
115
115
|
});
|
|
116
|
-
|
|
116
|
+
const close = async () => {
|
|
117
|
+
if (typeof deps.kernelDriver.close === 'function') {
|
|
118
|
+
await deps.kernelDriver.close();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
broker.close = close;
|
|
122
|
+
try {
|
|
123
|
+
await ensureKernelMigrated(broker);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
try { await close(); } catch { /* preserve the migration failure */ }
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
117
128
|
return {
|
|
118
129
|
useKernelBroker: true,
|
|
119
130
|
kernelBroker: broker,
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Close-on-merge linkage (kernel issue 18f1988e).
|
|
5
|
+
*
|
|
6
|
+
* The branch->issue linkage backbone shipped (kernel_worktrees rows written by
|
|
7
|
+
* `forge worktree create` and the auto-file rail), but nothing ever consumed it:
|
|
8
|
+
* a merged PR closed no kernel issue, so finished work stayed open forever. A
|
|
9
|
+
* hygiene sweep on 2026-07-25 closed 104 such issues, 61 of them auto-stubs
|
|
10
|
+
* minted on branch push whose merged PR closed nothing.
|
|
11
|
+
*
|
|
12
|
+
* This module is the consumer. Given a branch whose PR is known to be MERGED, it
|
|
13
|
+
* comments the merge evidence onto the linked kernel issue and closes it.
|
|
14
|
+
*
|
|
15
|
+
* Safety properties (each covered by test/kernel/close-on-merge.test.js):
|
|
16
|
+
* - LINKED ONLY. The issue comes from the kernel_worktrees linkage registry via
|
|
17
|
+
* resolveActiveIssueId — the same authoritative resolver `forge ship` stage
|
|
18
|
+
* state uses. An issue is never guessed from the branch/PR title, so a merge
|
|
19
|
+
* can only ever close the issue the branch was actually bound to.
|
|
20
|
+
* - IDEMPOTENT. The issue's status is read back first; a terminal issue
|
|
21
|
+
* (`done` / `cancelled` — the kernel's real terminal vocabulary) is a pure
|
|
22
|
+
* no-op that writes neither a comment nor a close. Running clean twice
|
|
23
|
+
* closes once.
|
|
24
|
+
* - NEVER CLOSES BLIND. An unreadable status is a skip, not an assumption.
|
|
25
|
+
* - BEST EFFORT. Every failure path — no kernel, a throwing driver, a rejected
|
|
26
|
+
* mutation — resolves to `{ closed: false, reason }`. Nothing throws, so
|
|
27
|
+
* `forge clean` still removes worktrees when tracking is broken.
|
|
28
|
+
*
|
|
29
|
+
* Writes go through the supported `runIssueOperation` seam (lib/forge-issues.js),
|
|
30
|
+
* the same path `forge inbox ack` uses, so kernel events/provenance are recorded
|
|
31
|
+
* exactly as a hand-run `forge comment` / `forge close` would be.
|
|
32
|
+
*
|
|
33
|
+
* @module kernel/close-on-merge
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const { resolveActiveIssueId } = require('../workflow/enforce-stage');
|
|
37
|
+
const { isWorkableStatus } = require('./readiness-model');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Render the merge-evidence comment posted onto the issue before it is closed.
|
|
41
|
+
* Stays honest when the PR could not be resolved: `forge clean` also detects a
|
|
42
|
+
* merge by git patch-equivalence, with no GitHub data to cite.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} params
|
|
45
|
+
* @param {string} params.branch - Merged branch name
|
|
46
|
+
* @param {{number: number, title?: string, mergeCommitOid?: string}|null} [params.pr]
|
|
47
|
+
* @returns {string} Comment body
|
|
48
|
+
*/
|
|
49
|
+
function buildMergeEvidence({ branch, pr }) {
|
|
50
|
+
const lines = ['Closed automatically: the branch backing this issue was merged.', '', `branch: ${branch}`];
|
|
51
|
+
if (pr && pr.number) {
|
|
52
|
+
lines.push(`pull request: #${pr.number}${pr.title ? ` — ${pr.title}` : ''}`);
|
|
53
|
+
if (pr.mergeCommitOid) lines.push(`merge commit: ${pr.mergeCommitOid}`);
|
|
54
|
+
} else {
|
|
55
|
+
lines.push('pull request: no pull request resolved (merge detected from git history)');
|
|
56
|
+
}
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Render the `--reason` recorded on the close event.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} params
|
|
64
|
+
* @param {string} params.branch
|
|
65
|
+
* @param {{number: number}|null} [params.pr]
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function buildCloseReason({ branch, pr }) {
|
|
69
|
+
return pr && pr.number ? `merged in PR #${pr.number}` : `branch ${branch} merged`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read an issue's current status through the supported read path.
|
|
74
|
+
*
|
|
75
|
+
* @param {Function} runIssueOperation
|
|
76
|
+
* @param {string} issueId
|
|
77
|
+
* @param {string} projectRoot
|
|
78
|
+
* @returns {Promise<string|null>} the status, or null when it could not be read.
|
|
79
|
+
*/
|
|
80
|
+
async function readIssueStatus(runIssueOperation, issueId, projectRoot) {
|
|
81
|
+
const result = await runIssueOperation('show', [issueId, '--json'], projectRoot);
|
|
82
|
+
if (!result || result.ok !== true || !result.data) return null;
|
|
83
|
+
const status = result.data.status;
|
|
84
|
+
return typeof status === 'string' && status ? status : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @returns {boolean} whether a mutation envelope reports success. */
|
|
88
|
+
function mutationSucceeded(result) {
|
|
89
|
+
return Boolean(result && (result.ok === true || result.success === true));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Comment the merge evidence onto the linked kernel issue and close it.
|
|
94
|
+
* See the module doc for the full contract. NEVER throws.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} options
|
|
97
|
+
* @param {string} options.branch - The merged branch.
|
|
98
|
+
* @param {string} [options.projectRoot] - Repo root threaded to the issue runner.
|
|
99
|
+
* @param {{number: number, title?: string, mergeCommitOid?: string}|null} [options.pr]
|
|
100
|
+
* Merge evidence from GitHub, when available.
|
|
101
|
+
* @param {object} options.driver - Kernel driver exposing the worktree linkage registry.
|
|
102
|
+
* @param {Function} options.runIssueOperation - (operation, args, projectRoot) => envelope.
|
|
103
|
+
* @param {Function} [options.resolveIssueId] - Override the branch->issue resolver (tests).
|
|
104
|
+
* @returns {Promise<{closed: boolean, issueId?: string|null, reason?: string,
|
|
105
|
+
* commented?: boolean, status?: string, error?: string}>}
|
|
106
|
+
*/
|
|
107
|
+
async function closeLinkedIssueOnMerge(options = {}) {
|
|
108
|
+
const { branch, projectRoot, pr = null, driver, runIssueOperation } = options;
|
|
109
|
+
try {
|
|
110
|
+
if (!branch || typeof branch !== 'string') return { closed: false, reason: 'no-branch' };
|
|
111
|
+
if (!driver || typeof runIssueOperation !== 'function') {
|
|
112
|
+
return { closed: false, reason: 'unavailable' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Linked only — never a title/heuristic match.
|
|
116
|
+
const resolveIssueId = options.resolveIssueId || resolveActiveIssueId;
|
|
117
|
+
const issueId = await resolveIssueId(driver, branch);
|
|
118
|
+
if (!issueId) return { closed: false, reason: 'not-linked' };
|
|
119
|
+
|
|
120
|
+
// Idempotency + never-close-blind: read the current status first.
|
|
121
|
+
const status = await readIssueStatus(runIssueOperation, issueId, projectRoot);
|
|
122
|
+
if (!status) return { closed: false, reason: 'read-failed', issueId };
|
|
123
|
+
if (!isWorkableStatus(status)) {
|
|
124
|
+
return { closed: false, reason: 'already-closed', issueId, status };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Evidence first, so it survives even if the close is rejected.
|
|
128
|
+
const commentResult = await runIssueOperation(
|
|
129
|
+
'comment',
|
|
130
|
+
[issueId, buildMergeEvidence({ branch, pr })],
|
|
131
|
+
projectRoot,
|
|
132
|
+
);
|
|
133
|
+
const commented = mutationSucceeded(commentResult);
|
|
134
|
+
|
|
135
|
+
const closeResult = await runIssueOperation(
|
|
136
|
+
'close',
|
|
137
|
+
[issueId, '--reason', buildCloseReason({ branch, pr })],
|
|
138
|
+
projectRoot,
|
|
139
|
+
);
|
|
140
|
+
if (!mutationSucceeded(closeResult)) {
|
|
141
|
+
return { closed: false, reason: 'close-failed', issueId, commented };
|
|
142
|
+
}
|
|
143
|
+
return { closed: true, issueId, commented };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// Best-effort: cleanup must never break on tracking.
|
|
146
|
+
return { closed: false, reason: 'error', error: error && error.message };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
closeLinkedIssueOnMerge,
|
|
152
|
+
buildMergeEvidence,
|
|
153
|
+
buildCloseReason,
|
|
154
|
+
};
|
package/lib/kernel/fs-class.js
CHANGED
|
@@ -284,7 +284,7 @@ function driveLetterOf(absPath) {
|
|
|
284
284
|
|
|
285
285
|
// Bounded exec options for the Windows drive probes. The timeout is the BLOCKER-1
|
|
286
286
|
// fix: execFileSync's try/catch only catches throws, never hangs, so an
|
|
287
|
-
// unresponsive `net use`
|
|
287
|
+
// an unresponsive `net use` probe (e.g. a wedged network redirector) would
|
|
288
288
|
// hang the whole process indefinitely. With a timeout, a hang becomes a throw
|
|
289
289
|
// that the gatherSignals catch turns into driveType='unknown' → warn (never
|
|
290
290
|
// refuse), symmetric with the G1 fail-safe. killSignal forces the child dead.
|
|
@@ -310,18 +310,21 @@ function parseNetUseDriveType(stdout, driveLetter) {
|
|
|
310
310
|
return null;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
-
// PURE (M5): a non-empty PowerShell DisplayRoot means the PSDrive is backed by a
|
|
314
|
-
// network/remote root. Returns 'network' when non-blank, else null.
|
|
315
|
-
function parseDisplayRoot(stdout) {
|
|
316
|
-
return String(stdout || '').trim() ? 'network' : null;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
313
|
// Windows: detect whether a drive letter is a mapped network drive.
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
314
|
+
// The conventional local C: drive is fixed without a subprocess. `net use` is
|
|
315
|
+
// authoritative for other mapped drive letters, while a no-match remains
|
|
316
|
+
// unknown because it does not prove that an SMB/cloud redirector is absent. The unknown
|
|
317
|
+
// result is fail-open (warn), while UNC and cloud path signals still refuse.
|
|
318
|
+
// Do not launch a second shell process for every kernel child. Under a heavily
|
|
319
|
+
// sharded suite those redundant fallback probes contend for host resources and
|
|
320
|
+
// can leave a shard blocked indefinitely.
|
|
321
|
+
// A probe failure still propagates to the gatherer, which maps it to the
|
|
322
|
+
// fail-safe `unknown`/warn classification.
|
|
323
|
+
// The probe uses an arg ARRAY (no shell) so it remains injection-free.
|
|
324
324
|
function defaultProbeDriveType(driveLetter, deps = {}) {
|
|
325
|
+
const normalizedDriveLetter = String(driveLetter || '').trim().toUpperCase();
|
|
326
|
+
if (normalizedDriveLetter === 'C:') return 'fixed';
|
|
327
|
+
|
|
325
328
|
const exec = deps.execFileSync || execFileSync;
|
|
326
329
|
|
|
327
330
|
// Primary: `net use` lists mapped drive letters (and their remote paths).
|
|
@@ -330,17 +333,9 @@ function defaultProbeDriveType(driveLetter, deps = {}) {
|
|
|
330
333
|
return 'network';
|
|
331
334
|
}
|
|
332
335
|
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
`(Get-PSDrive -Name '${psLetter}' -ErrorAction SilentlyContinue).DisplayRoot`;
|
|
337
|
-
const psOut = exec('powershell', ['-NoProfile', '-Command', psCmd], WIN_PROBE_EXEC_OPTIONS);
|
|
338
|
-
if (parseDisplayRoot(psOut) === 'network') {
|
|
339
|
-
return 'network';
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Letter exists, no network backing detected → fixed.
|
|
343
|
-
return 'fixed';
|
|
336
|
+
// `net use` completed successfully without listing this non-system letter.
|
|
337
|
+
// A no-match is not evidence that an SMB/cloud redirector is absent.
|
|
338
|
+
return 'unknown';
|
|
344
339
|
}
|
|
345
340
|
|
|
346
341
|
// Linux: read /proc/mounts, return fs type of the longest mountpoint prefixing absPath.
|
|
@@ -443,27 +438,49 @@ function isUnsafeFsOverrideActive(env = {}) {
|
|
|
443
438
|
return value === '1' || value === 'true';
|
|
444
439
|
}
|
|
445
440
|
|
|
441
|
+
// The gate runs on every broker instance, and one command builds several
|
|
442
|
+
// (`forge status` and `forge prime` each build four), so an un-memoized advisory
|
|
443
|
+
// printed four times per command. The advisory describes the process's database
|
|
444
|
+
// path, not the broker, so it is emitted once per (tier, class, path) per
|
|
445
|
+
// process. Keyed rather than a single flag so a second path — or a path that
|
|
446
|
+
// reclassifies — is still reported. Warnings only: `refuse` still throws on
|
|
447
|
+
// every call, so deduping can never turn a refusal into a pass.
|
|
448
|
+
const warnedFilesystemKeys = new Set();
|
|
449
|
+
|
|
450
|
+
function resetFilesystemWarningMemo() {
|
|
451
|
+
warnedFilesystemKeys.clear();
|
|
452
|
+
}
|
|
453
|
+
|
|
446
454
|
function assertFilesystemSafeForKernel(dbPath, deps = {}) {
|
|
447
455
|
const env = deps.env || process.env;
|
|
456
|
+
// console.warn writes to stderr, which is the contract here: `--json`
|
|
457
|
+
// consumers parse stdout, so an advisory on stdout would corrupt the envelope.
|
|
448
458
|
const warn = deps.warn || (msg => console.warn(msg));
|
|
449
459
|
const classify = deps.classifyFilesystem || classifyFilesystem;
|
|
450
460
|
|
|
451
461
|
const classification = classify(dbPath, deps);
|
|
452
462
|
const remediation = REMEDIATION[classification.remediationKey] || REMEDIATION.unknown;
|
|
453
463
|
|
|
464
|
+
const warnOnce = message => {
|
|
465
|
+
const key = `${classification.riskTier}|${classification.class}|${dbPath}`;
|
|
466
|
+
if (warnedFilesystemKeys.has(key)) return;
|
|
467
|
+
warnedFilesystemKeys.add(key);
|
|
468
|
+
warn(message);
|
|
469
|
+
};
|
|
470
|
+
|
|
454
471
|
if (classification.riskTier === 'safe') {
|
|
455
472
|
return classification;
|
|
456
473
|
}
|
|
457
474
|
|
|
458
475
|
if (classification.riskTier === 'warn') {
|
|
459
|
-
|
|
476
|
+
warnOnce(`forge kernel filesystem warning (${classification.class}):\n${remediation}`);
|
|
460
477
|
return classification;
|
|
461
478
|
}
|
|
462
479
|
|
|
463
480
|
// refuse
|
|
464
481
|
const overrideActive = isUnsafeFsOverrideActive(env);
|
|
465
482
|
if (overrideActive) {
|
|
466
|
-
|
|
483
|
+
warnOnce(
|
|
467
484
|
`forge kernel filesystem REFUSE downgraded by FORGE_KERNEL_ALLOW_UNSAFE_FS ` +
|
|
468
485
|
`(${classification.class}) — override active:\n${remediation}`,
|
|
469
486
|
);
|
|
@@ -487,9 +504,9 @@ module.exports = {
|
|
|
487
504
|
gatherSignals,
|
|
488
505
|
classifyFilesystem,
|
|
489
506
|
assertFilesystemSafeForKernel,
|
|
507
|
+
resetFilesystemWarningMemo,
|
|
490
508
|
isUnsafeFsOverrideActive,
|
|
491
509
|
defaultProbeDriveType,
|
|
492
510
|
parseNetUseDriveType,
|
|
493
|
-
parseDisplayRoot,
|
|
494
511
|
REMEDIATION,
|
|
495
512
|
};
|
|
@@ -78,12 +78,17 @@ function isValidExpiresAt(value) {
|
|
|
78
78
|
return new Date(timestamp).toISOString() === value;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// A lease with no expires_at never expires.
|
|
82
|
-
//
|
|
83
|
-
//
|
|
81
|
+
// A lease with no expires_at never expires. Normalize both instants before comparing:
|
|
82
|
+
// callers supply `now`, so it may be non-canonical or unusable even though stored
|
|
83
|
+
// expires_at is canonical. Without a usable clock expiry is not proven; conservatively
|
|
84
|
+
// keep the lease live rather than allowing a false reclaim or ready projection.
|
|
84
85
|
function isLeaseExpired(claim, now) {
|
|
85
86
|
if (!claim?.expires_at) return false;
|
|
86
|
-
|
|
87
|
+
if (typeof now !== 'string') return false;
|
|
88
|
+
const expiresAt = Date.parse(claim.expires_at);
|
|
89
|
+
const clock = Date.parse(now);
|
|
90
|
+
if (!Number.isFinite(expiresAt) || !Number.isFinite(clock)) return false;
|
|
91
|
+
return expiresAt <= clock;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
function buildClaimRow(event, now) {
|