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
package/lib/commands/shepherd.js
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* shepherd command — one bounded monitor pass over a pull request.
|
|
5
5
|
*
|
|
6
|
-
* `forge shepherd <pr> [--auto-rebase]`
|
|
7
|
-
* idempotent Tier-A action (rerun a flaky required
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* this command
|
|
6
|
+
* `forge shepherd <pr> [--auto-rebase]` runs one local review preflight, reads
|
|
7
|
+
* PR/CI state, takes at most one idempotent Tier-A action (rerun a flaky required
|
|
8
|
+
* check), persists bounded deltas/receipts, and exits. It NEVER merges and NEVER
|
|
9
|
+
* resolves review threads. Event-driven waiting belongs to the singleton/watch
|
|
10
|
+
* process; this command does not model-poll or loop in-process.
|
|
11
11
|
*
|
|
12
12
|
* `forge shepherd <pr> --bundle --json` instead prints the COMPLETE read-only
|
|
13
13
|
* PR-state bundle (all unresolved threads, merge state, CI, divergence,
|
|
@@ -21,12 +21,13 @@
|
|
|
21
21
|
* done IN CODE so an agent gets one payload instead of running those by hand. It
|
|
22
22
|
* still NEVER merges and NEVER resolves threads.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
24
|
+
* Public Memory is durable authority; the local journal is compatibility delivery.
|
|
25
25
|
*
|
|
26
26
|
* @module commands/shepherd
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
const { execFileSync } = require('node:child_process');
|
|
30
|
+
const crypto = require('node:crypto');
|
|
30
31
|
|
|
31
32
|
const { runShepherdPass } = require('../pr-shepherd');
|
|
32
33
|
const { gatherPrBundle } = require('../pr-bundle');
|
|
@@ -34,15 +35,26 @@ const { gatherPullSignal, renderPullSummary } = require('../pr-pull');
|
|
|
34
35
|
const { PrStateAdapter } = require('../adapters/pr-state-adapter');
|
|
35
36
|
const { validatePrStateAdapter } = require('../pr-state-validator');
|
|
36
37
|
const { gatherMonitorSnapshot } = require('../pr-monitor/gather');
|
|
38
|
+
const { privacySafeIdentity } = require('../pr-monitor/flow-monitor');
|
|
37
39
|
const { pollEvents } = require('../pr-monitor/monitor');
|
|
38
40
|
const { watchLoop } = require('../pr-monitor/watch');
|
|
39
|
-
const { startPrWatcherDetached } = require('../pr-monitor/watch-lifecycle');
|
|
41
|
+
const { startPrWatcherDetached, launchGateComplete } = require('../pr-monitor/watch-lifecycle');
|
|
42
|
+
const watchOwner = require('../pr-monitor/watch-owner');
|
|
40
43
|
const reconcileExecutor = require('../pr-monitor/reconcile-executor');
|
|
41
44
|
const monitorJournal = require('../pr-monitor/journal');
|
|
45
|
+
const brokerMod = require('../kernel/broker');
|
|
42
46
|
const { EVENT_TYPES: T } = require('../pr-monitor/events');
|
|
43
47
|
const { autoShepherdRailEnabled } = require('./ship');
|
|
48
|
+
const { runLocalReviewPreflight } = require('../pr-monitor/review-preflight');
|
|
44
49
|
|
|
45
50
|
const DEFAULT_RERUN_BUDGET = 3;
|
|
51
|
+
const MAX_MONITOR_ID_LENGTH = 128;
|
|
52
|
+
|
|
53
|
+
function rootMonitorId(owner, repo, pr) {
|
|
54
|
+
const raw = privacySafeIdentity(`pr:${privacySafeIdentity(`${owner}/${repo}`)}:${pr}`);
|
|
55
|
+
if (raw.length <= MAX_MONITOR_ID_LENGTH) return raw;
|
|
56
|
+
return `pr:${crypto.createHash('sha256').update(raw).digest('hex')}`;
|
|
57
|
+
}
|
|
46
58
|
|
|
47
59
|
// windowsHide: true on EVERY spawn here is load-bearing, not cosmetic. The
|
|
48
60
|
// shepherd watcher runs detached in the background and re-polls every ~60s; on
|
|
@@ -51,14 +63,35 @@ const DEFAULT_RERUN_BUDGET = 3;
|
|
|
51
63
|
// preferred home is the harness's managed shell (hidden + reaped); a
|
|
52
64
|
// Forge-spawned detached watcher is the no-session fallback and must be
|
|
53
65
|
// COMPLETELY silent — no console window, ever. See kernel issue 931e7924.
|
|
54
|
-
const defaultGhRunner = (cmd, a) => execFileSync(cmd, a, {
|
|
66
|
+
const defaultGhRunner = (cmd, a, options = {}) => execFileSync(cmd, a, {
|
|
67
|
+
encoding: 'utf8', timeout: 30000, windowsHide: true, ...options,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function githubRunner(deps, projectRoot) {
|
|
71
|
+
return deps.githubContext?.bound
|
|
72
|
+
? (_cmd, args, options = {}) => deps.githubContext.runGh(args, {
|
|
73
|
+
encoding: 'utf8', timeout: 30000, windowsHide: true, cwd: projectRoot, ...options,
|
|
74
|
+
})
|
|
75
|
+
: (deps.gh || defaultGhRunner);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveTargetRepository(gh = defaultGhRunner) {
|
|
79
|
+
const repoJson = gh('gh', ['repo', 'view', '--json', 'nameWithOwner,parent']);
|
|
80
|
+
const repoInfo = JSON.parse(repoJson || '{}');
|
|
81
|
+
const repository = repoInfo.parent?.nameWithOwner || repoInfo.nameWithOwner;
|
|
82
|
+
if (typeof repository !== 'string'
|
|
83
|
+
|| !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
|
|
84
|
+
throw new Error('PR base repository is unavailable from the checkout');
|
|
85
|
+
}
|
|
86
|
+
return repository.toLowerCase();
|
|
87
|
+
}
|
|
55
88
|
|
|
56
89
|
/**
|
|
57
90
|
* Resolve owner/repo and base branch for the shepherd pass.
|
|
58
91
|
*
|
|
59
|
-
* The base
|
|
60
|
-
*
|
|
61
|
-
*
|
|
92
|
+
* The exact base commit is read from the PR itself (`gh pr view <pr> --json
|
|
93
|
+
* baseRefOid`) rather than a local remote, so fork checkouts and PRs targeting
|
|
94
|
+
* `release/*`/`develop` are evaluated against the provider's reviewed base.
|
|
62
95
|
* `owner`/`name` come from the repository the PR is queried in — that IS the
|
|
63
96
|
* base repository. `cwd` (the worktree root) is threaded through so divergence
|
|
64
97
|
* is computed against the right checkout.
|
|
@@ -66,21 +99,30 @@ const defaultGhRunner = (cmd, a) => execFileSync(cmd, a, { encoding: 'utf8', tim
|
|
|
66
99
|
* @param {object} deps
|
|
67
100
|
* @returns {Promise<{ pr: string, owner: string, repo: string, base: string, baseRef: string, cwd?: string }>}
|
|
68
101
|
*/
|
|
69
|
-
async function defaultBuildContext({ pr, gh, git, projectRoot }) {
|
|
70
|
-
const
|
|
102
|
+
async function defaultBuildContext({ pr, gh, git, projectRoot, repository }) {
|
|
103
|
+
const targetRepository = repository || resolveTargetRepository(gh);
|
|
104
|
+
const prJson = gh('gh', [
|
|
105
|
+
'pr', 'view', String(pr), '--repo', targetRepository,
|
|
106
|
+
'--json', 'baseRefName,baseRefOid,headRefOid,state',
|
|
107
|
+
]);
|
|
71
108
|
const prInfo = JSON.parse(prJson || '{}');
|
|
72
109
|
const base = prInfo.baseRefName || 'master';
|
|
110
|
+
if (typeof prInfo.baseRefOid !== 'string' || !/^[0-9a-f]{40}$/i.test(prInfo.baseRefOid)) {
|
|
111
|
+
throw new Error('PR base commit is unavailable from the provider');
|
|
112
|
+
}
|
|
113
|
+
const baseRef = prInfo.baseRefOid.toLowerCase();
|
|
114
|
+
const headSha = typeof prInfo.headRefOid === 'string' && /^[0-9a-f]{40}$/i.test(prInfo.headRefOid)
|
|
115
|
+
? prInfo.headRefOid.toLowerCase()
|
|
116
|
+
: null;
|
|
73
117
|
|
|
74
|
-
const
|
|
75
|
-
const repo = JSON.parse(repoJson || '{}');
|
|
76
|
-
const owner = repo.owner?.login || '';
|
|
77
|
-
const name = repo.name || '';
|
|
118
|
+
const [owner, name] = targetRepository.split('/');
|
|
78
119
|
|
|
79
|
-
let
|
|
120
|
+
let localHead = null;
|
|
80
121
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
122
|
+
const candidate = git('git', ['rev-parse', 'HEAD']).trim();
|
|
123
|
+
if (/^[0-9a-f]{40}$/i.test(candidate)) localHead = candidate.toLowerCase();
|
|
124
|
+
} catch {
|
|
125
|
+
/* a missing local checkout makes local review preflight not applicable */
|
|
84
126
|
}
|
|
85
127
|
|
|
86
128
|
return {
|
|
@@ -88,7 +130,10 @@ async function defaultBuildContext({ pr, gh, git, projectRoot }) {
|
|
|
88
130
|
owner,
|
|
89
131
|
repo: name,
|
|
90
132
|
base,
|
|
91
|
-
baseRef
|
|
133
|
+
baseRef,
|
|
134
|
+
headSha,
|
|
135
|
+
prState: typeof prInfo.state === 'string' ? prInfo.state.toUpperCase() : null,
|
|
136
|
+
localHead,
|
|
92
137
|
...(projectRoot ? { cwd: projectRoot } : {}),
|
|
93
138
|
};
|
|
94
139
|
}
|
|
@@ -121,6 +166,14 @@ function formatAction(action) {
|
|
|
121
166
|
}
|
|
122
167
|
}
|
|
123
168
|
|
|
169
|
+
function writePassSummary(pr, state, reason, actions) {
|
|
170
|
+
const passActions = Array.isArray(actions) ? actions : [];
|
|
171
|
+
const reasonSuffix = reason ? ` — ${reason}` : '';
|
|
172
|
+
process.stdout.write(`Shepherd pass — PR #${pr}: ${state}${reasonSuffix}\n`);
|
|
173
|
+
for (const action of passActions) process.stdout.write(` • ${formatAction(action)}\n`);
|
|
174
|
+
if (!passActions.length) process.stdout.write(' • no actions this pass\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
124
177
|
/**
|
|
125
178
|
* Build the DEFAULT `check.failed` enrichment hook for the events pull surface.
|
|
126
179
|
*
|
|
@@ -138,6 +191,14 @@ function formatAction(action) {
|
|
|
138
191
|
* @param {object} pullCtx - ctx forwarded to `gatherPull` (owner/repo/base/adapter/runGh/self).
|
|
139
192
|
* @returns {(records: object[]) => Promise<void>}
|
|
140
193
|
*/
|
|
194
|
+
const UNSAFE_MONITOR_EXCERPT = /(?:gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|sk_(?:live|test)_[a-z0-9]{16,}|sk-[a-z0-9]{16,}|AKIA[0-9A-Z]{16}|(?:api[_-]?key|token|secret|password)\s*[:=]\s*\S{8,}|(?:^|[\\/])(?:users|home|root)[\\/]\S+)/i;
|
|
195
|
+
|
|
196
|
+
function safeMonitorExcerpt(value) {
|
|
197
|
+
return typeof value === 'string' && value.length <= 16_384 && !UNSAFE_MONITOR_EXCERPT.test(value)
|
|
198
|
+
? value
|
|
199
|
+
: null;
|
|
200
|
+
}
|
|
201
|
+
|
|
141
202
|
function makeCheckFailureEnricher(pullCtx) {
|
|
142
203
|
const gatherPull = pullCtx.gatherPull || gatherPullSignal;
|
|
143
204
|
return async (records) => {
|
|
@@ -156,7 +217,8 @@ function makeCheckFailureEnricher(pullCtx) {
|
|
|
156
217
|
if (r.type !== T.CHECK_FAILED) continue;
|
|
157
218
|
const f = byName.get(r.data?.name);
|
|
158
219
|
if (!f) continue;
|
|
159
|
-
|
|
220
|
+
const excerpt = safeMonitorExcerpt(f.excerpt);
|
|
221
|
+
if (excerpt) r.data.excerpt = excerpt;
|
|
160
222
|
if (f.jobUrl) r.data.jobUrl = f.jobUrl;
|
|
161
223
|
}
|
|
162
224
|
};
|
|
@@ -169,6 +231,20 @@ function parseSince(args) {
|
|
|
169
231
|
return 0;
|
|
170
232
|
}
|
|
171
233
|
|
|
234
|
+
function booleanOwnerRunningProbe(probe) {
|
|
235
|
+
if (typeof probe !== 'function') return undefined;
|
|
236
|
+
return async () => {
|
|
237
|
+
const result = await probe();
|
|
238
|
+
if (result && typeof result === 'object' && result.ok === false) {
|
|
239
|
+
const error = new Error('Watcher authority is unavailable');
|
|
240
|
+
error.code = typeof result.reason === 'string' ? result.reason : 'authority_unavailable';
|
|
241
|
+
error.cause = result;
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
return result === true;
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
172
248
|
/**
|
|
173
249
|
* Build the shared monitor context — journal `dir`, bounded `gather`, and the
|
|
174
250
|
* default `check.failed` enricher — that BOTH the `events` pull surface and the
|
|
@@ -183,21 +259,48 @@ function parseSince(args) {
|
|
|
183
259
|
async function buildMonitorContext(pr, projectRoot, deps) {
|
|
184
260
|
let dir = deps.dir;
|
|
185
261
|
let gather = deps.gather;
|
|
262
|
+
let repository = typeof deps.repository === 'string' ? deps.repository.toLowerCase() : null;
|
|
263
|
+
let prState = deps.prState || null;
|
|
264
|
+
let ownerAuthority = {
|
|
265
|
+
owner: deps.owner || watchOwner,
|
|
266
|
+
ownerOptions: deps.ownerOptions,
|
|
267
|
+
};
|
|
268
|
+
let authority = deps.store ? {
|
|
269
|
+
store: deps.store,
|
|
270
|
+
monitorId: deps.monitorId,
|
|
271
|
+
ownerRunId: deps.ownerRunId,
|
|
272
|
+
packetId: deps.packetId,
|
|
273
|
+
subjectId: deps.subjectId,
|
|
274
|
+
close: deps.close || (async () => {}),
|
|
275
|
+
} : {};
|
|
186
276
|
// enrich decorates newly-failed checks with log excerpts before the journal
|
|
187
277
|
// append. The caller MUST supply the default (not just forward an injected
|
|
188
278
|
// one), or a plain `forge shepherd events`/`watch` emits bare check.failed events.
|
|
189
279
|
let enrich = deps.enrich;
|
|
190
280
|
if (!gather || !dir) {
|
|
191
|
-
const gh = deps
|
|
192
|
-
const git = deps.git ||
|
|
281
|
+
const gh = githubRunner(deps, projectRoot);
|
|
282
|
+
const git = deps.git || defaultGhRunner;
|
|
193
283
|
const buildContext = deps.buildContext || defaultBuildContext;
|
|
194
|
-
const ctx = await buildContext({ pr, gh, git, projectRoot });
|
|
195
|
-
|
|
284
|
+
const ctx = await buildContext({ pr, gh, git, projectRoot, repository });
|
|
285
|
+
repository = `${ctx.owner}/${ctx.repo}`.toLowerCase();
|
|
286
|
+
prState = ctx.prState;
|
|
287
|
+
const adapter = deps.adapter || new PrStateAdapter({ gh, git, repository });
|
|
196
288
|
const validation = validatePrStateAdapter(adapter);
|
|
197
289
|
if (!validation.valid) {
|
|
198
290
|
return { error: `Invalid pr-state adapter: ${validation.errors.join('; ')}` };
|
|
199
291
|
}
|
|
200
|
-
|
|
292
|
+
let gitCommonDir = deps.gitCommonDir;
|
|
293
|
+
if (!gitCommonDir) {
|
|
294
|
+
try {
|
|
295
|
+
const resolveGitCommonDir = deps.resolveGitCommonDir || brokerMod.resolveGitCommonDir;
|
|
296
|
+
gitCommonDir = resolveGitCommonDir(projectRoot || process.cwd(), { warn: () => {} });
|
|
297
|
+
} catch {
|
|
298
|
+
/* unavailable common-dir keeps the legacy per-root journal fallback */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
dir = dir || monitorJournal.journalDir({
|
|
302
|
+
root: projectRoot || process.cwd(), gitCommonDir, repo: ctx.repo, pr: ctx.pr,
|
|
303
|
+
});
|
|
201
304
|
gather = gather || (() => gatherMonitorSnapshot({ ...ctx, adapter, self: deps.self }));
|
|
202
305
|
enrich = enrich || makeCheckFailureEnricher({
|
|
203
306
|
...ctx,
|
|
@@ -206,6 +309,45 @@ async function buildMonitorContext(pr, projectRoot, deps) {
|
|
|
206
309
|
runGh: (ghArgs) => gh('gh', ghArgs),
|
|
207
310
|
gatherPull: deps.gatherPull,
|
|
208
311
|
});
|
|
312
|
+
if (!deps.store) {
|
|
313
|
+
const buildKernelDeps = deps.buildKernelDeps
|
|
314
|
+
|| require('../kernel/cli-broker-factory').buildMigratedKernelIssueDeps;
|
|
315
|
+
const createMonitorStore = deps.createMonitorStore
|
|
316
|
+
|| require('../../packages/memory').createMonitorStore;
|
|
317
|
+
let kernel;
|
|
318
|
+
try {
|
|
319
|
+
kernel = await buildKernelDeps({ projectRoot: projectRoot || process.cwd(), gitCommonDir });
|
|
320
|
+
const repositoryIdentity = privacySafeIdentity(`${ctx.owner}/${ctx.repo}`);
|
|
321
|
+
authority = {
|
|
322
|
+
store: createMonitorStore(kernel.kernelDriver),
|
|
323
|
+
monitorId: rootMonitorId(ctx.owner, ctx.repo, ctx.pr),
|
|
324
|
+
ownerRunId: privacySafeIdentity(`shepherd:${repositoryIdentity}:${ctx.pr}`),
|
|
325
|
+
packetId: privacySafeIdentity(`shepherd-packet:${repositoryIdentity}:${ctx.pr}`),
|
|
326
|
+
subjectId: privacySafeIdentity(`${repositoryIdentity}#${ctx.pr}`),
|
|
327
|
+
close: async () => kernel.kernelBroker?.close?.(),
|
|
328
|
+
};
|
|
329
|
+
ownerAuthority = {
|
|
330
|
+
owner: deps.owner || watchOwner,
|
|
331
|
+
ownerOptions: {
|
|
332
|
+
driver: kernel.kernelDriver,
|
|
333
|
+
verifyProviderEvidence: async (evidence, expected) => (
|
|
334
|
+
typeof evidence?.state === 'string'
|
|
335
|
+
&& expected.states.includes(evidence.state.toLowerCase())
|
|
336
|
+
),
|
|
337
|
+
verifyTerminalReceipt: async (receiptId) => {
|
|
338
|
+
const state = await authority.store.readDeliveryState(authority.monitorId);
|
|
339
|
+
return state?.terminal_receipt?.object_id === receiptId;
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
} catch (error) {
|
|
344
|
+
try { await kernel?.kernelBroker?.close?.(); } catch { /* fallback must remain available */ }
|
|
345
|
+
if (deps.forceAuthority) {
|
|
346
|
+
return { error: `Durable monitor authority is unavailable: ${error.message}` };
|
|
347
|
+
}
|
|
348
|
+
authority = {};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
209
351
|
} else if (!enrich && deps.gatherPull) {
|
|
210
352
|
// Injected gather (tests / programmatic callers) still gets the default
|
|
211
353
|
// enrichment when a pull-signal source is supplied.
|
|
@@ -213,7 +355,19 @@ async function buildMonitorContext(pr, projectRoot, deps) {
|
|
|
213
355
|
pr, adapter: deps.adapter, self: deps.self, runGh: deps.runGh, gatherPull: deps.gatherPull,
|
|
214
356
|
});
|
|
215
357
|
}
|
|
216
|
-
|
|
358
|
+
if (deps.forceAuthority && !authority.store) {
|
|
359
|
+
return { error: 'Durable monitor authority is unavailable.' };
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
dir,
|
|
363
|
+
gather,
|
|
364
|
+
enrich,
|
|
365
|
+
repo: repository,
|
|
366
|
+
pr: Number(pr),
|
|
367
|
+
prState,
|
|
368
|
+
...authority,
|
|
369
|
+
...ownerAuthority,
|
|
370
|
+
};
|
|
217
371
|
}
|
|
218
372
|
|
|
219
373
|
/**
|
|
@@ -238,15 +392,31 @@ async function handleEvents(args, projectRoot, deps = {}) {
|
|
|
238
392
|
|
|
239
393
|
const built = await buildMonitorContext(pr, projectRoot, deps);
|
|
240
394
|
if (built.error) return { success: false, error: built.error };
|
|
241
|
-
const { dir, gather, enrich } = built;
|
|
242
|
-
|
|
243
395
|
const poll = deps.pollEvents || pollEvents;
|
|
244
|
-
|
|
396
|
+
let result;
|
|
397
|
+
try {
|
|
398
|
+
result = await poll({
|
|
399
|
+
...built,
|
|
400
|
+
since,
|
|
401
|
+
now: deps.now,
|
|
402
|
+
isOwnerRunning: booleanOwnerRunningProbe(deps.isOwnerRunning),
|
|
403
|
+
});
|
|
404
|
+
} finally {
|
|
405
|
+
await built.close?.();
|
|
406
|
+
}
|
|
245
407
|
// `output` is the agent-agnostic pull surface: NDJSON, one event per line. The
|
|
246
408
|
// registry CLI dispatch prints `result.output` (same contract as --pull/--bundle),
|
|
247
409
|
// so this handler does NOT write to stdout itself (that would double-print).
|
|
248
|
-
const
|
|
249
|
-
|
|
410
|
+
const overflow = result.overflow === true;
|
|
411
|
+
const overflowRecord = overflow ? {
|
|
412
|
+
type: 'monitor.overflow',
|
|
413
|
+
since: result.since,
|
|
414
|
+
firstAvailableSeq: result.events[0]?.seq ?? null,
|
|
415
|
+
action: 'restart-from-checkpoint',
|
|
416
|
+
} : null;
|
|
417
|
+
const outputRecords = overflowRecord ? [overflowRecord, ...result.events] : result.events;
|
|
418
|
+
const output = outputRecords.map((event) => JSON.stringify(event)).join('\n');
|
|
419
|
+
return { success: true, events: result.events, since: result.since, overflow, output };
|
|
250
420
|
}
|
|
251
421
|
|
|
252
422
|
/**
|
|
@@ -287,9 +457,11 @@ function wireSignals() {
|
|
|
287
457
|
* @param {Function} [exec] - gh runner (test injection).
|
|
288
458
|
* @returns {number[]}
|
|
289
459
|
*/
|
|
290
|
-
function defaultListOpenPrs(exec = execFileSync) {
|
|
460
|
+
function defaultListOpenPrs(repository, exec = execFileSync) {
|
|
291
461
|
try {
|
|
292
|
-
const out = exec('gh', [
|
|
462
|
+
const out = exec('gh', [
|
|
463
|
+
'pr', 'list', '--repo', repository, '--state', 'open', '--json', 'number', '-q', '.[].number',
|
|
464
|
+
], {
|
|
293
465
|
encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
|
|
294
466
|
});
|
|
295
467
|
return String(out)
|
|
@@ -313,7 +485,7 @@ function defaultListOpenPrs(exec = execFileSync) {
|
|
|
313
485
|
* @param {object} [deps]
|
|
314
486
|
* @returns {{ success: true, adopted: number[], total: number, reason?: string }}
|
|
315
487
|
*/
|
|
316
|
-
function handleAdopt(projectRoot, deps = {}) {
|
|
488
|
+
async function handleAdopt(projectRoot, deps = {}) {
|
|
317
489
|
const railEnabled = deps.railEnabled || autoShepherdRailEnabled;
|
|
318
490
|
// Gate BEFORE listing PRs / spawning watchers: a disabled rail must not spawn
|
|
319
491
|
// detached watchers. No-op result mirrors the fail-open (empty) adoption shape.
|
|
@@ -321,48 +493,145 @@ function handleAdopt(projectRoot, deps = {}) {
|
|
|
321
493
|
return { success: true, adopted: [], total: 0, reason: 'rail.auto_shepherd disabled' };
|
|
322
494
|
}
|
|
323
495
|
const listOpenPrs = deps.listOpenPrs || defaultListOpenPrs;
|
|
496
|
+
const gh = githubRunner(deps, projectRoot);
|
|
324
497
|
const startWatcher = deps.startWatcher || startPrWatcherDetached;
|
|
498
|
+
const resolveRepository = deps.resolveRepository
|
|
499
|
+
|| (() => resolveTargetRepository(gh));
|
|
325
500
|
let prs;
|
|
501
|
+
let repository;
|
|
502
|
+
let kernel;
|
|
503
|
+
let ownerOptions = deps.ownerOptions;
|
|
326
504
|
try {
|
|
327
|
-
|
|
505
|
+
repository = resolveRepository();
|
|
506
|
+
if (!ownerOptions) {
|
|
507
|
+
const buildKernelDeps = deps.buildKernelDeps
|
|
508
|
+
|| require('../kernel/cli-broker-factory').buildMigratedKernelIssueDeps;
|
|
509
|
+
kernel = await buildKernelDeps({ projectRoot: projectRoot || process.cwd() });
|
|
510
|
+
ownerOptions = {
|
|
511
|
+
driver: kernel.kernelDriver,
|
|
512
|
+
verifyProviderEvidence: async (evidence, expected) => (
|
|
513
|
+
evidence?.state === 'OPEN' && expected.states.includes('open')
|
|
514
|
+
),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
prs = listOpenPrs(repository, gh);
|
|
328
518
|
} catch {
|
|
329
519
|
prs = [];
|
|
330
520
|
}
|
|
331
521
|
if (!Array.isArray(prs)) prs = [];
|
|
332
522
|
const adopted = [];
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
523
|
+
try {
|
|
524
|
+
for (const pr of prs) {
|
|
525
|
+
try {
|
|
526
|
+
const owner = deps.owner || watchOwner;
|
|
527
|
+
if (!await launchGateComplete(owner, ownerOptions)) continue;
|
|
528
|
+
const res = await startWatcher({
|
|
529
|
+
prNumber: pr,
|
|
530
|
+
cwd: projectRoot,
|
|
531
|
+
repository,
|
|
532
|
+
owner,
|
|
533
|
+
ownerOptions,
|
|
534
|
+
providerEvidence: { state: 'OPEN', repository, pr },
|
|
535
|
+
});
|
|
536
|
+
if (res?.started) adopted.push(pr);
|
|
537
|
+
} catch { /* fail-open per PR: one bad arm never blocks the rest */ }
|
|
538
|
+
}
|
|
539
|
+
} finally {
|
|
540
|
+
try { await kernel?.kernelBroker?.close?.(); } catch { /* best-effort handle cleanup */ }
|
|
338
541
|
}
|
|
339
542
|
return { success: true, adopted, total: prs.length };
|
|
340
543
|
}
|
|
341
544
|
|
|
545
|
+
function watchOption(args, name) {
|
|
546
|
+
const index = args.indexOf(name);
|
|
547
|
+
return index < 0 ? null : args[index + 1];
|
|
548
|
+
}
|
|
549
|
+
|
|
342
550
|
async function handleWatch(args, projectRoot, deps = {}) {
|
|
343
551
|
const rawArgs = args || [];
|
|
344
552
|
// `--adopt` (no PR arg): arm a detached watcher for every open PR.
|
|
345
553
|
if (rawArgs.includes('--adopt')) {
|
|
346
554
|
return handleAdopt(projectRoot, deps);
|
|
347
555
|
}
|
|
348
|
-
const
|
|
556
|
+
const optionIndexes = new Set();
|
|
557
|
+
for (const name of ['--repo', '--generation', '--controller-pid', '--started-at']) {
|
|
558
|
+
const index = rawArgs.indexOf(name);
|
|
559
|
+
if (index >= 0) optionIndexes.add(index + 1);
|
|
560
|
+
}
|
|
561
|
+
const pr = rawArgs.find((a, index) => !String(a).startsWith('--') && a !== 'watch' && !optionIndexes.has(index));
|
|
349
562
|
if (!pr) {
|
|
350
563
|
return { success: false, error: 'Usage: forge shepherd watch <pr> | forge shepherd watch --adopt' };
|
|
351
564
|
}
|
|
352
565
|
|
|
353
|
-
const
|
|
566
|
+
const repository = watchOption(rawArgs, '--repo');
|
|
567
|
+
const suppliedGeneration = watchOption(rawArgs, '--generation');
|
|
568
|
+
const suppliedControllerPid = Number(watchOption(rawArgs, '--controller-pid'));
|
|
569
|
+
const built = await buildMonitorContext(pr, projectRoot, { ...deps, repository: repository || deps.repository });
|
|
354
570
|
if (built.error) return { success: false, error: built.error };
|
|
355
|
-
const {
|
|
356
|
-
|
|
571
|
+
const identity = { repo: built.repo, pr: Number(pr) };
|
|
572
|
+
const owner = built.owner || deps.owner || watchOwner;
|
|
573
|
+
const ownerOptions = built.ownerOptions || deps.ownerOptions || {};
|
|
574
|
+
let generation = suppliedGeneration;
|
|
575
|
+
let controllerPid = suppliedGeneration ? suppliedControllerPid : process.pid;
|
|
576
|
+
let reservedHere = false;
|
|
577
|
+
if (suppliedGeneration && (!Number.isSafeInteger(suppliedControllerPid) || suppliedControllerPid <= 0)) {
|
|
578
|
+
await built.close?.();
|
|
579
|
+
return { success: false, error: 'Watcher controller identity is invalid' };
|
|
580
|
+
}
|
|
581
|
+
if (!generation) {
|
|
582
|
+
if (!await launchGateComplete(owner, ownerOptions)) {
|
|
583
|
+
await built.close?.();
|
|
584
|
+
return {
|
|
585
|
+
success: true, started: false, passes: 0, stopped: false,
|
|
586
|
+
reason: 'migration-gate-incomplete',
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
let reserved = await owner.reserveStarting(identity, { controllerPid }, ownerOptions);
|
|
590
|
+
if (!reserved?.ok && reserved?.record?.phase === 'complete' && built.prState === 'OPEN') {
|
|
591
|
+
reserved = await owner.reserveReopened(identity, {
|
|
592
|
+
generation: reserved.record.generation,
|
|
593
|
+
expectedReceiptId: reserved.record.terminalReceiptId,
|
|
594
|
+
controllerPid,
|
|
595
|
+
providerEvidence: { state: built.prState },
|
|
596
|
+
}, ownerOptions);
|
|
597
|
+
}
|
|
598
|
+
if (!reserved?.ok || !reserved.record) {
|
|
599
|
+
await built.close?.();
|
|
600
|
+
return {
|
|
601
|
+
success: true,
|
|
602
|
+
started: false,
|
|
603
|
+
passes: 0,
|
|
604
|
+
stopped: false,
|
|
605
|
+
reason: reserved?.reason || 'authority-unavailable',
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
generation = reserved.record.generation;
|
|
609
|
+
reservedHere = true;
|
|
610
|
+
}
|
|
611
|
+
if (!await launchGateComplete(owner, ownerOptions)) {
|
|
612
|
+
if (reservedHere) {
|
|
613
|
+
await owner.abortStarting(identity, { generation, controllerPid }, ownerOptions);
|
|
614
|
+
}
|
|
615
|
+
await built.close?.();
|
|
616
|
+
return {
|
|
617
|
+
success: true, started: false, passes: 0, stopped: false,
|
|
618
|
+
reason: 'migration-gate-incomplete',
|
|
619
|
+
};
|
|
620
|
+
}
|
|
357
621
|
const loop = deps.watchLoop || watchLoop;
|
|
358
622
|
// Injected signal (tests) suppresses real process handlers; otherwise wire them.
|
|
359
623
|
const wired = deps.signal ? { signal: deps.signal, cleanup: () => {} } : wireSignals();
|
|
360
624
|
let result;
|
|
361
625
|
try {
|
|
362
626
|
result = await loop({
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
627
|
+
...built,
|
|
628
|
+
repo: identity.repo,
|
|
629
|
+
pr: identity.pr,
|
|
630
|
+
generation,
|
|
631
|
+
controllerPid,
|
|
632
|
+
pid: process.pid,
|
|
633
|
+
owner,
|
|
634
|
+
ownerOptions,
|
|
366
635
|
now: deps.now,
|
|
367
636
|
emit: deps.emit,
|
|
368
637
|
sleep: deps.sleep,
|
|
@@ -371,12 +640,10 @@ async function handleWatch(args, projectRoot, deps = {}) {
|
|
|
371
640
|
maxPasses: deps.maxPasses,
|
|
372
641
|
lockOpts: deps.lockOpts,
|
|
373
642
|
signal: wired.signal,
|
|
374
|
-
watcherRunning: deps.watcherRunning,
|
|
375
|
-
writePid: deps.writePid,
|
|
376
|
-
removePid: deps.removePid,
|
|
377
643
|
});
|
|
378
644
|
} finally {
|
|
379
645
|
wired.cleanup();
|
|
646
|
+
await built.close?.();
|
|
380
647
|
}
|
|
381
648
|
|
|
382
649
|
return {
|
|
@@ -402,7 +669,12 @@ async function handleWatch(args, projectRoot, deps = {}) {
|
|
|
402
669
|
* @returns {Promise<object>} result envelope.
|
|
403
670
|
*/
|
|
404
671
|
async function handleDaemon(projectRoot, deps = {}) {
|
|
405
|
-
const res = await reconcileExecutor.runDaemon(projectRoot, {
|
|
672
|
+
const res = await reconcileExecutor.runDaemon(projectRoot, {
|
|
673
|
+
...deps,
|
|
674
|
+
...(deps.githubContext?.bound ? {
|
|
675
|
+
runGh: args => deps.githubContext.runGh(args, { cwd: projectRoot, timeout: 30000 }),
|
|
676
|
+
} : {}),
|
|
677
|
+
});
|
|
406
678
|
if (!res.ok) {
|
|
407
679
|
// A live foreign daemon owns the lease — this invocation is a clean no-op.
|
|
408
680
|
return { success: true, started: false, reason: res.reason || 'foreign-lease' };
|
|
@@ -410,6 +682,78 @@ async function handleDaemon(projectRoot, deps = {}) {
|
|
|
410
682
|
return { success: true, started: true };
|
|
411
683
|
}
|
|
412
684
|
|
|
685
|
+
async function collectConvergenceEvidence({ args, pr, projectRoot, context, adapter, deps }) {
|
|
686
|
+
const built = await buildMonitorContext(pr, projectRoot, {
|
|
687
|
+
...deps,
|
|
688
|
+
adapter,
|
|
689
|
+
buildContext: async () => context,
|
|
690
|
+
forceAuthority: true,
|
|
691
|
+
});
|
|
692
|
+
if (built.error) throw new Error(built.error);
|
|
693
|
+
try {
|
|
694
|
+
const polled = await (deps.pollEvents || pollEvents)({
|
|
695
|
+
...built,
|
|
696
|
+
since: parseSince(args),
|
|
697
|
+
now: deps.now,
|
|
698
|
+
isOwnerRunning: async () => false,
|
|
699
|
+
});
|
|
700
|
+
const current = await adapter.readState(pr);
|
|
701
|
+
const exactHead = typeof current?.headSha === 'string' && /^[0-9a-f]{40}$/i.test(current.headSha)
|
|
702
|
+
? current.headSha.toLowerCase()
|
|
703
|
+
: null;
|
|
704
|
+
const expectedHead = typeof context.headSha === 'string' && /^[0-9a-f]{40}$/i.test(context.headSha)
|
|
705
|
+
? context.headSha.toLowerCase()
|
|
706
|
+
: null;
|
|
707
|
+
if (!exactHead || exactHead !== expectedHead) {
|
|
708
|
+
throw new Error('PR head changed after the shepherd pass; rerun convergence on the current head');
|
|
709
|
+
}
|
|
710
|
+
// The inline pass is FORCED here precisely because this evidence gates a
|
|
711
|
+
// MERGE_READY handoff. When the migration gate is quarantined/conflicting or
|
|
712
|
+
// the authority is unreadable, pollEvents deliberately suppresses that pass and
|
|
713
|
+
// reports `ranPass: false`; the journal deltas it still returns are stale by
|
|
714
|
+
// construction. Treating them as convergence evidence would hand off a merge
|
|
715
|
+
// that was never authoritatively converged, so fail closed with the reason.
|
|
716
|
+
if (polled.ranPass !== true) {
|
|
717
|
+
throw new Error(`the authoritative convergence pass did not run (${
|
|
718
|
+
polled.migrationGateIncomplete === true
|
|
719
|
+
? 'watcher migration gate incomplete'
|
|
720
|
+
: (polled.authorityUnavailable === true ? 'watcher authority unavailable' : 'pass suppressed')
|
|
721
|
+
})`);
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
deltas: Array.isArray(polled.events) ? polled.events : [],
|
|
725
|
+
deltaOverflow: polled.overflow === true,
|
|
726
|
+
receiptIds: Array.isArray(polled.receiptIds) ? polled.receiptIds : [],
|
|
727
|
+
continuationPending: polled.continuationPending === true,
|
|
728
|
+
...(polled.terminalReceiptId ? { terminalReceiptId: polled.terminalReceiptId } : {}),
|
|
729
|
+
exactHead,
|
|
730
|
+
};
|
|
731
|
+
} finally {
|
|
732
|
+
await built.close?.();
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function convergenceHandoff(state, pr, evidence) {
|
|
737
|
+
if (state === 'MERGE_READY') {
|
|
738
|
+
return {
|
|
739
|
+
next: 'merge',
|
|
740
|
+
command: `forge merge --auto ${pr} --expect-head ${evidence.exactHead || '<exact-head>'} --issue <issue-id>`,
|
|
741
|
+
humanApprovalRequired: true,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
if (state === 'MERGED') {
|
|
745
|
+
return {
|
|
746
|
+
next: 'verify',
|
|
747
|
+
command: 'forge verify',
|
|
748
|
+
terminalReceiptId: evidence.terminalReceiptId || null,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
if (state === 'NEEDS_REVIEW') {
|
|
752
|
+
return { next: 'review', command: 'forge review', resolvesThreads: false };
|
|
753
|
+
}
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
|
|
413
757
|
/**
|
|
414
758
|
* Command handler.
|
|
415
759
|
*
|
|
@@ -441,8 +785,8 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
441
785
|
return { success: false, error: 'Usage: forge shepherd <pr> [--auto-rebase] [--bundle --json] [--pull --json]' };
|
|
442
786
|
}
|
|
443
787
|
|
|
444
|
-
const gh = deps
|
|
445
|
-
const git = deps.git ||
|
|
788
|
+
const gh = githubRunner(deps, projectRoot);
|
|
789
|
+
const git = deps.git || defaultGhRunner;
|
|
446
790
|
const buildContext = deps.buildContext || defaultBuildContext;
|
|
447
791
|
const runPass = deps.runPass || runShepherdPass;
|
|
448
792
|
const gatherBundle = deps.gatherBundle || gatherPrBundle;
|
|
@@ -455,7 +799,11 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
455
799
|
|
|
456
800
|
const ctx = await buildContext({ pr, gh, git, projectRoot });
|
|
457
801
|
|
|
458
|
-
const adapter = deps.adapter || new PrStateAdapter({
|
|
802
|
+
const adapter = deps.adapter || new PrStateAdapter({
|
|
803
|
+
gh,
|
|
804
|
+
git,
|
|
805
|
+
repository: `${ctx.owner}/${ctx.repo}`,
|
|
806
|
+
});
|
|
459
807
|
const validation = validatePrStateAdapter(adapter);
|
|
460
808
|
if (!validation.valid) {
|
|
461
809
|
return { success: false, error: `Invalid pr-state adapter: ${validation.errors.join('; ')}` };
|
|
@@ -488,39 +836,206 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
488
836
|
return { success: true, bundle, output: JSON.stringify(bundle, null, 2) };
|
|
489
837
|
}
|
|
490
838
|
|
|
839
|
+
const runPreflight = deps.runLocalPreflight || runLocalReviewPreflight;
|
|
840
|
+
const preflightHead = ctx.headSha;
|
|
841
|
+
const cleanTree = isWorkingTreeClean(git);
|
|
842
|
+
let localPreflight;
|
|
843
|
+
try {
|
|
844
|
+
localPreflight = await runPreflight({
|
|
845
|
+
projectRoot: projectRoot || process.cwd(),
|
|
846
|
+
base: ctx.base,
|
|
847
|
+
baseRef: ctx.baseRef,
|
|
848
|
+
pr: String(pr),
|
|
849
|
+
expectedHead: ctx.headSha,
|
|
850
|
+
localHead: ctx.localHead,
|
|
851
|
+
cleanTree,
|
|
852
|
+
});
|
|
853
|
+
} catch (error) {
|
|
854
|
+
localPreflight = {
|
|
855
|
+
status: 'FAIL',
|
|
856
|
+
blocking: true,
|
|
857
|
+
providers: {},
|
|
858
|
+
findings: [{ provider: 'local-preflight', detail: error?.message || String(error) }],
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (localPreflight.blocking !== true) {
|
|
863
|
+
let postPreflightHead = null;
|
|
864
|
+
try {
|
|
865
|
+
const candidate = git('git', ['rev-parse', 'HEAD']).trim();
|
|
866
|
+
if (/^[0-9a-f]{40}$/i.test(candidate)) postPreflightHead = candidate.toLowerCase();
|
|
867
|
+
} catch {
|
|
868
|
+
/* unreadable mutable checkout fails the fence below */
|
|
869
|
+
}
|
|
870
|
+
const expectedLocalHead = typeof ctx.localHead === 'string' ? ctx.localHead.toLowerCase() : null;
|
|
871
|
+
if (!postPreflightHead || postPreflightHead !== expectedLocalHead || !isWorkingTreeClean(git)) {
|
|
872
|
+
localPreflight = {
|
|
873
|
+
...localPreflight,
|
|
874
|
+
status: 'INCOMPLETE',
|
|
875
|
+
blocking: true,
|
|
876
|
+
findings: [
|
|
877
|
+
...(Array.isArray(localPreflight.findings) ? localPreflight.findings : []),
|
|
878
|
+
{ provider: 'local-preflight', detail: 'Local checkout changed during local review; rerun on the exact clean PR head.' },
|
|
879
|
+
],
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
491
884
|
const result = await runPass({
|
|
492
885
|
...ctx,
|
|
493
886
|
adapter,
|
|
494
887
|
autoRebase,
|
|
495
|
-
cleanTree: autoRebase ?
|
|
888
|
+
cleanTree: autoRebase ? cleanTree : false,
|
|
496
889
|
rerunBudget: deps.rerunBudget || DEFAULT_RERUN_BUDGET,
|
|
497
890
|
rerunsUsed: deps.rerunsUsed || 0,
|
|
891
|
+
dryRun: localPreflight.blocking === true,
|
|
498
892
|
});
|
|
499
893
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
894
|
+
const collectEvidence = deps.collectConvergenceEvidence || collectConvergenceEvidence;
|
|
895
|
+
let evidence;
|
|
896
|
+
try {
|
|
897
|
+
const evidenceContext = result.expectedHead ? { ...ctx, headSha: result.expectedHead } : ctx;
|
|
898
|
+
evidence = await collectEvidence({ args, pr, projectRoot, context: evidenceContext, adapter, deps });
|
|
899
|
+
} catch (error) {
|
|
900
|
+
const reason = `Durable convergence evidence is unavailable: ${error.message}`;
|
|
901
|
+
writePassSummary(pr, 'INCOMPLETE', reason, result.actions);
|
|
902
|
+
return {
|
|
903
|
+
success: false,
|
|
904
|
+
state: 'INCOMPLETE',
|
|
905
|
+
remoteState: result.state,
|
|
906
|
+
reason,
|
|
907
|
+
actions: result.actions || [],
|
|
908
|
+
localPreflight,
|
|
909
|
+
deltas: [],
|
|
910
|
+
deltaOverflow: false,
|
|
911
|
+
receiptIds: [],
|
|
912
|
+
};
|
|
507
913
|
}
|
|
508
|
-
|
|
509
|
-
|
|
914
|
+
|
|
915
|
+
const normalizedPreflightHead = typeof preflightHead === 'string' ? preflightHead.toLowerCase() : null;
|
|
916
|
+
const normalizedResultHead = typeof result.expectedHead === 'string' ? result.expectedHead.toLowerCase() : null;
|
|
917
|
+
if (localPreflight.blocking !== true && result.state === 'MERGE_READY'
|
|
918
|
+
&& normalizedPreflightHead && normalizedResultHead && normalizedResultHead !== normalizedPreflightHead) {
|
|
919
|
+
return {
|
|
920
|
+
success: false,
|
|
921
|
+
state: 'INCOMPLETE',
|
|
922
|
+
remoteState: result.state,
|
|
923
|
+
reason: 'Local preflight head changed; rerun convergence on the current head',
|
|
924
|
+
actions: result.actions || [],
|
|
925
|
+
localPreflight,
|
|
926
|
+
deltas: evidence.deltas,
|
|
927
|
+
deltaOverflow: evidence.deltaOverflow,
|
|
928
|
+
receiptIds: evidence.receiptIds,
|
|
929
|
+
};
|
|
510
930
|
}
|
|
511
931
|
|
|
932
|
+
let confirmedResult = result;
|
|
933
|
+
if (localPreflight.blocking !== true && result.state === 'MERGE_READY'
|
|
934
|
+
&& evidence.continuationPending !== true) {
|
|
935
|
+
const confirmation = await runPass({
|
|
936
|
+
...ctx,
|
|
937
|
+
adapter,
|
|
938
|
+
autoRebase: false,
|
|
939
|
+
cleanTree: false,
|
|
940
|
+
rerunBudget: deps.rerunBudget || DEFAULT_RERUN_BUDGET,
|
|
941
|
+
rerunsUsed: deps.rerunsUsed || 0,
|
|
942
|
+
dryRun: true,
|
|
943
|
+
});
|
|
944
|
+
confirmedResult = {
|
|
945
|
+
...confirmation,
|
|
946
|
+
actions: [...(result.actions || []), ...(confirmation.actions || [])],
|
|
947
|
+
};
|
|
948
|
+
const confirmedHead = typeof confirmedResult.expectedHead === 'string'
|
|
949
|
+
? confirmedResult.expectedHead.toLowerCase()
|
|
950
|
+
: null;
|
|
951
|
+
if (confirmedResult.state === 'MERGE_READY' && confirmedHead !== evidence.exactHead) {
|
|
952
|
+
return {
|
|
953
|
+
success: false,
|
|
954
|
+
state: 'INCOMPLETE',
|
|
955
|
+
remoteState: confirmedResult.state,
|
|
956
|
+
reason: 'PR head changed while confirming mutable merge-readiness evidence; rerun convergence on the current head',
|
|
957
|
+
actions: confirmedResult.actions,
|
|
958
|
+
localPreflight,
|
|
959
|
+
deltas: evidence.deltas,
|
|
960
|
+
deltaOverflow: evidence.deltaOverflow,
|
|
961
|
+
receiptIds: evidence.receiptIds,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
if (confirmedResult.state !== result.state) {
|
|
965
|
+
try {
|
|
966
|
+
const evidenceContext = confirmedResult.expectedHead
|
|
967
|
+
? { ...ctx, headSha: confirmedResult.expectedHead }
|
|
968
|
+
: ctx;
|
|
969
|
+
evidence = await collectEvidence({ args, pr, projectRoot, context: evidenceContext, adapter, deps });
|
|
970
|
+
} catch (error) {
|
|
971
|
+
return {
|
|
972
|
+
success: false,
|
|
973
|
+
state: 'INCOMPLETE',
|
|
974
|
+
remoteState: confirmedResult.state,
|
|
975
|
+
reason: `Confirmed convergence evidence is unavailable: ${error.message}`,
|
|
976
|
+
actions: confirmedResult.actions,
|
|
977
|
+
localPreflight,
|
|
978
|
+
deltas: evidence.deltas,
|
|
979
|
+
deltaOverflow: evidence.deltaOverflow,
|
|
980
|
+
receiptIds: evidence.receiptIds,
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
if (confirmedResult.state === 'MERGED' && !evidence.terminalReceiptId) {
|
|
984
|
+
return {
|
|
985
|
+
success: false,
|
|
986
|
+
state: 'INCOMPLETE',
|
|
987
|
+
remoteState: confirmedResult.state,
|
|
988
|
+
reason: 'Confirmed merged state has no durable terminal receipt',
|
|
989
|
+
actions: confirmedResult.actions,
|
|
990
|
+
localPreflight,
|
|
991
|
+
deltas: evidence.deltas,
|
|
992
|
+
deltaOverflow: evidence.deltaOverflow,
|
|
993
|
+
receiptIds: evidence.receiptIds,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const effectiveState = evidence.continuationPending === true
|
|
1000
|
+
? 'PENDING'
|
|
1001
|
+
: localPreflight.blocking === true && confirmedResult.state === 'MERGE_READY'
|
|
1002
|
+
? (localPreflight.status === 'INCOMPLETE' ? 'INCOMPLETE' : 'PENDING')
|
|
1003
|
+
: confirmedResult.state;
|
|
1004
|
+
const effectiveReason = effectiveState !== confirmedResult.state
|
|
1005
|
+
? (evidence.continuationPending === true
|
|
1006
|
+
? 'Durable convergence work remains; run another bounded shepherd pass.'
|
|
1007
|
+
: effectiveState === 'INCOMPLETE'
|
|
1008
|
+
? 'Local review preflight is incomplete; merge readiness is not established.'
|
|
1009
|
+
: 'Local review preflight is blocking merge readiness.')
|
|
1010
|
+
: confirmedResult.reason;
|
|
1011
|
+
|
|
1012
|
+
// Surface the pass outcome so the monitor is legible when run interactively or
|
|
1013
|
+
// tailed by a scheduler (the bounded state machine is otherwise silent).
|
|
1014
|
+
writePassSummary(pr, effectiveState, effectiveReason, confirmedResult.actions);
|
|
1015
|
+
|
|
512
1016
|
return {
|
|
513
|
-
success:
|
|
514
|
-
state:
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
1017
|
+
success: effectiveState !== 'HARD_STOP' && effectiveState !== 'INCOMPLETE',
|
|
1018
|
+
state: effectiveState,
|
|
1019
|
+
...(effectiveState !== confirmedResult.state ? { remoteState: confirmedResult.state } : {}),
|
|
1020
|
+
reason: effectiveReason,
|
|
1021
|
+
actions: confirmedResult.actions || [],
|
|
1022
|
+
localPreflight,
|
|
1023
|
+
deltas: evidence.deltas,
|
|
1024
|
+
deltaOverflow: evidence.deltaOverflow,
|
|
1025
|
+
receiptIds: evidence.receiptIds,
|
|
1026
|
+
continuationPending: evidence.continuationPending === true,
|
|
1027
|
+
...(evidence.terminalReceiptId ? { terminalReceiptId: evidence.terminalReceiptId } : {}),
|
|
1028
|
+
...(convergenceHandoff(effectiveState, pr, evidence)
|
|
1029
|
+
? { handoff: convergenceHandoff(effectiveState, pr, evidence) }
|
|
1030
|
+
: {}),
|
|
1031
|
+
...(confirmedResult.authClass ? { authClass: confirmedResult.authClass } : {}),
|
|
1032
|
+
...(confirmedResult.retryAfter ? { retryAfter: confirmedResult.retryAfter } : {}),
|
|
519
1033
|
};
|
|
520
1034
|
}
|
|
521
1035
|
|
|
522
1036
|
module.exports = {
|
|
523
1037
|
name: 'shepherd',
|
|
1038
|
+
githubAuth: true,
|
|
524
1039
|
description: 'Run one bounded monitor pass over a PR (rerun flaky checks, escalate, hand off — never merges)',
|
|
525
1040
|
usage: 'Usage: forge shepherd <pr> [--auto-rebase] [--bundle --json] [--pull --json] | forge shepherd events <pr> --since <seq> [--json] | forge shepherd watch <pr> | forge shepherd watch --adopt',
|
|
526
1041
|
handler,
|
|
@@ -529,6 +1044,9 @@ module.exports = {
|
|
|
529
1044
|
buildMonitorContext,
|
|
530
1045
|
makeCheckFailureEnricher,
|
|
531
1046
|
parseSince,
|
|
1047
|
+
collectConvergenceEvidence,
|
|
1048
|
+
convergenceHandoff,
|
|
532
1049
|
defaultBuildContext,
|
|
1050
|
+
defaultListOpenPrs,
|
|
533
1051
|
isWorkingTreeClean,
|
|
534
1052
|
};
|