forge-workflow 0.1.0-beta.5 → 0.1.0-beta.7

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 (268) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +48 -0
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/bin/forge.js +12 -1
  6. package/docs/guides/MIGRATION.md +3 -3
  7. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  8. package/docs/reference/INSTALL.md +4 -0
  9. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  10. package/docs/reference/RELEASE.md +4 -4
  11. package/docs/reference/github-accounts.md +134 -0
  12. package/docs/reference/shepherd.md +63 -13
  13. package/lib/adapters/pr-state-adapter.js +15 -2
  14. package/lib/base-remote.js +138 -0
  15. package/lib/beta5-compatibility-evidence.js +1093 -0
  16. package/lib/bun-lockfile-proof.js +413 -0
  17. package/lib/bun-workflow-pins.js +461 -0
  18. package/lib/capabilities/index.js +9 -0
  19. package/lib/capabilities/model.js +141 -0
  20. package/lib/capabilities/probes.js +347 -0
  21. package/lib/codex-skills.js +2 -2
  22. package/lib/commands/_manifest.js +1 -0
  23. package/lib/commands/_registry.js +48 -18
  24. package/lib/commands/clean.js +57 -1
  25. package/lib/commands/doctor.js +37 -6
  26. package/lib/commands/gate.js +197 -27
  27. package/lib/commands/github.js +215 -0
  28. package/lib/commands/hooks.js +54 -6
  29. package/lib/commands/memory.js +66 -2
  30. package/lib/commands/merge.js +720 -73
  31. package/lib/commands/plan.js +33 -2
  32. package/lib/commands/pr.js +2 -0
  33. package/lib/commands/preflight.js +10 -2
  34. package/lib/commands/push.js +120 -9
  35. package/lib/commands/recall.js +95 -61
  36. package/lib/commands/release.js +23 -2
  37. package/lib/commands/remember.js +28 -4
  38. package/lib/commands/serve.js +26 -9
  39. package/lib/commands/setup.js +132 -4
  40. package/lib/commands/shepherd.js +578 -72
  41. package/lib/commands/ship.js +15 -69
  42. package/lib/commands/skill.js +8 -0
  43. package/lib/commands/team.js +47 -8
  44. package/lib/commands/test.js +163 -4
  45. package/lib/commands/validate.js +78 -22
  46. package/lib/commands/worktree.js +155 -19
  47. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  48. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  49. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  50. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  51. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  52. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  53. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  54. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  55. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  56. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  57. package/lib/forge-issues.js +78 -0
  58. package/lib/gate-events.js +98 -10
  59. package/lib/github-context.js +308 -0
  60. package/lib/global-flags.js +1 -0
  61. package/lib/hook-renderer.js +29 -1
  62. package/lib/issue-render.js +19 -0
  63. package/lib/kernel/broker.js +723 -31
  64. package/lib/kernel/claim-reconciler.js +238 -0
  65. package/lib/kernel/lease-enforcer.js +9 -4
  66. package/lib/kernel/legacy-claim-repair.js +442 -0
  67. package/lib/kernel/live-claim-projection.js +26 -0
  68. package/lib/kernel/migrations.js +118 -3
  69. package/lib/kernel/readiness-model.js +184 -12
  70. package/lib/kernel/schema.js +49 -1
  71. package/lib/kernel/sqlite-driver.js +3322 -183
  72. package/lib/kernel/taxonomy-validator.js +4 -1
  73. package/lib/kernel/windows-private-acl.js +239 -0
  74. package/lib/memory/hygiene.js +191 -0
  75. package/lib/memory/router.js +94 -27
  76. package/lib/memory/usage-evidence.js +4 -0
  77. package/lib/memory-digest.js +59 -0
  78. package/lib/merge-rules.js +135 -17
  79. package/lib/npm-publish-workflow.js +233 -40
  80. package/lib/package-root.js +2 -0
  81. package/lib/pr-monitor/auto-actions.js +169 -28
  82. package/lib/pr-monitor/differ.js +110 -4
  83. package/lib/pr-monitor/events.js +0 -0
  84. package/lib/pr-monitor/flow-monitor.js +1424 -0
  85. package/lib/pr-monitor/gather.js +251 -44
  86. package/lib/pr-monitor/journal.js +0 -37
  87. package/lib/pr-monitor/monitor.js +117 -10
  88. package/lib/pr-monitor/process-identity.js +117 -0
  89. package/lib/pr-monitor/reconcile-executor.js +1101 -625
  90. package/lib/pr-monitor/reconcile.js +0 -0
  91. package/lib/pr-monitor/render-summary.js +121 -24
  92. package/lib/pr-monitor/review-preflight.js +269 -0
  93. package/lib/pr-monitor/shepherd-lease.js +28 -19
  94. package/lib/pr-monitor/verdict.js +438 -0
  95. package/lib/pr-monitor/watch-lifecycle.js +144 -38
  96. package/lib/pr-monitor/watch-owner.js +1414 -0
  97. package/lib/pr-monitor/watch.js +129 -58
  98. package/lib/pr-shepherd.js +17 -3
  99. package/lib/project-memory.js +145 -3
  100. package/lib/protected-state-authority.js +799 -4
  101. package/lib/protected-state-surfaces.js +181 -3
  102. package/lib/release-readiness.js +2 -3
  103. package/lib/review-adapter.js +65 -0
  104. package/lib/skills-sync.js +65 -32
  105. package/lib/validation/risk-manifest.js +339 -0
  106. package/lib/validation-receipt.js +190 -0
  107. package/lib/workflow/enforce-stage.js +44 -0
  108. package/lib/workflow/plan-authority.js +225 -0
  109. package/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  110. package/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  111. package/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  112. package/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  113. package/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  114. package/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  115. package/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  116. package/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  117. package/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  118. package/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  119. package/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  120. package/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  121. package/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  122. package/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  123. package/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  124. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  125. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  126. package/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  127. package/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  128. package/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  129. package/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  130. package/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  131. package/node_modules/@forge/contracts/index.js +32 -0
  132. package/node_modules/@forge/contracts/package.json +35 -0
  133. package/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  134. package/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  135. package/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  136. package/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  137. package/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  138. package/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  139. package/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  140. package/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  141. package/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  142. package/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  143. package/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  144. package/node_modules/@forge/contracts/src/baseline.js +23 -0
  145. package/node_modules/@forge/contracts/src/canonical.js +151 -0
  146. package/node_modules/@forge/contracts/src/definitions.js +176 -0
  147. package/node_modules/@forge/contracts/src/identity.js +42 -0
  148. package/node_modules/@forge/contracts/src/schema.js +72 -0
  149. package/node_modules/@forge/contracts/src/validate.js +305 -0
  150. package/node_modules/@forge/flow/index.js +114 -0
  151. package/node_modules/@forge/flow/package.json +34 -0
  152. package/node_modules/@forge/flow/src/bounded-loop.js +415 -0
  153. package/node_modules/@forge/flow/src/efficiency-supervisor.js +89 -0
  154. package/node_modules/@forge/flow/src/executor.js +279 -0
  155. package/node_modules/@forge/flow/src/monitor-durability.js +419 -0
  156. package/node_modules/@forge/flow/src/monitor-runtime.js +460 -0
  157. package/node_modules/@forge/flow/src/process-lifecycle.js +469 -0
  158. package/node_modules/@forge/flow/src/skill-runtime.js +343 -0
  159. package/node_modules/@forge/memory/index.js +331 -0
  160. package/node_modules/@forge/memory/package.json +34 -0
  161. package/node_modules/@forge/memory/src/authority-provider.js +67 -0
  162. package/node_modules/@forge/memory/src/backend-registry.js +166 -0
  163. package/node_modules/@forge/memory/src/feedback-intake.js +243 -0
  164. package/node_modules/@forge/memory/src/pr-lifecycle-authority.js +946 -0
  165. package/node_modules/@forge/memory/src/usage-evidence.js +205 -0
  166. package/package.json +16 -4
  167. package/packages/flow/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  168. package/packages/flow/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  169. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  170. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  171. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  172. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  173. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  174. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  175. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  176. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  177. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  178. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  179. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  180. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  181. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  182. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  183. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  184. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  185. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  186. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  187. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  188. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  189. package/packages/flow/node_modules/@forge/contracts/index.js +32 -0
  190. package/packages/flow/node_modules/@forge/contracts/package.json +35 -0
  191. package/packages/flow/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  192. package/packages/flow/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  193. package/packages/flow/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  194. package/packages/flow/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  195. package/packages/flow/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  196. package/packages/flow/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  197. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  198. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  199. package/packages/flow/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  200. package/packages/flow/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  201. package/packages/flow/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  202. package/packages/flow/node_modules/@forge/contracts/src/baseline.js +23 -0
  203. package/packages/flow/node_modules/@forge/contracts/src/canonical.js +151 -0
  204. package/packages/flow/node_modules/@forge/contracts/src/definitions.js +176 -0
  205. package/packages/flow/node_modules/@forge/contracts/src/identity.js +42 -0
  206. package/packages/flow/node_modules/@forge/contracts/src/schema.js +72 -0
  207. package/packages/flow/node_modules/@forge/contracts/src/validate.js +305 -0
  208. package/packages/memory/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  209. package/packages/memory/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  210. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  211. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  212. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  213. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  214. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  215. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  216. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  217. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  218. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  219. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  220. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  221. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  222. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  223. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  224. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  225. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  226. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  227. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  228. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  229. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  230. package/packages/memory/node_modules/@forge/contracts/index.js +32 -0
  231. package/packages/memory/node_modules/@forge/contracts/package.json +35 -0
  232. package/packages/memory/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  233. package/packages/memory/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  234. package/packages/memory/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  235. package/packages/memory/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  236. package/packages/memory/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  237. package/packages/memory/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  238. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  239. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  240. package/packages/memory/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  241. package/packages/memory/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  242. package/packages/memory/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  243. package/packages/memory/node_modules/@forge/contracts/src/baseline.js +23 -0
  244. package/packages/memory/node_modules/@forge/contracts/src/canonical.js +151 -0
  245. package/packages/memory/node_modules/@forge/contracts/src/definitions.js +176 -0
  246. package/packages/memory/node_modules/@forge/contracts/src/identity.js +42 -0
  247. package/packages/memory/node_modules/@forge/contracts/src/schema.js +72 -0
  248. package/packages/memory/node_modules/@forge/contracts/src/validate.js +305 -0
  249. package/scripts/commitlint.js +13 -15
  250. package/scripts/generate-risk-manifest.js +91 -0
  251. package/scripts/github-context-bridge.sh +10 -0
  252. package/scripts/legacy-claim-repair.js +145 -0
  253. package/scripts/lib/behavioral-eval-runtime.js +3 -2
  254. package/scripts/process-tree.js +14 -2
  255. package/scripts/protected-state-check.js +440 -17
  256. package/scripts/sync-agent-skills.js +333 -34
  257. package/scripts/test-full-suite.js +704 -18
  258. package/scripts/test-profile.js +13 -3
  259. package/scripts/test.js +96 -15
  260. package/skills/coverage.json +1 -0
  261. package/skills/review/SKILL.md +2 -0
  262. package/skills/review/evals/scorecard.json +2 -2
  263. package/skills/setup/SKILL.md +18 -0
  264. package/skills/setup/evals/scorecard.json +3 -3
  265. package/skills/shepherd/SKILL.md +19 -2
  266. package/skills/shepherd/evals/scorecard.json +3 -3
  267. package/skills/validate/SKILL.md +3 -0
  268. package/skills/validate/evals/scorecard.json +1 -1
