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.
Files changed (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -0,0 +1,438 @@
1
+ 'use strict';
2
+
3
+ /** Pure, fail-closed reducer for merge evidence already normalized by adapters. */
4
+
5
+ const { types: { isProxy } } = require('node:util');
6
+
7
+ const FULL_SHA = /^[0-9a-f]{40}$/i;
8
+ const HEAD_SOURCES = new Set(['same-repository', 'fork', 'external']);
9
+ const REVIEW_DECISIONS = new Set(['NONE', 'APPROVED', 'REVIEW_REQUIRED', 'CHANGES_REQUESTED']);
10
+
11
+ const VERDICT_STATES = Object.freeze({
12
+ INCOMPLETE: 'INCOMPLETE',
13
+ STALE: 'STALE',
14
+ BLOCKED: 'BLOCKED',
15
+ MERGE_READY: 'MERGE_READY',
16
+ });
17
+
18
+ function normalizeSha(value) {
19
+ return typeof value === 'string' && FULL_SHA.test(value) ? value.toLowerCase() : null;
20
+ }
21
+
22
+ function object(value) {
23
+ if (!value || typeof value !== 'object') return null;
24
+ try {
25
+ return isProxy(value) || Array.isArray(value) ? null : value;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function readDataProperty(value, key) {
32
+ if (!value || (typeof value !== 'object' && typeof value !== 'function')) {
33
+ return { ok: false, value: undefined };
34
+ }
35
+ try {
36
+ if (isProxy(value)) return { ok: false, value: undefined };
37
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
38
+ if (!descriptor || !Object.hasOwn(descriptor, 'value')) {
39
+ return { ok: false, value: undefined };
40
+ }
41
+ return { ok: true, value: descriptor.value };
42
+ } catch {
43
+ return { ok: false, value: undefined };
44
+ }
45
+ }
46
+
47
+ function data(value, key) {
48
+ return readDataProperty(value, key).value;
49
+ }
50
+
51
+ function readArrayItems(value, maxItems = 10_000) {
52
+ try {
53
+ if (isProxy(value) || !Array.isArray(value)) return null;
54
+ const lengthRead = readDataProperty(value, 'length');
55
+ const length = lengthRead.value;
56
+ if (!lengthRead.ok || !Number.isSafeInteger(length) || length < 0 || length > maxItems) return null;
57
+ const items = [];
58
+ for (let index = 0; index < length; index += 1) {
59
+ const item = readDataProperty(value, String(index));
60
+ if (!item.ok) return null;
61
+ items.push(item.value);
62
+ }
63
+ return items;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function normalizeRepository(value) {
70
+ if (typeof value !== 'string') return null;
71
+ const normalized = value.trim().toLowerCase();
72
+ const parts = normalized.split('/');
73
+ return parts.length === 2 && parts.every((part) => part && !/\s/.test(part)) ? normalized : null;
74
+ }
75
+
76
+ function normalizePrNumber(value) {
77
+ return Number.isSafeInteger(value) && value > 0 ? value : null;
78
+ }
79
+
80
+ function reason(code, detail) {
81
+ return detail === undefined ? { code } : { code, detail };
82
+ }
83
+
84
+ function addReason(groups, group, code, detail) {
85
+ groups[group].push(reason(code, detail));
86
+ }
87
+
88
+ function currentSurface(groups, surface, expectedHead, code) {
89
+ const head = normalizeSha(data(surface, 'headSha'));
90
+ if (!head) {
91
+ addReason(groups, 'incomplete', `${code}_head_invalid`);
92
+ return false;
93
+ }
94
+ if (expectedHead && head !== expectedHead) {
95
+ addReason(groups, 'stale', `${code}_stale`);
96
+ return false;
97
+ }
98
+ return true;
99
+ }
100
+
101
+ function checkKey(entry) {
102
+ return `${entry.name}\u0000${entry.appId === null ? '*' : entry.appId}`;
103
+ }
104
+
105
+ function normalizeCheckEntry(entry) {
106
+ const record = object(entry);
107
+ const name = data(record, 'name');
108
+ const rawAppId = data(record, 'appId');
109
+ if (!record || typeof name !== 'string' || !name.trim()) return null;
110
+ let appId = null;
111
+ if (rawAppId !== null) {
112
+ if (!Number.isSafeInteger(rawAppId) || rawAppId <= 0) return null;
113
+ appId = rawAppId;
114
+ }
115
+ return { name: name.trim(), appId };
116
+ }
117
+
118
+ function addIdentityValidityReasons(identity, groups) {
119
+ if (!identity.expectedHead) addReason(groups, 'incomplete', 'invalid_expected_head');
120
+ if (!identity.expectedBase) addReason(groups, 'incomplete', 'invalid_expected_base');
121
+ if (!identity.expectedRepository) addReason(groups, 'incomplete', 'invalid_expected_repository');
122
+ if (!identity.expectedPrNumber) addReason(groups, 'incomplete', 'invalid_expected_pr_number');
123
+ if (!identity.prNumber) addReason(groups, 'incomplete', 'invalid_pr_number');
124
+ if (!identity.observedHead) addReason(groups, 'incomplete', 'invalid_observed_head');
125
+ if (!identity.baseSha) addReason(groups, 'incomplete', 'invalid_base');
126
+ }
127
+
128
+ function addIdentityDriftReasons(identity, groups) {
129
+ if (identity.expectedHead && identity.observedHead && identity.expectedHead !== identity.observedHead) {
130
+ addReason(groups, 'stale', 'head_mismatch');
131
+ }
132
+ if (identity.expectedBase && identity.baseSha && identity.expectedBase !== identity.baseSha) {
133
+ addReason(groups, 'stale', 'base_mismatch');
134
+ }
135
+ if (identity.expectedRepository && identity.repository
136
+ && identity.expectedRepository !== identity.repository) {
137
+ addReason(groups, 'stale', 'repository_mismatch');
138
+ }
139
+ if (identity.expectedPrNumber && identity.prNumber
140
+ && identity.expectedPrNumber !== identity.prNumber) {
141
+ addReason(groups, 'stale', 'pr_number_mismatch');
142
+ }
143
+ }
144
+
145
+ function evaluateHeadSource(head, headRepository, baseRepository, groups) {
146
+ if (!headRepository || !baseRepository) {
147
+ addReason(groups, 'incomplete', 'repository_identity_incomplete');
148
+ return;
149
+ }
150
+ const source = data(head, 'source');
151
+ const sameRepository = headRepository === baseRepository;
152
+ if (!HEAD_SOURCES.has(source)) {
153
+ addReason(groups, 'incomplete', 'head_source_unknown');
154
+ } else if ((sameRepository && source !== 'same-repository')
155
+ || (!sameRepository && source === 'same-repository')) {
156
+ addReason(groups, 'incomplete', 'head_source_conflict');
157
+ }
158
+ if (!sameRepository && data(head, 'acquired') !== true) {
159
+ addReason(groups, 'incomplete', 'external_head_not_acquired');
160
+ }
161
+ }
162
+
163
+ function evaluateIdentity(input, groups) {
164
+ const head = object(data(input, 'head'));
165
+ const base = object(data(input, 'base'));
166
+ const identity = {
167
+ expectedHead: normalizeSha(data(input, 'expectedHeadSha')),
168
+ expectedBase: normalizeSha(data(input, 'expectedBaseSha')),
169
+ expectedRepository: normalizeRepository(data(input, 'expectedRepository')),
170
+ expectedPrNumber: normalizePrNumber(data(input, 'expectedPrNumber')),
171
+ repository: normalizeRepository(data(base, 'repository')),
172
+ prNumber: normalizePrNumber(data(input, 'prNumber')),
173
+ observedHead: normalizeSha(data(head, 'sha')),
174
+ baseSha: normalizeSha(data(base, 'sha')),
175
+ };
176
+ const headRepository = normalizeRepository(data(head, 'repository'));
177
+ addIdentityValidityReasons(identity, groups);
178
+ addIdentityDriftReasons(identity, groups);
179
+ evaluateHeadSource(head, headRepository, identity.repository, groups);
180
+ return identity;
181
+ }
182
+
183
+ function evaluateAncestry(input, identity, groups) {
184
+ const ancestry = object(data(input, 'ancestry'));
185
+ if (!ancestry || data(ancestry, 'complete') !== true) {
186
+ addReason(groups, 'incomplete', 'ancestry_incomplete');
187
+ return;
188
+ }
189
+ currentSurface(groups, ancestry, identity.expectedHead, 'ancestry');
190
+ const ancestryBase = normalizeSha(data(ancestry, 'baseSha'));
191
+ if (!ancestryBase) addReason(groups, 'incomplete', 'ancestry_base_invalid');
192
+ else if (identity.baseSha && ancestryBase !== identity.baseSha) addReason(groups, 'stale', 'ancestry_base_stale');
193
+ else if (identity.expectedBase && ancestryBase !== identity.expectedBase) {
194
+ addReason(groups, 'stale', 'ancestry_base_stale');
195
+ }
196
+
197
+ const containsBase = data(ancestry, 'containsBase');
198
+ const behindBy = data(ancestry, 'behindBy');
199
+ const conflicting = data(ancestry, 'conflicting');
200
+ if (typeof containsBase !== 'boolean'
201
+ || !Number.isSafeInteger(behindBy) || behindBy < 0
202
+ || typeof conflicting !== 'boolean') {
203
+ addReason(groups, 'incomplete', 'ancestry_malformed');
204
+ return;
205
+ }
206
+ if (!containsBase) addReason(groups, 'blocked', 'base_not_ancestor');
207
+ if (behindBy > 0) addReason(groups, 'blocked', 'head_behind_base', behindBy);
208
+ if (conflicting) addReason(groups, 'blocked', 'merge_conflict');
209
+ }
210
+
211
+ function evaluateRequiredPolicy(required, groups) {
212
+ const items = readArrayItems(required);
213
+ if (!items) {
214
+ addReason(groups, 'incomplete', 'required_policy_incomplete');
215
+ return null;
216
+ }
217
+ const grouped = new Map();
218
+ const normalized = [];
219
+ for (const raw of items) {
220
+ const entry = normalizeCheckEntry(raw);
221
+ if (!entry) {
222
+ addReason(groups, 'incomplete', 'required_policy_malformed');
223
+ continue;
224
+ }
225
+ if (!grouped.has(entry.name)) grouped.set(entry.name, []);
226
+ grouped.get(entry.name).push(entry);
227
+ }
228
+ for (const name of [...grouped.keys()].sort(compareCanonicalStrings)) {
229
+ const entries = grouped.get(name);
230
+ if (entries.length !== 1) addReason(groups, 'incomplete', 'required_policy_conflict', name);
231
+ else normalized.push(entries[0]);
232
+ }
233
+ return normalized;
234
+ }
235
+
236
+ function evaluateCheckObservations(observations, expectedHead, groups) {
237
+ const items = readArrayItems(observations);
238
+ if (!items) {
239
+ addReason(groups, 'incomplete', 'check_observations_incomplete');
240
+ return null;
241
+ }
242
+ const grouped = new Map();
243
+ for (const raw of items) {
244
+ const entry = normalizeCheckEntry(raw);
245
+ const status = data(raw, 'status');
246
+ const conclusion = data(raw, 'conclusion');
247
+ if (!entry || typeof status !== 'string'
248
+ || (conclusion !== null && typeof conclusion !== 'string')) {
249
+ addReason(groups, 'incomplete', 'check_observation_malformed');
250
+ continue;
251
+ }
252
+ const head = normalizeSha(data(raw, 'headSha'));
253
+ if (!head) addReason(groups, 'incomplete', 'check_observation_head_invalid', entry.name);
254
+ else if (expectedHead && head !== expectedHead) addReason(groups, 'stale', 'check_observation_stale', entry.name);
255
+ const normalized = {
256
+ ...entry,
257
+ status: status.toUpperCase(),
258
+ conclusion: String(conclusion || '').toUpperCase(),
259
+ };
260
+ const key = checkKey(normalized);
261
+ if (!grouped.has(key)) grouped.set(key, []);
262
+ grouped.get(key).push(normalized);
263
+ }
264
+ const byKey = new Map();
265
+ const byName = new Map();
266
+ const conflicts = new Set();
267
+ const conflictNames = new Set();
268
+ for (const key of [...grouped.keys()].sort(compareCanonicalStrings)) {
269
+ const entries = grouped.get(key);
270
+ if (entries.length !== 1) {
271
+ conflicts.add(key);
272
+ conflictNames.add(entries[0].name);
273
+ addReason(groups, 'incomplete', 'check_observation_conflict', entries[0].name);
274
+ } else {
275
+ byKey.set(key, entries[0]);
276
+ if (!byName.has(entries[0].name)) byName.set(entries[0].name, []);
277
+ byName.get(entries[0].name).push(entries[0]);
278
+ }
279
+ }
280
+ return { byKey, byName, conflicts, conflictNames };
281
+ }
282
+
283
+ function evaluateChecks(input, expectedHead, groups) {
284
+ const checks = object(data(input, 'checks'));
285
+ if (!checks || data(checks, 'complete') !== true) {
286
+ addReason(groups, 'incomplete', 'checks_incomplete');
287
+ return;
288
+ }
289
+ currentSurface(groups, checks, expectedHead, 'checks');
290
+ const required = evaluateRequiredPolicy(data(checks, 'required'), groups);
291
+ const observations = evaluateCheckObservations(data(checks, 'observations'), expectedHead, groups);
292
+ if (!required || !observations) return;
293
+
294
+ for (const requirement of required) {
295
+ const key = checkKey(requirement);
296
+ if (observations.conflicts.has(key)
297
+ || (requirement.appId === null && observations.conflictNames.has(requirement.name))) continue;
298
+ const matches = requirement.appId === null
299
+ ? observations.byName.get(requirement.name) || []
300
+ : [observations.byKey.get(key)].filter(Boolean);
301
+ if (matches.length === 0) {
302
+ addReason(groups, 'blocked', 'required_check_missing', requirement.name);
303
+ } else if (!matches.some((entry) => entry.status === 'COMPLETED' && entry.conclusion === 'SUCCESS')) {
304
+ addReason(groups, 'blocked', 'required_check_not_successful', requirement.name);
305
+ }
306
+ }
307
+ }
308
+
309
+ function evaluateReview(input, expectedHead, groups) {
310
+ const review = object(data(input, 'review'));
311
+ if (!review || data(review, 'complete') !== true) {
312
+ addReason(groups, 'incomplete', 'review_incomplete');
313
+ return;
314
+ }
315
+ currentSurface(groups, review, expectedHead, 'review');
316
+ const required = data(review, 'required');
317
+ const rawDecision = data(review, 'decision');
318
+ const conflicting = data(review, 'conflicting');
319
+ if (typeof required !== 'boolean' || typeof rawDecision !== 'string'
320
+ || typeof conflicting !== 'boolean') {
321
+ addReason(groups, 'incomplete', 'review_malformed');
322
+ return;
323
+ }
324
+ const decision = rawDecision.toUpperCase();
325
+ if (!REVIEW_DECISIONS.has(decision)) {
326
+ addReason(groups, 'incomplete', 'review_decision_unknown');
327
+ return;
328
+ }
329
+ if (conflicting) {
330
+ addReason(groups, 'incomplete', 'review_state_conflict');
331
+ } else if (!required && decision === 'REVIEW_REQUIRED') {
332
+ addReason(groups, 'incomplete', 'review_state_conflict');
333
+ } else if (decision === 'CHANGES_REQUESTED') {
334
+ addReason(groups, 'blocked', 'changes_requested');
335
+ } else if (required && decision !== 'APPROVED') {
336
+ addReason(groups, 'blocked', 'approval_missing');
337
+ }
338
+ }
339
+
340
+ function evaluateThreads(input, expectedHead, groups) {
341
+ const threads = object(data(input, 'threads'));
342
+ if (!threads || data(threads, 'complete') !== true) {
343
+ addReason(groups, 'incomplete', 'threads_incomplete');
344
+ return;
345
+ }
346
+ currentSurface(groups, threads, expectedHead, 'threads');
347
+ const items = readArrayItems(data(threads, 'items'));
348
+ if (!items) {
349
+ addReason(groups, 'incomplete', 'threads_malformed');
350
+ return;
351
+ }
352
+ const byId = new Map();
353
+ let unresolved = 0;
354
+ for (const thread of items) {
355
+ const id = data(thread, 'id');
356
+ const resolved = data(thread, 'resolved');
357
+ const outdated = data(thread, 'outdated');
358
+ if (!object(thread) || typeof id !== 'string' || !id.trim()
359
+ || typeof resolved !== 'boolean' || typeof outdated !== 'boolean') {
360
+ addReason(groups, 'incomplete', 'thread_malformed');
361
+ continue;
362
+ }
363
+ const normalizedId = id.trim();
364
+ if (!byId.has(normalizedId)) byId.set(normalizedId, []);
365
+ byId.get(normalizedId).push({ resolved, outdated });
366
+ }
367
+ for (const id of [...byId.keys()].sort(compareCanonicalStrings)) {
368
+ const states = byId.get(id);
369
+ if (states.length !== 1) {
370
+ addReason(groups, 'incomplete', 'thread_state_conflict', id);
371
+ } else if (!states[0].resolved && !states[0].outdated) {
372
+ unresolved += 1;
373
+ }
374
+ }
375
+ if (unresolved > 0) addReason(groups, 'blocked', 'unresolved_threads', unresolved);
376
+ }
377
+
378
+ function verdictState(groups) {
379
+ if (groups.incomplete.length > 0) return VERDICT_STATES.INCOMPLETE;
380
+ if (groups.stale.length > 0) return VERDICT_STATES.STALE;
381
+ if (groups.blocked.length > 0) return VERDICT_STATES.BLOCKED;
382
+ return VERDICT_STATES.MERGE_READY;
383
+ }
384
+
385
+ function compareCanonicalStrings(left, right) {
386
+ if (left < right) return -1;
387
+ if (left > right) return 1;
388
+ return 0;
389
+ }
390
+
391
+ function compareReason(left, right) {
392
+ const codeOrder = compareCanonicalStrings(left.code, right.code);
393
+ if (codeOrder !== 0) return codeOrder;
394
+ const leftDetail = String(left.detail ?? '');
395
+ const rightDetail = String(right.detail ?? '');
396
+ return compareCanonicalStrings(leftDetail, rightDetail);
397
+ }
398
+
399
+ function canonicalReasons(groups) {
400
+ const seen = new Set();
401
+ return [...groups.incomplete, ...groups.stale, ...groups.blocked]
402
+ .sort(compareReason)
403
+ .filter((item) => {
404
+ const key = `${item.code}\u0000${String(item.detail ?? '')}`;
405
+ if (seen.has(key)) return false;
406
+ seen.add(key);
407
+ return true;
408
+ });
409
+ }
410
+
411
+ function evaluateCurrentHeadVerdict(input = {}) {
412
+ const evidence = object(input) || {};
413
+ const groups = { incomplete: [], stale: [], blocked: [] };
414
+ const identity = evaluateIdentity(evidence, groups);
415
+ evaluateAncestry(evidence, identity, groups);
416
+ evaluateChecks(evidence, identity.expectedHead, groups);
417
+ evaluateReview(evidence, identity.expectedHead, groups);
418
+ evaluateThreads(evidence, identity.expectedHead, groups);
419
+ return {
420
+ state: verdictState(groups),
421
+ repository: identity.repository,
422
+ prNumber: identity.prNumber,
423
+ headSha: identity.observedHead,
424
+ baseSha: identity.baseSha,
425
+ reasons: canonicalReasons(groups),
426
+ };
427
+ }
428
+
429
+ module.exports = {
430
+ VERDICT_STATES,
431
+ evaluateCurrentHeadVerdict,
432
+ normalizePrNumber,
433
+ normalizeRepository,
434
+ normalizeSha,
435
+ object,
436
+ readArrayItems,
437
+ readDataProperty,
438
+ };
@@ -19,26 +19,60 @@
19
19
  const path = require('node:path');
20
20
  const { spawn, execFileSync } = require('node:child_process');
21
21
 
22
- const journal = require('./journal');
22
+ const watchOwner = require('./watch-owner');
23
+ const { isCompiledBinary } = require('../package-root');
24
+ const { readGithubAccount } = require('../github-context');
25
+
26
+ const CANONICAL_REPOSITORY = /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/;
27
+
28
+ async function launchGateComplete(owner = watchOwner, ownerOptions = {}) {
29
+ if (typeof owner?.readMigrationGate !== 'function') return false;
30
+ try {
31
+ const result = await owner.readMigrationGate({}, ownerOptions);
32
+ return result?.ok === true && result.gate?.state === 'complete';
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
23
37
 
24
38
  /** Absolute path to the forge CLI entrypoint (this file is lib/pr-monitor/). */
25
39
  function forgeBin() {
26
40
  return path.join(__dirname, '..', '..', 'bin', 'forge.js');
27
41
  }
28
42
 
43
+ function forgeArgs(args, opts = {}) {
44
+ return (opts.isCompiledBinary || isCompiledBinary)() ? [...args] : [forgeBin(), ...args];
45
+ }
46
+
47
+ // Workers resolve their own account after re-entry. Preserve native unbound environments.
48
+ function githubWorkerEnvironment(projectRoot, opts = {}) {
49
+ let bound;
50
+ try {
51
+ bound = opts.githubContext ? opts.githubContext.bound
52
+ : Boolean((opts.readGithubAccount || readGithubAccount)(projectRoot));
53
+ } catch {
54
+ throw new Error('Unable to read the worker GitHub account binding.');
55
+ }
56
+ if (!bound) return opts.env;
57
+ const env = { ...(opts.env || process.env) };
58
+ for (const key of Object.keys(env)) {
59
+ if (['gh_token', 'github_token', 'gh_host'].includes(key.toLowerCase())) delete env[key];
60
+ }
61
+ return env;
62
+ }
63
+
29
64
  /**
30
- * Best-effort repo slug (the bare repo NAME, matching the shepherd's `ctx.repo`)
31
- * from `git remote get-url origin`, so the idempotency check hits the same
32
- * journal dir the watcher itself uses. Returns null on any failure — the caller
33
- * then falls through to spawn and relies on the watch loop's own de-dup.
65
+ * Parse a canonical `owner/repository` from an origin URL. Production launchers
66
+ * supply the provider-resolved base repository; this remains a bounded utility
67
+ * for diagnostics and compatibility callers.
34
68
  */
35
69
  function defaultResolveSlug({ cwd, exec = execFileSync }) {
36
70
  try {
37
71
  const url = exec('git', ['remote', 'get-url', 'origin'], {
38
72
  cwd, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
39
73
  }).trim();
40
- const match = /[/:][^/]+\/([^/]+?)(?:\.git)?$/.exec(url);
41
- return match ? match[1] : null;
74
+ const match = /[/:]([^/:]+)\/([^/]+?)(?:\.git)?$/.exec(url);
75
+ return match ? `${match[1]}/${match[2]}`.toLowerCase() : null;
42
76
  } catch {
43
77
  return null;
44
78
  }
@@ -51,45 +85,129 @@ function defaultResolveSlug({ cwd, exec = execFileSync }) {
51
85
  * @param {string|number} opts.prNumber - the PR to watch.
52
86
  * @param {string} [opts.cwd] - repo root (default process.cwd()).
53
87
  * @param {Function} [opts.spawn] - child spawner (test injection).
54
- * @param {Function} [opts.exec] - git runner for slug resolution (test injection).
55
- * @param {object} [opts.journal] - journal module (test injection).
56
- * @param {Function} [opts.resolveSlug] - slug resolver (test injection).
57
- * @returns {{ started: boolean, pid?: number|null, reason?: string }} — never throws.
88
+ * @param {string} opts.repository - canonical provider base repository.
89
+ * @param {object} [opts.reservation] - exact pre-reserved owner envelope.
90
+ * @param {object} [opts.owner] - watcher owner API (test injection).
91
+ * @param {object} [opts.ownerOptions] - Kernel driver and evidence verification.
92
+ * @returns {Promise<{ started: boolean, pid?: number|null, reason?: string }>} — never throws.
58
93
  */
59
- function startPrWatcherDetached(opts = {}) {
94
+ async function startPrWatcherDetached(opts = {}) {
60
95
  const { prNumber, cwd = process.cwd() } = opts;
61
96
  const spawnFn = opts.spawn || spawn;
62
- const journalMod = opts.journal || journal;
63
- const resolveSlug = opts.resolveSlug || defaultResolveSlug;
97
+ const owner = opts.owner || watchOwner;
98
+ const controllerPid = opts.controllerPid ?? process.pid;
99
+ const repository = typeof opts.repository === 'string' ? opts.repository.toLowerCase() : '';
100
+ const pr = Number(prNumber);
101
+ let reservation = null;
64
102
  try {
65
- if (!prNumber) return { started: false, reason: 'no-pr' };
103
+ if (!Number.isSafeInteger(pr) || pr <= 0) return { started: false, reason: 'no-pr' };
104
+ if (!CANONICAL_REPOSITORY.test(repository)) return { started: false, reason: 'repository-unavailable' };
105
+ if (!Number.isSafeInteger(controllerPid) || controllerPid <= 0
106
+ || typeof owner.abortStarting !== 'function'
107
+ || (opts.reservation == null && typeof owner.reserveStarting !== 'function')) {
108
+ return { started: false, reason: 'authority-unavailable' };
109
+ }
110
+ if (!await launchGateComplete(owner, opts.ownerOptions || {})) {
111
+ return { started: false, reason: 'migration-gate-incomplete' };
112
+ }
66
113
 
67
- const slug = resolveSlug({ cwd, exec: opts.exec });
68
- if (slug) {
69
- const dir = journalMod.journalDir({ root: cwd, repo: slug, pr: prNumber });
70
- if (journalMod.watcherRunning(dir)) return { started: false, reason: 'already-running' };
114
+ const env = githubWorkerEnvironment(cwd, opts);
115
+ const identity = { repo: repository, pr };
116
+ if (opts.reservation != null) {
117
+ reservation = opts.reservation;
118
+ const record = reservation?.record;
119
+ const startedAt = typeof record?.startedAt === 'string' ? new Date(record.startedAt) : null;
120
+ const exactReservation = reservation?.ok === true
121
+ && record?.repo === repository
122
+ && record?.pr === pr
123
+ && record?.phase === 'starting'
124
+ && record?.controllerPid === controllerPid
125
+ && typeof record?.generation === 'string'
126
+ && record.generation.length > 0
127
+ && startedAt != null
128
+ && !Number.isNaN(startedAt.getTime())
129
+ && startedAt.toISOString() === record.startedAt;
130
+ if (!exactReservation) return { started: false, reason: 'invalid-reservation' };
131
+ } else {
132
+ reservation = await owner.reserveStarting(identity, { controllerPid }, opts.ownerOptions || {});
133
+ if (!reservation?.ok && reservation?.record?.phase === 'complete' && opts.providerEvidence
134
+ && typeof owner.reserveReopened === 'function') {
135
+ reservation = await owner.reserveReopened(identity, {
136
+ generation: reservation.record.generation,
137
+ expectedReceiptId: reservation.record.terminalReceiptId,
138
+ controllerPid,
139
+ providerEvidence: opts.providerEvidence,
140
+ }, opts.ownerOptions || {});
141
+ }
142
+ }
143
+ if (!reservation?.ok || !reservation.record) {
144
+ return { started: false, reason: reservation?.reason || 'authority-unavailable' };
145
+ }
146
+ if (!await launchGateComplete(owner, opts.ownerOptions || {})) {
147
+ if (opts.reservation == null) {
148
+ await owner.abortStarting(identity, {
149
+ generation: reservation.record.generation, controllerPid,
150
+ }, opts.ownerOptions || {});
151
+ }
152
+ return { started: false, reason: 'migration-gate-incomplete' };
71
153
  }
72
154
 
73
155
  const child = spawnFn(
74
156
  process.execPath,
75
- [forgeBin(), 'shepherd', 'watch', String(prNumber)],
76
- { cwd, detached: true, stdio: 'ignore', windowsHide: true },
157
+ forgeArgs([
158
+ 'shepherd', 'watch', String(pr),
159
+ '--repo', repository,
160
+ '--generation', reservation.record.generation,
161
+ '--controller-pid', String(controllerPid),
162
+ '--started-at', reservation.record.startedAt,
163
+ ], opts),
164
+ { cwd, detached: true, stdio: 'ignore', windowsHide: true, ...(env ? { env } : {}) },
77
165
  );
78
166
  // spawn can emit an ASYNC 'error' (ENOENT/EACCES) AFTER returning; with no
79
167
  // listener that becomes an unhandled exception that could crash ship. A no-op
80
- // handler keeps a failed detached start best-effort (the watch loop's own
81
- // journal claim is the authoritative de-dup anyway).
82
- if (child && typeof child.on === 'function') child.on('error', () => {});
168
+ // handler keeps a failed detached start best-effort; the reservation is
169
+ // conditionally aborted so a later controller can recover immediately.
170
+ if (child && typeof child.on === 'function') {
171
+ child.on('error', () => {
172
+ void Promise.resolve()
173
+ .then(() => owner.abortStarting(identity, {
174
+ generation: reservation.record.generation, controllerPid,
175
+ }, opts.ownerOptions || {}))
176
+ .catch(() => { /* a stale starting row is recoverable by the daemon */ });
177
+ });
178
+ }
83
179
  if (child && typeof child.unref === 'function') child.unref();
84
- return { started: true, pid: child?.pid ?? null };
85
- } catch (err) {
180
+ if (!Number.isSafeInteger(child?.pid) || child.pid <= 0) {
181
+ await owner.abortStarting(identity, {
182
+ generation: reservation.record.generation, controllerPid,
183
+ }, opts.ownerOptions || {});
184
+ return { started: false, reason: 'spawn-pid-unavailable' };
185
+ }
186
+ return {
187
+ started: true,
188
+ pid: child.pid,
189
+ generation: reservation.record.generation,
190
+ startedAt: reservation.record.startedAt,
191
+ repository,
192
+ };
193
+ } catch {
86
194
  // Lifecycle auto-start must never fail ship — degrade to "not started".
87
- return { started: false, reason: err.message };
195
+ if (reservation?.record && CANONICAL_REPOSITORY.test(repository)) {
196
+ try {
197
+ await owner.abortStarting({ repo: repository, pr }, {
198
+ generation: reservation.record.generation, controllerPid,
199
+ }, opts.ownerOptions || {});
200
+ } catch { /* a stale starting row is recoverable by the daemon */ }
201
+ }
202
+ return { started: false, reason: 'watcher-launch-failed' };
88
203
  }
89
204
  }
90
205
 
91
206
  module.exports = {
92
207
  startPrWatcherDetached,
208
+ launchGateComplete,
93
209
  defaultResolveSlug,
94
210
  forgeBin,
211
+ forgeArgs,
212
+ githubWorkerEnvironment,
95
213
  };