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
@@ -17,6 +17,16 @@
17
17
 
18
18
  const { gatherPrSnapshot } = require('../pr-pull');
19
19
  const { isFailed, isGreen } = require('../pr-shepherd');
20
+ const { classifyReviewActor, normalizeEvidenceText } = require('../review-adapter');
21
+
22
+ const SNAPSHOT_LIMITS = Object.freeze({
23
+ maxChecks: 100,
24
+ maxThreads: 100,
25
+ maxReviews: 100,
26
+ maxComments: 100,
27
+ maxDegraded: 32,
28
+ maxTextChars: 256,
29
+ });
20
30
 
21
31
  /** green | failed | pending — reuses the SAME predicates as the verdict core. */
22
32
  function classifyCheck(check) {
@@ -25,45 +35,200 @@ function classifyCheck(check) {
25
35
  return 'pending';
26
36
  }
27
37
 
28
- function normalizeChecks(checks) {
29
- return (Array.isArray(checks) ? checks : []).map((c) => ({
30
- name: c.name || '',
31
- class: classifyCheck(c),
32
- }));
38
+ function compareBy(field) {
39
+ return (left, right) => {
40
+ const a = String(left[field] ?? '');
41
+ const b = String(right[field] ?? '');
42
+ if (a < b) return -1;
43
+ if (a > b) return 1;
44
+ return 0;
45
+ };
46
+ }
47
+
48
+ function evidenceText(value) {
49
+ return normalizeEvidenceText(value, { maxChars: SNAPSHOT_LIMITS.maxTextChars });
50
+ }
51
+
52
+ function identifiedSurface(value) {
53
+ if (typeof value === 'string' && value.trim()) return value;
54
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
55
+ return value.source || value.surface || null;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function addIdentifiedSurface(value, add, error) {
61
+ const surface = identifiedSurface(value);
62
+ if (surface) add(surface, error);
63
+ }
64
+
65
+ function createEvidenceAdder(degraded, incompleteSurfaces) {
66
+ return (surface, error) => {
67
+ const safeSurface = evidenceText(surface) || 'unknown';
68
+ const safeError = evidenceText(error) || 'evidence unavailable';
69
+ incompleteSurfaces.add(safeSurface);
70
+ if (!degraded.some((item) => item.surface === safeSurface && item.error === safeError)
71
+ && degraded.length < SNAPSHOT_LIMITS.maxDegraded) {
72
+ degraded.push({ surface: safeSurface, error: safeError });
73
+ }
74
+ };
75
+ }
76
+
77
+ function trackDegradedItem(item, add) {
78
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
79
+ add('degraded', 'degraded evidence item is malformed');
80
+ addIdentifiedSurface(item, add, 'degraded evidence metadata is malformed');
81
+ return;
82
+ }
83
+ add(item.source || item.surface || 'unknown', item.error || 'evidence unavailable');
84
+ }
85
+
86
+ function trackInitialDegraded(initialDegraded, add) {
87
+ if (!Array.isArray(initialDegraded)) {
88
+ add('degraded', 'degraded evidence is malformed');
89
+ addIdentifiedSurface(initialDegraded, add, 'degraded evidence metadata is malformed');
90
+ return;
91
+ }
92
+ for (const item of initialDegraded.slice(0, SNAPSHOT_LIMITS.maxDegraded)) {
93
+ trackDegradedItem(item, add);
94
+ }
95
+ if (initialDegraded.length > SNAPSHOT_LIMITS.maxDegraded) {
96
+ add('degraded', 'degraded evidence exceeded item limit');
97
+ }
33
98
  }
34
99
 
35
- function normalizeThreads(threads) {
36
- return (Array.isArray(threads) ? threads : []).map((t) => ({
37
- threadId: t.threadId || '',
38
- isResolved: Boolean(t.isResolved),
39
- isOutdated: Boolean(t.isOutdated),
40
- commentCount: Array.isArray(t.comments) ? t.comments.length : 0,
41
- actionable: !t.isResolved && !t.isOutdated,
42
- path: t.path || null,
43
- }));
100
+ function createEvidenceTracker(initialDegraded) {
101
+ const degraded = [];
102
+ const incompleteSurfaces = new Set();
103
+ const add = createEvidenceAdder(degraded, incompleteSurfaces);
104
+ trackInitialDegraded(initialDegraded, add);
105
+ return { add, degraded, incompleteSurfaces };
44
106
  }
45
107
 
46
- function normalizeReviews(reviews) {
47
- return (Array.isArray(reviews) ? reviews : []).map((r) => ({
48
- author: r.author || '',
49
- state: r.state || '',
50
- commitOid: r.commitOid || null,
51
- submittedAt: r.submittedAt || null,
52
- }));
108
+ function trackUnreadable(value, tracker) {
109
+ if (!Array.isArray(value)) {
110
+ tracker.add('unreadable', 'unreadable evidence is malformed');
111
+ const surface = identifiedSurface(value);
112
+ if (surface) tracker.add(surface, 'unreadable evidence metadata is malformed');
113
+ return;
114
+ }
115
+ for (const item of value) {
116
+ if (typeof item === 'string' && item.trim()) {
117
+ tracker.add(item, 'provider read was incomplete');
118
+ } else {
119
+ tracker.add('unreadable', 'unreadable evidence item is malformed');
120
+ const surface = identifiedSurface(item);
121
+ if (surface) tracker.add(surface, 'unreadable evidence metadata is malformed');
122
+ }
123
+ }
53
124
  }
54
125
 
55
- function normalizeComments(comments) {
56
- return (Array.isArray(comments) ? comments : []).map((c) => ({
57
- id: c.id || `${c.author || ''}:${c.createdAt || ''}`,
58
- author: c.author || '',
59
- }));
126
+ function boundedNormalize(value, { surface, limit, normalize, tracker, sortField }) {
127
+ if (!Array.isArray(value)) {
128
+ tracker.add(surface, `${surface} evidence is missing or malformed`);
129
+ return [];
130
+ }
131
+ if (value.length > limit) tracker.add(surface, `${surface} evidence exceeded ${limit} item limit`);
132
+ const output = [];
133
+ for (const item of value.slice(0, limit)) {
134
+ try {
135
+ output.push(normalize(item));
136
+ } catch (_error) {
137
+ tracker.add(surface, `${surface} evidence item is malformed`);
138
+ }
139
+ }
140
+ return output.sort(compareBy(sortField));
60
141
  }
61
142
 
62
- function normalizeDegraded(degraded) {
63
- return (Array.isArray(degraded) ? degraded : []).map((d) => ({
64
- surface: d.source || d.surface || 'unknown',
65
- error: d.error || '',
66
- }));
143
+ function normalizeChecks(checks, tracker) {
144
+ return boundedNormalize(checks, {
145
+ surface: 'checks',
146
+ limit: SNAPSHOT_LIMITS.maxChecks,
147
+ tracker,
148
+ sortField: 'name',
149
+ normalize: (check) => {
150
+ if (!check || typeof check !== 'object' || Array.isArray(check)
151
+ || typeof check.name !== 'string' || !check.name.trim()
152
+ || (typeof check.status !== 'string' && typeof check.conclusion !== 'string')) {
153
+ throw new TypeError('malformed check');
154
+ }
155
+ return { name: evidenceText(check.name), class: classifyCheck(check) };
156
+ },
157
+ });
158
+ }
159
+
160
+ function normalizeActor(value) {
161
+ const author = evidenceText(value.author);
162
+ const actorKind = classifyReviewActor(value);
163
+ if (!author || actorKind === 'unknown') throw new TypeError('malformed actor');
164
+ return { author, actorKind };
165
+ }
166
+
167
+ function normalizeThreads(threads, tracker) {
168
+ return boundedNormalize(threads, {
169
+ surface: 'threads',
170
+ limit: SNAPSHOT_LIMITS.maxThreads,
171
+ tracker,
172
+ sortField: 'threadId',
173
+ normalize: (thread) => {
174
+ if (!thread || typeof thread !== 'object' || Array.isArray(thread)
175
+ || typeof thread.threadId !== 'string' || !thread.threadId
176
+ || typeof thread.isResolved !== 'boolean'
177
+ || typeof thread.isOutdated !== 'boolean'
178
+ || !Array.isArray(thread.comments) || thread.comments.length === 0
179
+ || (thread.path !== null && thread.path !== undefined && typeof thread.path !== 'string')) {
180
+ throw new TypeError('malformed thread');
181
+ }
182
+ const actor = normalizeActor(thread.comments[0]);
183
+ return {
184
+ threadId: evidenceText(thread.threadId),
185
+ isResolved: thread.isResolved,
186
+ isOutdated: thread.isOutdated,
187
+ commentCount: thread.comments.length,
188
+ actionable: !thread.isResolved && !thread.isOutdated,
189
+ path: thread.path ? evidenceText(thread.path) : null,
190
+ ...actor,
191
+ };
192
+ },
193
+ });
194
+ }
195
+
196
+ function normalizeReviews(reviews, tracker) {
197
+ return boundedNormalize(reviews, {
198
+ surface: 'reviews',
199
+ limit: SNAPSHOT_LIMITS.maxReviews,
200
+ tracker,
201
+ sortField: 'author',
202
+ normalize: (review) => {
203
+ if (!review || typeof review !== 'object' || Array.isArray(review)
204
+ || typeof review.state !== 'string' || !review.state) {
205
+ throw new TypeError('malformed review');
206
+ }
207
+ const actor = normalizeActor(review);
208
+ return {
209
+ ...actor,
210
+ state: evidenceText(review.state),
211
+ commitOid: review.commitOid == null ? null : evidenceText(review.commitOid),
212
+ submittedAt: review.submittedAt == null ? null : evidenceText(review.submittedAt),
213
+ };
214
+ },
215
+ });
216
+ }
217
+
218
+ function normalizeComments(comments, tracker) {
219
+ return boundedNormalize(comments, {
220
+ surface: 'comments',
221
+ limit: SNAPSHOT_LIMITS.maxComments,
222
+ tracker,
223
+ sortField: 'id',
224
+ normalize: (comment) => {
225
+ if (!comment || typeof comment !== 'object' || Array.isArray(comment)
226
+ || typeof comment.id !== 'string' || !comment.id) {
227
+ throw new TypeError('malformed comment');
228
+ }
229
+ return { id: evidenceText(comment.id), ...normalizeActor(comment) };
230
+ },
231
+ });
67
232
  }
68
233
 
69
234
  /**
@@ -76,6 +241,30 @@ function conflictBool(conflicts) {
76
241
  return null;
77
242
  }
78
243
 
244
+ function objectOrEmpty(value) {
245
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
246
+ }
247
+
248
+ function createSnapshotTracker(source, state) {
249
+ const tracker = createEvidenceTracker(Object.hasOwn(source, 'degraded') ? source.degraded : []);
250
+ if (state !== source.state) tracker.add('state', 'PR state evidence is missing or malformed');
251
+ if (Object.hasOwn(source, 'unreadable')) trackUnreadable(source.unreadable, tracker);
252
+ return tracker;
253
+ }
254
+
255
+ function normalizeBehind(source, tracker) {
256
+ const behind = Number.isSafeInteger(source.behind) && source.behind >= 0 ? source.behind : 0;
257
+ if (behind !== source.behind && source.behind !== undefined) {
258
+ tracker.add('branch', 'branch divergence evidence is malformed');
259
+ }
260
+ return behind;
261
+ }
262
+
263
+ function deriveThreadState(tracker, openThreadCount) {
264
+ if (tracker.incompleteSurfaces.has('threads')) return 'INCOMPLETE';
265
+ return openThreadCount > 0 ? 'OPEN' : 'ZERO';
266
+ }
267
+
79
268
  /**
80
269
  * Normalize a raw `gatherPrSnapshot` result into the monitor diff subject.
81
270
  *
@@ -84,21 +273,38 @@ function conflictBool(conflicts) {
84
273
  * @returns {object}
85
274
  */
86
275
  function normalizeSnapshot(snap, ctx) {
87
- const state = snap.state || {};
276
+ const source = objectOrEmpty(snap);
277
+ const state = objectOrEmpty(source.state);
278
+ const tracker = createSnapshotTracker(source, state);
279
+ const checks = normalizeChecks(state.checks, tracker);
280
+ const threads = normalizeThreads(source.threads, tracker);
281
+ const reviews = normalizeReviews(source.reviews, tracker);
282
+ const comments = normalizeComments(source.issueComments, tracker);
283
+ const behind = normalizeBehind(source, tracker);
284
+ const openThreadCount = threads.filter((thread) => thread.actionable).length;
285
+ const evidenceStatus = tracker.degraded.length === 0 ? 'COMPLETE' : 'INCOMPLETE';
286
+ const threadState = deriveThreadState(tracker, openThreadCount);
287
+ const degraded = [...tracker.degraded].sort(compareBy('surface'));
88
288
  return {
89
- repo: ctx.repo,
90
- pr: String(ctx.pr),
91
- headSha: state.headSha || '',
289
+ repo: evidenceText(ctx?.repo),
290
+ pr: evidenceText(ctx?.pr),
291
+ headSha: evidenceText(state.headSha),
92
292
  prState: String(state.state || 'OPEN').toUpperCase(),
93
- draft: Boolean(snap.draft),
94
- verdict: { state: snap.verdict || 'UNKNOWN', reason: null },
95
- checks: normalizeChecks(state.checks),
96
- threads: normalizeThreads(snap.threads),
97
- reviews: normalizeReviews(snap.reviews),
98
- comments: normalizeComments(snap.issueComments),
99
- behind: snap.behind || 0,
100
- conflicts: conflictBool(snap.conflicts),
101
- degraded: normalizeDegraded(snap.degraded),
293
+ draft: Boolean(source.draft),
294
+ verdict: {
295
+ state: evidenceStatus === 'COMPLETE' ? (source.verdict || 'UNKNOWN') : 'UNKNOWN',
296
+ reason: evidenceStatus === 'COMPLETE' ? null : 'review evidence incomplete',
297
+ },
298
+ evidenceStatus,
299
+ threadState,
300
+ openThreadCount,
301
+ checks,
302
+ threads,
303
+ reviews,
304
+ comments,
305
+ behind,
306
+ conflicts: conflictBool(source.conflicts),
307
+ degraded,
102
308
  };
103
309
  }
104
310
 
@@ -117,6 +323,7 @@ async function gatherMonitorSnapshot(ctx) {
117
323
  }
118
324
 
119
325
  module.exports = {
326
+ SNAPSHOT_LIMITS,
120
327
  gatherMonitorSnapshot,
121
328
  normalizeSnapshot,
122
329
  classifyCheck,
@@ -35,21 +35,35 @@ function sanitize(part) {
35
35
  return String(part || '').replace(/[^A-Za-z0-9._-]+/g, '-');
36
36
  }
37
37
 
38
+ /**
39
+ * Resolve the one journal authority shared by every worktree. Standard repos
40
+ * use `<main-root>/.forge/pr-monitor`; a nonstandard common dir centralizes
41
+ * beneath that shared metadata directory. No common dir preserves legacy use.
42
+ */
43
+ function resolveJournalRoot({ root, gitCommonDir }) {
44
+ const fallbackRoot = path.resolve(root);
45
+ if (!gitCommonDir) return path.join(fallbackRoot, '.forge', 'pr-monitor');
46
+ const commonDir = path.resolve(gitCommonDir);
47
+ if (path.basename(commonDir).toLowerCase() === '.git') {
48
+ return path.join(path.dirname(commonDir), '.forge', 'pr-monitor');
49
+ }
50
+ return path.join(commonDir, 'forge', 'pr-monitor');
51
+ }
52
+
38
53
  /**
39
54
  * Resolve (and create) the per-PR journal directory.
40
55
  *
41
56
  * @param {{ root: string, repo: string, pr: string|number }} ctx
42
57
  * @returns {string} absolute directory path
43
58
  */
44
- function journalDir({ root, repo, pr }) {
45
- const dir = path.join(root, '.forge', 'pr-monitor', `${sanitize(repo)}-${sanitize(pr)}`);
59
+ function journalDir({ root, gitCommonDir, repo, pr }) {
60
+ const dir = path.join(resolveJournalRoot({ root, gitCommonDir }), `${sanitize(repo)}-${sanitize(pr)}`);
46
61
  fs.mkdirSync(dir, { recursive: true });
47
62
  return dir;
48
63
  }
49
64
 
50
65
  function journalPath(dir) { return path.join(dir, 'events.ndjson'); }
51
66
  function snapshotPath(dir) { return path.join(dir, 'snapshot.json'); }
52
- function pidPath(dir) { return path.join(dir, 'watch.pid'); }
53
67
  function lockPath(dir) { return path.join(dir, 'journal.lock'); }
54
68
 
55
69
  /**
@@ -239,49 +253,18 @@ async function withJournalLock(dir, fn, opts = {}) {
239
253
  }
240
254
  }
241
255
 
242
- /** Write the watcher pid file. */
243
- function writePid(dir, pid) {
244
- fs.writeFileSync(pidPath(dir), String(pid == null ? process.pid : pid));
245
- }
246
-
247
- /** Read the watcher pid (number) or null. */
248
- function readPid(dir) {
249
- const file = pidPath(dir);
250
- if (!fs.existsSync(file)) return null;
251
- const n = Number.parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
252
- return Number.isFinite(n) ? n : null;
253
- }
254
-
255
- /** Remove the watcher pid file (best-effort). */
256
- function removePid(dir) {
257
- try { fs.unlinkSync(pidPath(dir)); } catch { /* already gone */ }
258
- }
259
-
260
256
  /** Is `pid` a live process? `process.kill(pid, 0)` probes without signaling. */
261
257
  function pidAlive(pid) {
262
258
  if (!pid) return false;
263
259
  try { process.kill(pid, 0); return true; } catch (err) { return err?.code === 'EPERM'; }
264
260
  }
265
261
 
266
- /**
267
- * A watcher is "running" when the pid file names a live process that is NOT us.
268
- * Stale pid files (dead process) return false so a poll falls back to an inline
269
- * pass.
270
- *
271
- * @param {string} dir
272
- * @returns {boolean}
273
- */
274
- function watcherRunning(dir) {
275
- const pid = readPid(dir);
276
- return pidAlive(pid) && pid !== process.pid;
277
- }
278
-
279
262
  module.exports = {
280
263
  sanitize,
264
+ resolveJournalRoot,
281
265
  journalDir,
282
266
  journalPath,
283
267
  snapshotPath,
284
- pidPath,
285
268
  lockPath,
286
269
  withJournalLock,
287
270
  readAllEvents,
@@ -291,9 +274,5 @@ module.exports = {
291
274
  appendEvents,
292
275
  readSnapshot,
293
276
  writeSnapshot,
294
- writePid,
295
- readPid,
296
- removePid,
297
277
  pidAlive,
298
- watcherRunning,
299
278
  };
@@ -11,6 +11,10 @@
11
11
  const { finalizeEvent, eventIdentity, fingerprint } = require('./events');
12
12
  const { diffSnapshots } = require('./differ');
13
13
  const journal = require('./journal');
14
+ const { launchGateComplete } = require('./watch-lifecycle');
15
+
16
+ const MAX_EVENT_DELTAS = 128;
17
+ const ACTIVE_OWNER_PHASES = new Set(['starting', 'running', 'stop_requested', 'terminal_pending']);
14
18
 
15
19
  /** ISO-8601 timestamp; injectable via ctx.now for deterministic tests. */
16
20
  function defaultNow() { return new Date().toISOString(); }
@@ -37,6 +41,35 @@ function finalizeRecords(candidates, { baseSeq, ts, snapshot }) {
37
41
  }));
38
42
  }
39
43
 
44
+ function createCompatibilityAppender(dir) {
45
+ const deliveredIds = new Set(journal.readAllEvents(dir)
46
+ .map(event => event.memoryEventId).filter(Boolean));
47
+ const snapshotRecord = journal.readSnapshot(dir);
48
+ const appliedSeq = snapshotRecord?.appliedSeq || 0;
49
+ const seen = journal.seenIdentities(dir, appliedSeq);
50
+ const journalCursor = journal.lastSeq(dir);
51
+ let nextSeq = journalCursor;
52
+ return {
53
+ journalCursor,
54
+ append(record, memoryEventId) {
55
+ const identity = eventIdentity(record);
56
+ if ((memoryEventId && deliveredIds.has(memoryEventId)) || seen.has(identity)) return;
57
+ nextSeq += 1;
58
+ journal.appendEvents(dir, [{ ...record, seq: nextSeq, ...(memoryEventId ? { memoryEventId } : {}) }]);
59
+ if (memoryEventId) deliveredIds.add(memoryEventId);
60
+ seen.add(identity);
61
+ },
62
+ finish(snapshot) {
63
+ if (!snapshot) return;
64
+ journal.writeSnapshot(dir, {
65
+ snapshot,
66
+ fingerprint: fingerprint(snapshot),
67
+ appliedSeq: nextSeq,
68
+ });
69
+ },
70
+ };
71
+ }
72
+
40
73
  /**
41
74
  * The read→diff→dedup→sequence→append→snapshot critical section of a single
42
75
  * pass. Runs ONLY while holding the journal lock (see `runMonitorPass`), so it
@@ -105,12 +138,40 @@ async function runMonitorPassLocked(ctx) {
105
138
  * @returns {Promise<{ events: object[], changed: boolean, fingerprint: string }>}
106
139
  */
107
140
  async function runMonitorPass(ctx) {
108
- return journal.withJournalLock(ctx.dir, () => runMonitorPassLocked(ctx), ctx.lockOpts);
141
+ if (ctx.store) {
142
+ const { runFlowMonitorPass } = require('./flow-monitor');
143
+ return journal.withJournalLock(ctx.dir, async () => {
144
+ let latestSnapshot;
145
+ const compatibility = createCompatibilityAppender(ctx.dir);
146
+ const deliverLegacy = ctx.deliverLegacy || (async (record, memoryEventId) => {
147
+ compatibility.append(record, memoryEventId);
148
+ });
149
+ const result = await runFlowMonitorPass({
150
+ ...ctx,
151
+ gather: async () => {
152
+ const snapshot = await ctx.gather();
153
+ latestSnapshot = snapshot;
154
+ return snapshot;
155
+ },
156
+ deliverLegacy,
157
+ });
158
+ if (!ctx.deliverLegacy) compatibility.finish(latestSnapshot);
159
+ return { ...result, journalCursor: compatibility.journalCursor };
160
+ }, ctx.lockOpts);
161
+ }
162
+ return journal.withJournalLock(ctx.dir, async () => {
163
+ const journalCursor = journal.lastSeq(ctx.dir);
164
+ const result = await runMonitorPassLocked(ctx);
165
+ return { ...result, journalCursor };
166
+ }, ctx.lockOpts);
109
167
  }
110
168
 
111
169
  /**
112
- * `forge shepherd events <pr> --since <seq>` core: run one inline pass when no
113
- * watcher owns this PR, then return every journaled event with `seq > since`.
170
+ * `forge shepherd events <pr> --since <seq>` core: run one inline pass when the
171
+ * Kernel owner row proves no watcher owns this PR AND the shared migration gate
172
+ * proves EXACTLY complete, then return every journaled
173
+ * event with `seq > since`. PID files are compatibility evidence only and are
174
+ * never consulted for this decision.
114
175
  * This is the agent-agnostic PULL surface — stdout NDJSON, nothing under .claude.
115
176
  *
116
177
  * @param {object} ctx
@@ -118,22 +179,67 @@ async function runMonitorPass(ctx) {
118
179
  * @param {() => Promise<object>} ctx.gather
119
180
  * @param {number} [ctx.since]
120
181
  * @param {() => string} [ctx.now]
121
- * @param {(dir: string) => boolean} [ctx.watcherRunning]
122
182
  * @param {(records: object[]) => Promise<void>|void} [ctx.enrich]
123
- * @returns {Promise<{ events: object[], since: number, ranPass: boolean }>}
183
+ * @returns {Promise<{ events: object[], since: number, ranPass: boolean,
184
+ * migrationGateIncomplete?: boolean }>}
124
185
  */
125
186
  async function pollEvents(ctx) {
126
187
  const { dir, gather, since = 0 } = ctx;
127
- const isRunning = (ctx.watcherRunning || journal.watcherRunning)(dir);
188
+ const checkOwner = typeof ctx.isOwnerRunning === 'function'
189
+ ? ctx.isOwnerRunning
190
+ : (ctx.owner && typeof ctx.owner.readOwner === 'function' && ctx.repo && Number(ctx.pr) > 0
191
+ ? async () => {
192
+ const result = await ctx.owner.readOwner(
193
+ { repo: ctx.repo, pr: Number(ctx.pr) },
194
+ ctx.ownerOptions || {},
195
+ );
196
+ if (!result?.ok) return { ok: false, reason: result?.reason || 'authority_unavailable' };
197
+ const record = result.record;
198
+ return record != null && (ACTIVE_OWNER_PHASES.has(record.phase) || record.phase === 'blocked');
199
+ }
200
+ : null);
201
+ let authorityUnavailable = typeof checkOwner !== 'function';
202
+ let isRunning = true;
203
+ if (!authorityUnavailable) {
204
+ try {
205
+ const result = await checkOwner();
206
+ authorityUnavailable = result && typeof result === 'object' && result.ok === false;
207
+ isRunning = authorityUnavailable ? true : result === true || result?.running === true;
208
+ } catch {
209
+ authorityUnavailable = true;
210
+ isRunning = true;
211
+ }
212
+ }
128
213
  let ranPass = false;
129
- if (!isRunning) {
130
- await runMonitorPass({ dir, gather, now: ctx.now, enrich: ctx.enrich });
131
- ranPass = true;
214
+ let pass = null;
215
+ // An idle owner row is necessary but NOT sufficient: until the lease/owner
216
+ // migration gate is EXACTLY `complete`, legacy watchers may still be running
217
+ // outside Kernel authority, so an inline pass could duplicate one. Reuse the
218
+ // one shared predicate every launcher uses (`launchGateComplete`) rather than
219
+ // re-deriving "not blocked" here — partial, unknown and unreadable all fail closed.
220
+ let migrationGateIncomplete = false;
221
+ if (!authorityUnavailable && !isRunning) {
222
+ if (await launchGateComplete(ctx.owner || undefined, ctx.ownerOptions || {})) {
223
+ pass = await runMonitorPass({ ...ctx, dir, gather, now: ctx.now, enrich: ctx.enrich });
224
+ ranPass = true;
225
+ } else {
226
+ migrationGateIncomplete = true;
227
+ }
132
228
  }
229
+ const readEventsSince = ctx.readEventsSince || journal.readEventsSince;
230
+ const available = readEventsSince(dir, since);
231
+ const overflow = available.length > MAX_EVENT_DELTAS;
232
+ const events = overflow ? available.slice(-MAX_EVENT_DELTAS) : available;
133
233
  return {
134
- events: journal.readEventsSince(dir, since),
234
+ events,
135
235
  since: Number(since) || 0,
136
236
  ranPass,
237
+ ...(authorityUnavailable ? { authorityUnavailable: true } : {}),
238
+ ...(migrationGateIncomplete ? { migrationGateIncomplete: true } : {}),
239
+ overflow,
240
+ receiptIds: Array.isArray(pass?.receiptIds) ? pass.receiptIds.slice(0, MAX_EVENT_DELTAS) : [],
241
+ continuationPending: pass?.continuationPending === true,
242
+ ...(pass?.terminalReceiptId ? { terminalReceiptId: pass.terminalReceiptId } : {}),
137
243
  };
138
244
  }
139
245
 
@@ -143,4 +249,5 @@ module.exports = {
143
249
  dedupeAgainstJournal,
144
250
  finalizeRecords,
145
251
  defaultNow,
252
+ MAX_EVENT_DELTAS,
146
253
  };