@@ -15,29 +15,23 @@
15
15
  * and only pushed if the next tick did not recover/green it — the JOURNAL still
16
16
  * records everything (authority), so `events --since` replay stays complete.
17
17
  *
18
- * Every external effect is injectable (emit/sleep/rng/now/gather/watcherRunning/
19
- * writePid/removePid, plus `maxPasses`/`signal`) so tests exercise the loop with a
20
- * fake clock and fake gh, never touching live GitHub or waiting 60s.
18
+ * Every external effect is injectable (emit/sleep/rng/now/gather/owner authority,
19
+ * plus `maxPasses`/`signal`) so tests exercise the loop with a fake clock and fake
20
+ * provider, never touching live GitHub or waiting 60s.
21
21
  *
22
22
  * @module pr-monitor/watch
23
23
  */
24
24
 
25
25
  const { runMonitorPass } = require('./monitor');
26
26
  const journal = require('./journal');
27
+ const watchOwner = require('./watch-owner');
27
28
  const { EVENT_TYPES: T } = require('./events');
28
29
 
29
30
  const DEFAULT_INTERVAL_MS = 60000;
31
+ const DEFAULT_OWNER_HEARTBEAT_MS = 60000;
30
32
  const JITTER_RATIO = 0.2;
31
33
  const TERMINAL_TYPES = new Set([T.PR_MERGED, T.PR_CLOSED]);
32
34
 
33
- // In-process ownership of the watcher slot, keyed by journal dir. The journal
34
- // PID file serializes claims ACROSS processes, but it cannot make claims within
35
- // ONE process idempotent: after writePid, `watcherRunning(dir)` returns false
36
- // because the PID equals process.pid, so a second same-process watchLoop() would
37
- // also claim, run in parallel, and (on either exit) remove the shared PID while
38
- // the other is still active. This Set closes that gap for the loop lifetime.
39
- const ACTIVE_DIRS = new Set();
40
-
41
35
  /** Default push sink: one NDJSON line per event to stdout. */
42
36
  function defaultEmit(event) {
43
37
  process.stdout.write(`${JSON.stringify(event)}\n`);
@@ -78,30 +72,6 @@ function isAborted(signal) {
78
72
  return Boolean(signal && signal.aborted);
79
73
  }
80
74
 
81
- /**
82
- * Atomically claim the watcher slot for this PR. The `watcherRunning` check and
83
- * the `writePid` write run TOGETHER inside the cross-process journal lock (so two
84
- * starts in DIFFERENT processes can never both pass), AND the in-process
85
- * `ACTIVE_DIRS` guard rejects a SECOND concurrent `watchLoop()` in the SAME
86
- * process (which the PID file alone misses, since our own PID reads as "not
87
- * running"). Returns true only when the slot was newly claimed; the caller must
88
- * release with `releaseClaim(dir)` on loop exit. The (possibly injected)
89
- * `watcherRunning`/`writePid` primitives are honored, so test injection works.
90
- */
91
- function defaultClaim(dir, { watcherRunning, writePid, lockOpts }) {
92
- return journal.withJournalLock(dir, () => {
93
- if (ACTIVE_DIRS.has(dir) || watcherRunning(dir)) return false;
94
- writePid(dir);
95
- ACTIVE_DIRS.add(dir);
96
- return true;
97
- }, lockOpts);
98
- }
99
-
100
- /** Release the in-process watcher claim for `dir` (idempotent). */
101
- function releaseClaim(dir) {
102
- ACTIVE_DIRS.delete(dir);
103
- }
104
-
105
75
  /**
106
76
  * Split a pass's events into: held-candidate failures (by check name), terminal
107
77
  * events (pr.merged/closed), and everything else (emitted immediately).
@@ -144,6 +114,20 @@ function confirmHeld(pending, contra, emit) {
144
114
  }
145
115
  }
146
116
 
117
+ function emitTerminalPass(state, events, emit) {
118
+ const { failures, terminal, others } = partition(events);
119
+ const contra = contradictions(events);
120
+ const heldNames = new Set(state.pending.keys());
121
+ confirmHeld(state.pending, contra, emit);
122
+ state.pending = new Map();
123
+ for (const event of others) {
124
+ if (event.type === T.CHECK_RECOVERED && heldNames.has(checkName(event))) continue;
125
+ emit(event);
126
+ }
127
+ for (const failure of failures.values()) emit(failure);
128
+ for (const event of terminal) emit(event);
129
+ }
130
+
147
131
  /**
148
132
  * Run ONE watch tick: a bounded monitor pass, then stream its events with the
149
133
  * 2-pass flap debounce applied. Mutates `state.pending` (the held failures).
@@ -151,10 +135,18 @@ function confirmHeld(pending, contra, emit) {
151
135
  * @returns {Promise<{ terminal: boolean }>} terminal=true → stop the loop.
152
136
  */
153
137
  async function runWatchPass(state, ctx, emit) {
154
- const { events } = await runMonitorPass({
155
- dir: ctx.dir, gather: ctx.gather, now: ctx.now, enrich: ctx.enrich, lockOpts: ctx.lockOpts,
138
+ const monitorPass = ctx.runMonitorPass || runMonitorPass;
139
+ const { events: rawEvents, terminalReceiptId, journalCursor } = await monitorPass({
140
+ ...ctx, dir: ctx.dir, gather: ctx.gather, now: ctx.now, enrich: ctx.enrich, lockOpts: ctx.lockOpts,
156
141
  });
142
+ const events = ctx.dir && Number.isSafeInteger(journalCursor)
143
+ ? journal.readEventsSince(ctx.dir, journalCursor)
144
+ : rawEvents;
157
145
  const { failures, terminal, others } = partition(events);
146
+ if (terminalReceiptId) {
147
+ emitTerminalPass(state, events, emit);
148
+ return { terminal: true, terminalReceiptId };
149
+ }
158
150
  const contra = contradictions(events);
159
151
  const heldNames = new Set(state.pending.keys());
160
152
 
@@ -168,7 +160,7 @@ async function runWatchPass(state, ctx, emit) {
168
160
  if (terminal.length) {
169
161
  for (const failure of failures.values()) emit(failure);
170
162
  for (const t of terminal) emit(t);
171
- return { terminal: true };
163
+ return { terminal: false, receiptUnavailable: true };
172
164
  }
173
165
  state.pending = failures;
174
166
  return { terminal: false };
@@ -192,51 +184,130 @@ async function runWatchPass(state, ctx, emit) {
192
184
  * @returns {Promise<{ started: boolean, passes: number, stopped: boolean, reason?: string }>}
193
185
  */
194
186
  async function watchLoop(ctx) {
195
- const { dir } = ctx;
196
187
  const emit = ctx.emit || defaultEmit;
197
188
  const sleep = ctx.sleep || defaultSleep;
198
189
  const rng = ctx.rng || Math.random;
199
190
  const intervalMs = ctx.intervalMs ?? DEFAULT_INTERVAL_MS;
191
+ const ownerHeartbeatMs = ctx.ownerHeartbeatMs ?? DEFAULT_OWNER_HEARTBEAT_MS;
200
192
  const maxPasses = ctx.maxPasses ?? Infinity;
201
- const watcherRunning = ctx.watcherRunning || journal.watcherRunning;
202
- const writePid = ctx.writePid || journal.writePid;
203
- const removePid = ctx.removePid || journal.removePid;
204
- const claim = ctx.claim || (() => defaultClaim(dir, { watcherRunning, writePid, lockOpts: ctx.lockOpts }));
205
-
206
- // Atomic idempotent claim: a live OTHER watcher already owns this PR → no-op.
207
- // The check+write are serialized under the journal lock so concurrent starts
208
- // cannot both succeed (see defaultClaim).
209
- const claimed = await claim(dir);
210
- if (!claimed) {
211
- return { started: false, passes: 0, stopped: false, reason: 'watcher-already-running' };
193
+ const owner = ctx.owner || watchOwner;
194
+ const ownerOptions = ctx.ownerOptions || {};
195
+ const identity = { repo: String(ctx.repo || '').toLowerCase(), pr: Number(ctx.pr) };
196
+ const generation = ctx.generation;
197
+ const controllerPid = Number(ctx.controllerPid);
198
+ const pid = Number(ctx.pid ?? process.pid);
199
+
200
+ // Exact generation binding makes a duplicate or stale child a clean no-op.
201
+ if (!Number.isSafeInteger(identity.pr) || identity.pr <= 0 || !identity.repo
202
+ || !generation || !Number.isSafeInteger(controllerPid) || controllerPid <= 0
203
+ || !Number.isSafeInteger(pid) || pid <= 0 || typeof owner.bindRunning !== 'function') {
204
+ return { started: false, passes: 0, stopped: false, reason: 'authority-unavailable' };
205
+ }
206
+ const bound = await owner.bindRunning(identity, { generation, controllerPid, pid }, ownerOptions);
207
+ if (!bound?.ok) {
208
+ return { started: false, passes: 0, stopped: false, reason: bound?.reason || 'bind-failed' };
212
209
  }
213
210
 
214
211
  const state = { pending: new Map() };
215
212
  let passes = 0;
216
213
  let stopped = false;
214
+ let terminalPending = false;
215
+ let reason;
216
+ let heartbeatFailure;
217
+ let heartbeatInFlight = null;
218
+ const checkpointOwner = () => {
219
+ if (heartbeatInFlight) return heartbeatInFlight;
220
+ heartbeatInFlight = (async () => {
221
+ try {
222
+ const checkpoint = await owner.heartbeat(identity, { generation, pid }, ownerOptions);
223
+ if (!checkpoint?.ok) heartbeatFailure = checkpoint?.reason || 'owner-lost';
224
+ } catch {
225
+ heartbeatFailure = 'owner-lost';
226
+ } finally {
227
+ heartbeatInFlight = null;
228
+ }
229
+ })();
230
+ return heartbeatInFlight;
231
+ };
232
+ const scheduleHeartbeat = ctx.setInterval || setInterval;
233
+ const cancelHeartbeat = ctx.clearInterval || clearInterval;
234
+ const heartbeatTimer = scheduleHeartbeat(() => { void checkpointOwner(); }, ownerHeartbeatMs);
235
+ heartbeatTimer?.unref?.();
217
236
  try {
218
237
  while (passes < maxPasses && !isAborted(ctx.signal)) {
238
+ if (heartbeatFailure) {
239
+ reason = heartbeatFailure;
240
+ break;
241
+ }
242
+ const current = await owner.readOwner(identity, ownerOptions);
243
+ if (!current?.ok || current.record?.generation !== generation || current.record?.watcherPid !== pid) {
244
+ reason = current?.reason || 'owner-lost';
245
+ break;
246
+ }
247
+ if (current.record.phase === 'stop_requested') {
248
+ stopped = true;
249
+ reason = 'stop-requested';
250
+ break;
251
+ }
252
+ if (current.record.phase === 'terminal_pending') {
253
+ stopped = true;
254
+ terminalPending = true;
255
+ reason = 'terminal-pending';
256
+ break;
257
+ }
258
+ if (current.record.phase !== 'running') {
259
+ reason = 'owner-not-running';
260
+ break;
261
+ }
219
262
  const result = await runWatchPass(state, ctx, emit);
220
263
  passes += 1;
221
- if (result.terminal) { stopped = true; break; }
264
+ if (result.terminal) {
265
+ // Keep the exact running row for dead-owner receipt recovery if this write fails.
266
+ terminalPending = true;
267
+ const recorded = await owner.recordTerminal(identity, {
268
+ generation, pid, terminalReceiptId: result.terminalReceiptId,
269
+ }, ownerOptions);
270
+ if (recorded?.ok) {
271
+ stopped = true;
272
+ terminalPending = true;
273
+ reason = 'terminal-pending';
274
+ break;
275
+ }
276
+ reason = recorded?.reason || 'receipt-unavailable';
277
+ }
278
+ await checkpointOwner();
279
+ if (heartbeatFailure) {
280
+ reason = heartbeatFailure;
281
+ break;
282
+ }
222
283
  if (passes >= maxPasses || isAborted(ctx.signal)) break;
223
284
  await sleep(jitter(intervalMs, rng), ctx.signal);
224
285
  }
225
286
  } finally {
226
- // Release BOTH claims: the in-process slot (for a same-process restart) and
227
- // the cross-process PID file. Ordered so the in-process guard clears first.
228
- releaseClaim(dir);
229
- removePid(dir);
287
+ cancelHeartbeat(heartbeatTimer);
288
+ await heartbeatInFlight;
289
+ // Terminal ownership remains until a later dead-PID completion transaction;
290
+ // every other process exit releases its exact generation cooperatively.
291
+ if (!terminalPending) {
292
+ try {
293
+ const stopRequested = typeof owner.requestStop === 'function'
294
+ ? await owner.requestStop(identity, { generation, pid }, ownerOptions)
295
+ : { ok: true };
296
+ if (stopRequested?.ok) {
297
+ await owner.releaseNonterminal(identity, { generation, pid }, ownerOptions);
298
+ }
299
+ } catch {
300
+ // Preserve the owner row for a later dead-watcher recovery transaction.
301
+ }
302
+ }
230
303
  }
231
- return { started: true, passes, stopped };
304
+ return { started: true, passes, stopped, ...(reason ? { reason } : {}) };
232
305
  }
233
306
 
234
307
  module.exports = {
235
308
  watchLoop,
236
309
  runWatchPass,
237
310
  defaultSleep,
238
- defaultClaim,
239
- releaseClaim,
240
311
  jitter,
241
312
  partition,
242
313
  contradictions,
@@ -211,7 +211,7 @@ async function handleFailedRequired({
211
211
  * @returns {Promise<object>} decision envelope.
212
212
  */
213
213
  async function handleBehindBase({
214
- behind, autoRebase, cleanTree, adapter, baseRef, headUnchanged, actions,
214
+ behind, autoRebase, cleanTree, adapter, baseRef, expectedHead, headUnchanged, actions,
215
215
  }) {
216
216
  if (!autoRebase) {
217
217
  return result('ESCALATE', {
@@ -240,10 +240,22 @@ async function handleBehindBase({
240
240
  });
241
241
  }
242
242
  try {
243
- await adapter.rebaseOntoBase({ baseRef });
243
+ const rebaseResult = await adapter.rebaseOntoBase({ baseRef, expectedHead });
244
+ const verifiedHead = rebaseResult?.previousHead === expectedHead
245
+ && typeof rebaseResult?.headSha === 'string'
246
+ && /^[0-9a-f]{40}$/i.test(rebaseResult.headSha)
247
+ ? rebaseResult.headSha.toLowerCase()
248
+ : null;
249
+ if (!verifiedHead) {
250
+ return result('ESCALATE', {
251
+ actions,
252
+ reason: 'Rebase completed without a lease-bound post-action head; refusing convergence handoff.',
253
+ });
254
+ }
244
255
  actions.push({ type: 'rebase', baseRef });
245
256
  return result('PENDING', {
246
257
  actions,
258
+ expectedHead: verifiedHead,
247
259
  reason: 'Rebased onto base and force-pushed with lease. Awaiting CI on the next scheduled pass.',
248
260
  });
249
261
  } catch (error) {
@@ -478,7 +490,8 @@ async function runShepherdPass(ctx) {
478
490
  // never rebase — force autoRebase off so the branch-behind path only escalates. ---
479
491
  if (behind > 0) {
480
492
  return handleBehindBase({
481
- behind, autoRebase: dryRun ? false : autoRebase, cleanTree, adapter, baseRef, headUnchanged, actions,
493
+ behind, autoRebase: dryRun ? false : autoRebase, cleanTree, adapter, baseRef,
494
+ expectedHead: startSha, headUnchanged, actions,
482
495
  });
483
496
  }
484
497
 
@@ -500,6 +513,7 @@ async function runShepherdPass(ctx) {
500
513
  }
501
514
  return result('MERGE_READY', {
502
515
  actions,
516
+ expectedHead: startSha,
503
517
  reason: 'All required checks are green and the branch is up to date. Handing off to the human to merge in the GitHub UI — the shepherd never merges.',
504
518
  });
505
519
  }
@@ -2,10 +2,13 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const { createHash, randomUUID } = require('node:crypto');
6
+ const { types: { isProxy } } = require('node:util');
5
7
  const { resolveKernelDatabasePath } = require('./kernel/cli-broker-factory');
6
8
  const { resolveGitCommonDir } = require('./kernel/broker');
7
9
  const { createBuiltinSQLiteDriver } = require('./kernel/sqlite-driver');
8
10
  const { normalizeRecallHit } = require('./memory-recall');
11
+ const { createUsageEvidenceStore } = require('@forge/memory');
9
12
 
10
13
  // Project memory is a Forge read model persisted in the kernel store (kernel_memories),
11
14
  // written DIRECTLY rather than through the issue CAS/guarded-event path. The store seam
@@ -146,20 +149,28 @@ function list(projectRoot, options = {}) {
146
149
  // driver so recall never loads and re-sorts the whole table. `options.agents` (a
147
150
  // source_agent allow-list) scopes the read, e.g. to human `remember` notes only.
148
151
  function recent(projectRoot, limit, options = {}) {
149
- return resolveStore(projectRoot, options).recentMemories(limit, { agents: options.agents });
152
+ return resolveStore(projectRoot, options).recentMemories(limit, {
153
+ agents: options.agents,
154
+ kind: options.kind,
155
+ });
150
156
  }
151
157
 
152
158
  // Total stored memories (optionally scoped by `options.agents`) — paired with `recent` so
153
159
  // recall can report "showing N of TOTAL".
154
160
  function count(projectRoot, options = {}) {
155
- return resolveStore(projectRoot, options).countMemories({ agents: options.agents });
161
+ return resolveStore(projectRoot, options).countMemories({
162
+ agents: options.agents,
163
+ kind: options.kind,
164
+ });
156
165
  }
157
166
 
158
167
  // BM25 top-N recall over the FTS5 index (token-AND). Unlike `search` (the legacy LIKE
159
168
  // helper) this does not short-circuit an empty query — the driver falls back to recent so
160
169
  // recall stays capped either way.
161
170
  function searchRanked(projectRoot, query, limit, options = {}) {
162
- return resolveStore(projectRoot, options).searchMemoriesRanked(query, limit);
171
+ return resolveStore(projectRoot, options).searchMemoriesRanked(query, limit, {
172
+ kind: options.kind,
173
+ });
163
174
  }
164
175
 
165
176
  // Relevance-only BM25 recall that returns the raw bm25 `score` per entry, so a caller can
@@ -178,6 +189,131 @@ function searchRankedScored(projectRoot, query, limit, options = {}) {
178
189
  .map(hit => normalizeRecallHit(hit, projectId));
179
190
  }
180
191
 
192
+ function opaqueUsageIdentity(kind, value) {
193
+ return createHash('sha256').update(`${kind}\0${value}`).digest('hex');
194
+ }
195
+
196
+ function memoryUsageIdentity(key) {
197
+ return opaqueUsageIdentity('forge.memory.key.v1', key);
198
+ }
199
+
200
+ function ownDataValue(value, field) {
201
+ if (!value || typeof value !== 'object' || isProxy(value)) return undefined;
202
+ let descriptor;
203
+ try { descriptor = Object.getOwnPropertyDescriptor(value, field); } catch { return undefined; }
204
+ return descriptor && Object.hasOwn(descriptor, 'value') ? descriptor.value : undefined;
205
+ }
206
+
207
+ function usageResolutionOptions(options) {
208
+ const resolutionOptions = {};
209
+ for (const field of ['store', 'gitCommonDir', 'platform', 'realpath', 'databasePath']) {
210
+ const option = ownDataValue(options, field);
211
+ if (option !== undefined) resolutionOptions[field] = option;
212
+ }
213
+ return resolutionOptions;
214
+ }
215
+
216
+ function usageInvocationId(options) {
217
+ const value = ownDataValue(options, 'invocationId');
218
+ return typeof value === 'string' && value ? value : randomUUID();
219
+ }
220
+
221
+ function usageObservedAt(options) {
222
+ const invocationStartedAt = ownDataValue(options, 'invocationStartedAt');
223
+ if (typeof invocationStartedAt === 'string') return invocationStartedAt;
224
+ const now = ownDataValue(options, 'now');
225
+ if (typeof now === 'string') return now;
226
+ return new Date().toISOString();
227
+ }
228
+
229
+ function usageMemoryIdentities(notes) {
230
+ return notes
231
+ .map(note => ownDataValue(note, 'id'))
232
+ .filter(id => typeof id === 'string' && id.length > 0)
233
+ .map(memoryUsageIdentity);
234
+ }
235
+
236
+ function appendRecallUsage(store, identities, invocationId, scope, selectionDigest, observedAt) {
237
+ let appended = 0;
238
+ let failed = 0;
239
+ for (const memoryId of identities) {
240
+ try {
241
+ const result = store.append({
242
+ event_id: opaqueUsageIdentity('forge.memory.used.event.v1', `${invocationId}\0${memoryId}`),
243
+ memory_id: memoryId,
244
+ scope,
245
+ use_kind: 'cli-recall',
246
+ consumer_id: 'cli-recall',
247
+ selection_digest: selectionDigest,
248
+ observed_at: observedAt,
249
+ idempotency_key: opaqueUsageIdentity('forge.memory.used.idempotency.v1', `${invocationId}\0${memoryId}`),
250
+ });
251
+ if (result?.appended) appended += 1;
252
+ } catch {
253
+ // Evidence is advisory: a storage failure must never hide a useful recall.
254
+ failed += 1;
255
+ }
256
+ }
257
+ return { attempted: identities.length, appended, failed };
258
+ }
259
+
260
+ // Recall evidence deliberately accepts only the returned note identifiers. It hashes those
261
+ // identifiers immediately, so neither a user path, query, nor note content can reach storage.
262
+ function recordRecallUsage(projectRoot, selected, options = {}) {
263
+ const notes = Array.isArray(selected) ? selected : [];
264
+ if (notes.length === 0) return { attempted: 0, appended: 0, failed: 0 };
265
+ const usageStore = ownDataValue(options, 'usageStore');
266
+ const resolutionOptions = usageResolutionOptions(options);
267
+ const invocationId = usageInvocationId(options);
268
+ const observedAt = usageObservedAt(options);
269
+ const identities = usageMemoryIdentities(notes);
270
+ if (identities.length === 0) return { attempted: 0, appended: 0, failed: 0 };
271
+ let scope;
272
+ let selectionDigest;
273
+ let store;
274
+ try {
275
+ scope = opaqueUsageIdentity('forge.memory.scope.v1', resolveProjectId(projectRoot, resolutionOptions));
276
+ selectionDigest = opaqueUsageIdentity('forge.memory.selection.v1', identities.join('\0'));
277
+ store = createUsageEvidenceStore(usageStore || resolveStore(projectRoot, resolutionOptions));
278
+ } catch {
279
+ return { attempted: identities.length, appended: 0, failed: identities.length };
280
+ }
281
+ return appendRecallUsage(store, identities, invocationId, scope, selectionDigest, observedAt);
282
+ }
283
+
284
+ function usageProjection(projectRoot, key, options = {}) {
285
+ if (typeof key !== 'string' || !key) return null;
286
+ try {
287
+ const usageStore = ownDataValue(options, 'usageStore');
288
+ return createUsageEvidenceStore(usageStore || resolveStore(projectRoot, options))
289
+ .projection(opaqueUsageIdentity('forge.memory.key.v1', key));
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
295
+ function usageProjectionStatus(projectRoot, keys, options = {}) {
296
+ if (!Array.isArray(keys) || keys.length === 0) return { available: true, projections: new Map() };
297
+ const rawKeys = [...new Set(keys.filter(key => typeof key === 'string' && key))].slice(0, 200);
298
+ if (rawKeys.length === 0) return { available: true, projections: new Map() };
299
+ const usageStore = ownDataValue(options, 'usageStore');
300
+ const identities = rawKeys.map(memoryUsageIdentity);
301
+ try {
302
+ const rows = createUsageEvidenceStore(usageStore || resolveStore(projectRoot, options)).projections(identities);
303
+ const keyByIdentity = new Map(rawKeys.map((key, index) => [identities[index], key]));
304
+ return {
305
+ available: true,
306
+ projections: new Map(rows.map(row => [keyByIdentity.get(row.memory_id), row]).filter(([key]) => key)),
307
+ };
308
+ } catch {
309
+ return { available: false, projections: new Map() };
310
+ }
311
+ }
312
+
313
+ function usageProjections(projectRoot, keys, options = {}) {
314
+ return usageProjectionStatus(projectRoot, keys, options).projections;
315
+ }
316
+
181
317
  // Close and forget every cached default store. The CLI process is short-lived (the OS
182
318
  // closes the handle on exit), so this is mainly a lifecycle helper for long-lived hosts and
183
319
  // tests — it releases the SQLite/WAL handle before a temp dir is removed.
@@ -201,6 +337,12 @@ module.exports = {
201
337
  count,
202
338
  searchRanked,
203
339
  searchRankedScored,
340
+ recordRecallUsage,
341
+ usageProjection,
342
+ usageProjectionStatus,
343
+ usageProjections,
344
+ memoryUsageIdentity,
345
+ resolveStore,
204
346
  resolveProjectId,
205
347
  closeAll,
206
348
  };