forge-workflow 0.1.0-beta.3 → 0.1.0-beta.4

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 (122) hide show
  1. package/AGENTS.md +1 -1
  2. package/bin/forge-cmd.js +1 -1
  3. package/bin/forge.js +5 -0
  4. package/docs/reference/COMMANDS.md +3 -1
  5. package/docs/reference/shepherd.md +72 -1
  6. package/lib/adapters/greptile-review-adapter.js +1 -1
  7. package/lib/adapters/pr-state-adapter.js +103 -8
  8. package/lib/agents-config.js +5 -0
  9. package/lib/commands/_issue.js +31 -46
  10. package/lib/commands/_manifest.js +1 -1
  11. package/lib/commands/_resolve-command-opts.js +36 -29
  12. package/lib/commands/claim.js +2 -4
  13. package/lib/commands/hooks.js +155 -8
  14. package/lib/commands/plan.js +23 -115
  15. package/lib/commands/prime.js +8 -1
  16. package/lib/commands/release.js +1 -2
  17. package/lib/commands/serve.js +5 -2
  18. package/lib/commands/setup.js +0 -1
  19. package/lib/commands/shepherd.js +36 -3
  20. package/lib/commands/skill.js +275 -0
  21. package/lib/commands/status.js +37 -32
  22. package/lib/commands/test.js +32 -0
  23. package/lib/commands/worktree.js +27 -1
  24. package/lib/core/runtime-graph.js +88 -14
  25. package/lib/forge-issues.js +13 -464
  26. package/lib/harness-capability-matrix.js +2 -2
  27. package/lib/hook-renderer.js +54 -12
  28. package/lib/issue-backend.js +42 -3
  29. package/lib/kernel/broker.js +28 -0
  30. package/lib/kernel/migrations.js +30 -2
  31. package/lib/kernel/schema.js +35 -0
  32. package/lib/kernel/sqlite-driver.js +150 -0
  33. package/lib/memory-recall.js +151 -0
  34. package/lib/orientation.js +303 -6
  35. package/lib/pr-bundle.js +6 -2
  36. package/lib/pr-monitor/reconcile-executor.js +659 -0
  37. package/lib/pr-monitor/reconcile-tick.js +138 -0
  38. package/lib/pr-monitor/reconcile.js +0 -0
  39. package/lib/pr-monitor/render-sticky.js +14 -0
  40. package/lib/pr-monitor/shepherd-lease.js +243 -0
  41. package/lib/pr-monitor/upsert-sticky.js +1 -1
  42. package/lib/pr-monitor/watch-lifecycle.js +1 -1
  43. package/lib/pr-pull.js +70 -15
  44. package/lib/project-memory.js +8 -0
  45. package/lib/rules-sync.js +4 -0
  46. package/lib/runtime-health.js +15 -46
  47. package/lib/skill-eval.js +750 -0
  48. package/lib/status/identity.js +46 -0
  49. package/lib/status/presenter.js +0 -35
  50. package/lib/status/snapshot.js +11 -16
  51. package/lib/upgrade-safety.js +8 -9
  52. package/lib/using-forge.js +315 -0
  53. package/lib/workflow/enforce-stage.js +5 -5
  54. package/lib/workflow/state-manager.js +23 -23
  55. package/package.json +1 -1
  56. package/rules/using-forge.md +24 -0
  57. package/scripts/forge-team/index.sh +0 -5
  58. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  59. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  60. package/scripts/test.js +8 -1
  61. package/skills/claim-safety/SKILL.md +4 -0
  62. package/skills/claim-safety/evals/scorecard.json +41 -0
  63. package/skills/coverage.json +83 -0
  64. package/skills/dev/SKILL.md +4 -0
  65. package/skills/dev/evals/scorecard.json +41 -0
  66. package/skills/gates/SKILL.md +80 -0
  67. package/skills/gates/evals/evals.json +38 -0
  68. package/skills/gates/evals/scorecard.json +41 -0
  69. package/skills/hermes-forge/SKILL.md +1 -0
  70. package/skills/hermes-forge/evals/scorecard.json +41 -0
  71. package/skills/issue-basics/SKILL.md +1 -0
  72. package/skills/issue-basics/evals/scorecard.json +41 -0
  73. package/skills/kernel/SKILL.md +38 -0
  74. package/skills/kernel/evals/scorecard.json +41 -0
  75. package/skills/memory/SKILL.md +16 -1
  76. package/skills/memory/evals/scorecard.json +41 -0
  77. package/skills/parallel-deep-research/SKILL.md +1 -0
  78. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  79. package/skills/plan/SKILL.md +6 -0
  80. package/skills/plan/evals/scorecard.json +41 -0
  81. package/skills/portability/SKILL.md +47 -0
  82. package/skills/portability/evals/evals.json +34 -0
  83. package/skills/portability/evals/scorecard.json +41 -0
  84. package/skills/research/SKILL.md +1 -0
  85. package/skills/research/evals/scorecard.json +41 -0
  86. package/skills/review/SKILL.md +6 -0
  87. package/skills/review/evals/scorecard.json +41 -0
  88. package/skills/rollback/SKILL.md +1 -0
  89. package/skills/rollback/evals/scorecard.json +41 -0
  90. package/skills/setup/SKILL.md +91 -0
  91. package/skills/setup/evals/evals.json +42 -0
  92. package/skills/setup/evals/scorecard.json +41 -0
  93. package/skills/shepherd/SKILL.md +76 -36
  94. package/skills/shepherd/evals/evals.json +21 -9
  95. package/skills/shepherd/evals/scorecard.json +41 -0
  96. package/skills/ship/SKILL.md +6 -0
  97. package/skills/ship/evals/scorecard.json +41 -0
  98. package/skills/smith/SKILL.md +8 -0
  99. package/skills/smith/evals/scorecard.json +41 -0
  100. package/skills/sonarcloud/SKILL.md +1 -0
  101. package/skills/sonarcloud/evals/scorecard.json +41 -0
  102. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  103. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  104. package/skills/status/SKILL.md +3 -0
  105. package/skills/status/evals/scorecard.json +41 -0
  106. package/skills/triage-ready/SKILL.md +2 -0
  107. package/skills/triage-ready/evals/scorecard.json +41 -0
  108. package/skills/using-forge/SKILL.md +104 -0
  109. package/skills/using-forge/evals/scorecard.json +41 -0
  110. package/skills/validate/SKILL.md +4 -0
  111. package/skills/validate/evals/scorecard.json +41 -0
  112. package/skills/verify/SKILL.md +4 -0
  113. package/skills/verify/evals/scorecard.json +41 -0
  114. package/skills/worktree/SKILL.md +87 -0
  115. package/skills/worktree/evals/evals.json +38 -0
  116. package/skills/worktree/evals/scorecard.json +41 -0
  117. package/lib/adapters/beads-issue-adapter.js +0 -127
  118. package/lib/beads-nudge.js +0 -91
  119. package/lib/commands/board.js +0 -64
  120. package/lib/status/beads-snapshot.js +0 -145
  121. package/scripts/forge-team/lib/dashboard.sh +0 -316
  122. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The debounce / cost guard for the autonomous shepherd (W-S4 design §2).
5
+ *
6
+ * `tick()` is the cheap-path-first gate that fires from an arbitrary `forge`
7
+ * command (W-S4b wires the call site). Its whole job is to make the HOT path — a
8
+ * live daemon already converging — cost ~one `readFileSync` and return, so a
9
+ * per-command trigger never regresses into a `gh` call per command.
10
+ *
11
+ * Three gates:
12
+ * G1 A fresh lease (heartbeat < STALE_MS) means a live daemon is converging —
13
+ * do NOTHING (no stat, no enumerate, no spawn).
14
+ * G2 The enumeration window (RECONCILE_MIN_INTERVAL) has not elapsed since the
15
+ * last cold tick — skip enumeration.
16
+ * G3 Cold tick: bump the sentinel mtime FIRST (throttle even if gh is slow),
17
+ * then run the INJECTED enumerate()+reconcile()+execute().
18
+ *
19
+ * The sentinel file `<gitCommonDir>/forge/shepherd.reconcile` is the throttle
20
+ * stamp — its **mtime IS `last_enumerated_at`** (no content, no parse). It is a
21
+ * SEPARATE file from the token-guarded lease payload precisely because a tick
22
+ * fired by an arbitrary command does NOT hold the lease token and so cannot write
23
+ * the lease (design correction #3) — but any process may bump this sentinel.
24
+ *
25
+ * This module does NOT spawn — `enumerate`/`execute`/`reconcile` are injected, so
26
+ * the guard is fully testable with a fake clock + temp dir and 0 real I/O beyond
27
+ * the lock read and sentinel stat/bump. The daemon executor + spawn wiring are
28
+ * W-S4b.
29
+ *
30
+ * @module pr-monitor/reconcile-tick
31
+ */
32
+
33
+ const fs = require('node:fs');
34
+ const path = require('node:path');
35
+ const { STALE_MS } = require('./shepherd-lease');
36
+ const { reconcile: defaultReconcile } = require('./reconcile');
37
+
38
+ /** Default minimum interval between cold enumerations, per repo (ms). */
39
+ const RECONCILE_MIN_INTERVAL = 60000;
40
+
41
+ /** Resolve the enumeration window, honoring the env override at call time (tests). */
42
+ function resolveMinInterval() {
43
+ const override = Number(process.env.FORGE_RECONCILE_MIN_INTERVAL);
44
+ return Number.isFinite(override) && override > 0 ? override : RECONCILE_MIN_INTERVAL;
45
+ }
46
+
47
+ function forgeDir(gitCommonDir) {
48
+ return path.join(gitCommonDir, 'forge');
49
+ }
50
+ function lockPath(gitCommonDir) {
51
+ return path.join(forgeDir(gitCommonDir), 'shepherd.lock');
52
+ }
53
+ function sentinelPath(gitCommonDir) {
54
+ return path.join(forgeDir(gitCommonDir), 'shepherd.reconcile');
55
+ }
56
+
57
+ /** Read + parse the lock payload, or null when missing/unreadable/corrupt. */
58
+ function readLock(gitCommonDir) {
59
+ try {
60
+ return JSON.parse(fs.readFileSync(lockPath(gitCommonDir), 'utf8'));
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /** Stat the sentinel, or null when it does not exist yet. */
67
+ function statSentinel(gitCommonDir) {
68
+ try {
69
+ return fs.statSync(sentinelPath(gitCommonDir));
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** Bump the sentinel mtime to `t` (create it if absent). mtime IS last_enumerated_at. */
76
+ function bumpSentinel(gitCommonDir, t) {
77
+ const file = sentinelPath(gitCommonDir);
78
+ fs.mkdirSync(path.dirname(file), { recursive: true });
79
+ if (!fs.existsSync(file)) fs.writeFileSync(file, '', { mode: 0o600 });
80
+ const secs = t / 1000;
81
+ fs.utimesSync(file, secs, secs);
82
+ }
83
+
84
+ /**
85
+ * Run one debounce tick. Returns `{ path: 'G1'|'G2'|'G3', actions? }` describing
86
+ * which gate fired (the `actions` are the reconcile output on the G3 cold path).
87
+ *
88
+ * Injected seams (all keep the guard hermetic — no gh/spawn/clock of its own):
89
+ * now () => ms — clock (default Date.now)
90
+ * enumerate () => {desired, observed} — the expensive gh∩kernel gather (G3 only)
91
+ * execute (actions) => void — the action dispatcher (G3 only)
92
+ * reconcile (desired, observed, now) => {actions} — pure core (default: the real one)
93
+ * minInterval ms — enumeration window (default: env-or-60000)
94
+ */
95
+ function tick({
96
+ gitCommonDir,
97
+ now = () => Date.now(),
98
+ enumerate,
99
+ execute,
100
+ reconcile = defaultReconcile,
101
+ minInterval = resolveMinInterval(),
102
+ } = {}) {
103
+ const t = now();
104
+
105
+ // G1 — a fresh lease means a live daemon is converging. Do NOTHING (hot path:
106
+ // one readFileSync + JSON.parse, then return; no stat, no enumerate, no spawn).
107
+ const lock = readLock(gitCommonDir);
108
+ if (lock) {
109
+ const beat = Date.parse(lock.heartbeatAt);
110
+ if (Number.isFinite(beat) && (t - beat) < STALE_MS) {
111
+ return { path: 'G1' };
112
+ }
113
+ }
114
+
115
+ // G2 — the enumeration window has not elapsed since the last cold tick. Trust the
116
+ // last enumeration; skip the expensive gather. (Daemon revive on this path is W-S4b.)
117
+ const sentinelStat = statSentinel(gitCommonDir);
118
+ if (sentinelStat && (t - sentinelStat.mtimeMs) < minInterval) {
119
+ return { path: 'G2' };
120
+ }
121
+
122
+ // G3 — cold tick: no fresh lease AND the window elapsed. Bump the sentinel mtime
123
+ // FIRST so the throttle holds even if the enumerate() gh call is slow, THEN run the
124
+ // injected enumerate → reconcile → execute.
125
+ bumpSentinel(gitCommonDir, t);
126
+ const { desired, observed } = enumerate();
127
+ const { actions } = reconcile(desired, observed, t);
128
+ execute(actions);
129
+ return { path: 'G3', actions };
130
+ }
131
+
132
+ module.exports = {
133
+ tick,
134
+ RECONCILE_MIN_INTERVAL,
135
+ resolveMinInterval,
136
+ lockPath,
137
+ sentinelPath,
138
+ };
Binary file
@@ -147,6 +147,10 @@ function renderChecks(bundle, lines) {
147
147
  * @param {object} bundle - a `gatherPrBundle` result (lib/pr-bundle.js).
148
148
  * @param {object} [opts]
149
149
  * @param {Date} [opts.now] - injected clock for deterministic output.
150
+ * @param {string} [opts.verdict] - the canonical `--pull` verdict.
151
+ * @param {string[]} [opts.unreadable] - unreadable signal names (from the `--pull`
152
+ * evidence). Surfaced when the verdict is UNKNOWN so the sticky always says WHICH
153
+ * signal could not be read, not just a bare `unknown`.
150
154
  * @returns {{ marker: string, body: string }}
151
155
  */
152
156
  function renderStickyComment(bundle = {}, opts = {}) {
@@ -163,6 +167,16 @@ function renderStickyComment(bundle = {}, opts = {}) {
163
167
  // by pr-pull). Surface only: it labels state; this monitor **does not merge**
164
168
  // and never resolves review threads.
165
169
  lines.push(verdictHeadline(opts.verdict));
170
+ // An `unknown` verdict is useless without the WHY — name the unreadable
171
+ // signal(s) so a human/agent knows what to fix (e.g. `requiredChecks` when both
172
+ // branch-protection AND the rollup fallback could not be read).
173
+ const verdictUpper = String(opts.verdict || '').toUpperCase();
174
+ const isUnknown = verdictUpper === 'UNKNOWN' || !VERDICT_HEADLINE[verdictUpper];
175
+ const unreadable = Array.isArray(opts.unreadable) ? opts.unreadable.filter(Boolean) : [];
176
+ if (isUnknown && unreadable.length > 0) {
177
+ lines.push('');
178
+ lines.push(`> Unreadable signal(s): ${unreadable.map((s) => `\`${s}\``).join(', ')}.`);
179
+ }
166
180
  lines.push('');
167
181
  lines.push('_Surfaces open review + check state so async feedback never rots. This monitor **does not merge** and never resolves review threads — a human merges in the GitHub UI._');
168
182
  lines.push('');
@@ -0,0 +1,243 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shepherd singleton lease — a machine-wide "one watcher-set" guard for the PR
5
+ * shepherd, fusing two existing precedents:
6
+ *
7
+ * - `serve.lock`'s exclusive-create + foreign-PID block + stale reclaim
8
+ * (`lib/commands/_serve-security.js`), so a live foreign owner is never
9
+ * stolen but a dead/wedged one is reclaimed.
10
+ * - the journal lock's heartbeat + TTL staleness
11
+ * (`lib/pr-monitor/journal.js`), so a slow-but-alive owner refreshes its
12
+ * timestamp and a crashed owner ages out.
13
+ *
14
+ * The lock lives at `<gitCommonDir>/forge/shepherd.lock`, keyed by the SAME
15
+ * `resolveGitCommonDir` the kernel DB uses — so every worktree of a repo shares
16
+ * one lock. This module is the lease PRIMITIVE only: pure fs + I/O, no spawned
17
+ * process and no reconcile loop (the daemon wires those up later).
18
+ *
19
+ * Payload JSON: `{ pid, token, startedAt, heartbeatAt, watchers: [prNumbers] }`.
20
+ *
21
+ * ## Ownership is by TOKEN, not pid
22
+ * Each successful `acquire` mints a unique `token`. Every mutating op
23
+ * (`stamp`/`updateWatchers`/`release`) verifies that token against the on-disk
24
+ * lock before writing. A pid can be reused after a crash/reboot, and a wedged
25
+ * owner can revive after its lease was reclaimed — in both cases the token no
26
+ * longer matches, so the superseded holder can never resurrect or mutate a lease
27
+ * it no longer owns. Takeover of a stale lock is made atomic by an O_EXCL create
28
+ * (only one racer can win it), so two processes reclaiming the same stale lock
29
+ * can never both succeed.
30
+ *
31
+ * @module pr-monitor/shepherd-lease
32
+ */
33
+
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+ const crypto = require('node:crypto');
37
+ const { resolveGitCommonDir } = require('../kernel/broker');
38
+
39
+ /** A wedged owner whose heartbeat is older than this (ms) is reclaimable. */
40
+ const STALE_MS = 30000;
41
+ const LOCK_FILE_MODE = 0o600;
42
+
43
+ /**
44
+ * Resolve the shared lock path. `gitCommonDir` may be injected (tests, or a
45
+ * caller that already resolved it); otherwise it is resolved from `projectRoot`
46
+ * with the same resolver the kernel broker uses.
47
+ */
48
+ function lockFilePath(projectRoot, opts = {}) {
49
+ const gitCommonDir = opts.gitCommonDir
50
+ ? path.resolve(opts.gitCommonDir)
51
+ : resolveGitCommonDir(projectRoot, opts);
52
+ return path.join(gitCommonDir, 'forge', 'shepherd.lock');
53
+ }
54
+
55
+ /** Parse the lock payload, or null when missing/unreadable/corrupt. */
56
+ function readLock(file) {
57
+ try {
58
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // Is `pid` a live process? `process.kill(pid, 0)` sends no signal but throws
65
+ // ESRCH when the pid is gone. EPERM means it exists but isn't ours — still live.
66
+ function pidAlive(pid) {
67
+ if (!Number.isInteger(pid) || pid <= 0) return false;
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch (err) {
72
+ return err.code === 'EPERM';
73
+ }
74
+ }
75
+
76
+ /** Is a held lock stale (dead owner, or heartbeat older than STALE_MS)? */
77
+ function isHeldStale(held, { isAlive, now }) {
78
+ if (!isAlive(held.pid)) return true;
79
+ const beat = Date.parse(held.heartbeatAt);
80
+ if (!Number.isFinite(beat)) return true;
81
+ return now() - beat >= STALE_MS;
82
+ }
83
+
84
+ /**
85
+ * A lock is "ours" iff its unique lease `token` matches the one `acquire`
86
+ * returned. Token is authoritative: a pid can be reused after a crash/reboot and
87
+ * a wedged owner can revive after being reclaimed, so a pid match alone is NOT
88
+ * proof of ownership. Only a hypothetical legacy lock with no `token` field falls
89
+ * back to pid comparison.
90
+ */
91
+ function ownsLock(held, { token, pid }) {
92
+ if (held.token) return token !== undefined && held.token === token;
93
+ return held.pid === pid;
94
+ }
95
+
96
+ function writeLock(file, payload) {
97
+ fs.mkdirSync(path.dirname(file), { recursive: true });
98
+ fs.writeFileSync(file, JSON.stringify(payload), { mode: LOCK_FILE_MODE });
99
+ }
100
+
101
+ /**
102
+ * Atomically create the lock with O_EXCL and write `payload`. Returns true when
103
+ * we won the create, false on EEXIST (a concurrent contender holds it); other
104
+ * errors rethrow. The O_EXCL create is the single atomic arbiter — of N racers
105
+ * attempting it against the same absent path, exactly one succeeds.
106
+ */
107
+ function tryExclusiveCreate(file, payload) {
108
+ let fd;
109
+ try {
110
+ fd = fs.openSync(file, 'wx', LOCK_FILE_MODE);
111
+ } catch (err) {
112
+ if (err.code === 'EEXIST') return false;
113
+ throw err;
114
+ }
115
+ try {
116
+ fs.writeSync(fd, JSON.stringify(payload));
117
+ } finally {
118
+ fs.closeSync(fd);
119
+ }
120
+ return true;
121
+ }
122
+
123
+ /**
124
+ * Try to claim the singleton lease.
125
+ * -> { ok:true, file, token } first claim
126
+ * -> { ok:true, file, token, reclaimed } stale lock (dead/wedged owner) reclaimed
127
+ * -> { ok:false, held } a LIVE, FRESH foreign owner holds it
128
+ * -> { ok:false, held } we lost the atomic takeover race
129
+ *
130
+ * `pid`/`isAlive`/`now`/`token` are injectable for testing. `onBeforeTakeover` is
131
+ * a test seam invoked AFTER the stale lock is removed and BEFORE our exclusive
132
+ * re-create, so a test can simulate a competitor winning the O_EXCL create first
133
+ * (making our takeover lose).
134
+ */
135
+ function acquire(projectRoot, {
136
+ gitCommonDir,
137
+ pid = process.pid,
138
+ isAlive = pidAlive,
139
+ now = () => Date.now(),
140
+ token = crypto.randomUUID(),
141
+ onBeforeTakeover = null,
142
+ } = {}) {
143
+ const file = lockFilePath(projectRoot, { gitCommonDir });
144
+ fs.mkdirSync(path.dirname(file), { recursive: true });
145
+ const iso = new Date(now()).toISOString();
146
+ const payload = { pid, token, startedAt: iso, heartbeatAt: iso, watchers: [] };
147
+
148
+ // Fast path: atomic exclusive create. Only ONE caller can win O_EXCL.
149
+ if (tryExclusiveCreate(file, payload)) {
150
+ return { ok: true, file, token };
151
+ }
152
+
153
+ // A lock exists. A DIFFERENT, live, fresh owner blocks us.
154
+ const held = readLock(file);
155
+ if (held && held.pid !== pid && !isHeldStale(held, { isAlive, now })) {
156
+ return { ok: false, held };
157
+ }
158
+
159
+ // Stale (dead/wedged owner, unreadable, or already ours). Take over ATOMICALLY:
160
+ // remove the stale lock, then re-create it with O_EXCL. If a competitor
161
+ // recreated it first, our exclusive create fails (EEXIST) and we back off — so
162
+ // two racers reclaiming the same stale lock can NEVER both win. A wedged owner
163
+ // that revives after we delete its lock is stopped by the per-lease `token`
164
+ // guard on stamp()/updateWatchers()/release(), never able to resurrect it here.
165
+ fs.rmSync(file, { force: true });
166
+ if (typeof onBeforeTakeover === 'function') onBeforeTakeover();
167
+ if (!tryExclusiveCreate(file, payload)) {
168
+ return { ok: false, held: readLock(file) };
169
+ }
170
+ return { ok: true, file, token, reclaimed: true };
171
+ }
172
+
173
+ /**
174
+ * Refresh `heartbeatAt` on OUR lock. Returns false (a no-op) when the lock is
175
+ * missing or NOT ours by `token` — we never stamp a lease we no longer own, so a
176
+ * revived wedged owner cannot resurrect a reclaimed lease.
177
+ */
178
+ function stamp(projectRoot, { gitCommonDir, token, pid = process.pid, now = () => Date.now() } = {}) {
179
+ const file = lockFilePath(projectRoot, { gitCommonDir });
180
+ const held = readLock(file);
181
+ if (!held || !ownsLock(held, { token, pid })) return false;
182
+ held.heartbeatAt = new Date(now()).toISOString();
183
+ writeLock(file, held);
184
+ return true;
185
+ }
186
+
187
+ /**
188
+ * Start a heartbeat timer that stamps `heartbeatAt` every STALE_MS/3. The timer
189
+ * is `.unref()`ed so it never keeps the process alive. `opts` MUST carry the
190
+ * `token` returned by `acquire` (threaded straight through to `stamp`). Returns
191
+ * the handle for `stopHeartbeat`.
192
+ */
193
+ function startHeartbeat(projectRoot, opts = {}) {
194
+ const timer = setInterval(() => stamp(projectRoot, opts), Math.max(1, Math.floor(STALE_MS / 3)));
195
+ if (typeof timer.unref === 'function') timer.unref();
196
+ return timer;
197
+ }
198
+
199
+ /** Stop a heartbeat timer started by `startHeartbeat`. */
200
+ function stopHeartbeat(timer) {
201
+ if (timer) clearInterval(timer);
202
+ }
203
+
204
+ /**
205
+ * Rewrite the `watchers[]` array on OUR lock. Returns false when the lock is
206
+ * missing or NOT ours by `token`.
207
+ */
208
+ function updateWatchers(projectRoot, prNumbers, { gitCommonDir, token, pid = process.pid } = {}) {
209
+ const file = lockFilePath(projectRoot, { gitCommonDir });
210
+ const held = readLock(file);
211
+ if (!held || !ownsLock(held, { token, pid })) return false;
212
+ held.watchers = Array.isArray(prNumbers) ? prNumbers : [];
213
+ writeLock(file, held);
214
+ return true;
215
+ }
216
+
217
+ /**
218
+ * Release the lease — delete the lock ONLY when it is ours by `token`, so a
219
+ * foreign or already-reclaimed lock is never removed out from under its owner
220
+ * (and a reused pid can never delete someone else's lease).
221
+ */
222
+ function release(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
223
+ const file = lockFilePath(projectRoot, { gitCommonDir });
224
+ try {
225
+ const held = readLock(file);
226
+ if (held && ownsLock(held, { token, pid })) fs.rmSync(file, { force: true });
227
+ } catch {
228
+ /* best effort — a failed release just leaves a stale lock to be reclaimed */
229
+ }
230
+ }
231
+
232
+ module.exports = {
233
+ STALE_MS,
234
+ lockFilePath,
235
+ pidAlive,
236
+ ownsLock,
237
+ acquire,
238
+ stamp,
239
+ startHeartbeat,
240
+ stopHeartbeat,
241
+ updateWatchers,
242
+ release,
243
+ };
@@ -88,7 +88,7 @@ async function upsertStickyComment({ marker }, client) {
88
88
  // one annotation here covers every call site. `encoding: 'utf8'` also pipes
89
89
  // stderr onto the thrown error, so isAlreadyGone() below can classify failures.
90
90
  function runGh(args) {
91
- return execFileSync('gh', args, { encoding: 'utf8' }); // NOSONAR S4036 - hardcoded CLI (gh), args array (no shell), developer-tool context
91
+ return execFileSync('gh', args, { encoding: 'utf8', windowsHide: true }); // NOSONAR S4036 - hardcoded CLI (gh), args array (no shell), developer-tool context. windowsHide: sticky upsert runs in the detached watcher (issue 931e7924).
92
92
  }
93
93
 
94
94
  /**
@@ -35,7 +35,7 @@ function forgeBin() {
35
35
  function defaultResolveSlug({ cwd, exec = execFileSync }) {
36
36
  try {
37
37
  const url = exec('git', ['remote', 'get-url', 'origin'], {
38
- cwd, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
38
+ cwd, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
39
39
  }).trim();
40
40
  const match = /[/:][^/]+\/([^/]+?)(?:\.git)?$/.exec(url);
41
41
  return match ? match[1] : null;
package/lib/pr-pull.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * @module pr-pull
29
29
  */
30
30
 
31
- const { isFailed, isGreen, runShepherdPass } = require('./pr-shepherd');
31
+ const { isFailed, isGreen } = require('./pr-shepherd');
32
32
  const { fenceUntrusted } = require('./untrusted-content');
33
33
 
34
34
  /** Token caps that keep the payload bounded regardless of PR size. */
@@ -680,9 +680,12 @@ function buildPullPayload({
680
680
 
681
681
  return {
682
682
  ...prField(pr),
683
+ // DEPRECATED: `state` is the legacy decision-pass ladder, kept only for
684
+ // back-compat. It is now a PROJECTION of `verdict` (via verdictToLegacyState),
685
+ // never independently computed. Consumers MUST read `verdict` — the single,
686
+ // trustworthy, fail-closed merge vocabulary (never false-clean). Full removal
687
+ // of `state` is tracked as a follow-up kernel issue.
683
688
  state,
684
- // `verdict` is the trustworthy, fail-closed merge signal (never false-clean);
685
- // `state` remains the legacy decision-pass state for back-compat.
686
689
  ...(verdict ? { verdict } : {}),
687
690
  ...(evidence ? { evidence } : {}),
688
691
  summary,
@@ -993,6 +996,52 @@ function verdictLabel(verdict) {
993
996
  /** The full label reconcile set — one label per canonical verdict. */
994
997
  const VERDICT_LABELS = MERGE_VERDICTS.map((v) => `${VERDICT_LABEL_PREFIX}${v.toLowerCase()}`);
995
998
 
999
+ /**
1000
+ * The ONE place that maps the canonical 7-enum `verdict` onto the DEPRECATED
1001
+ * legacy `runShepherdPass` ladder (`pr-shepherd.js:34`). `verdict` is the single
1002
+ * consumer-facing vocabulary; the payload's `state` field is a back-compat
1003
+ * PROJECTION of the verdict through this map — never independently computed — so
1004
+ * the two can never disagree. Unknown/empty input fails closed to `UNKNOWN`.
1005
+ *
1006
+ * @param {string} verdict - a canonical MERGE_VERDICTS value.
1007
+ * @returns {string} the deprecated legacy state.
1008
+ */
1009
+ function verdictToLegacyState(verdict) {
1010
+ const v = String(verdict || '').toUpperCase();
1011
+ switch (v) {
1012
+ case 'CLEAN-MERGEABLE': return 'MERGE_READY';
1013
+ case 'REVIEW-PENDING': return 'NEEDS_REVIEW';
1014
+ case 'BLOCKED-THREADS': return 'NEEDS_REVIEW';
1015
+ case 'BLOCKED-CHECKS': return 'PENDING';
1016
+ // BEHIND maps to ESCALATE, not PENDING: the legacy runShepherdPass escalated a
1017
+ // behind head (handleBehindBase with --auto-rebase OFF, the default) so a human
1018
+ // rebases or opts into auto-rebase. Mapping it to PENDING would tell a legacy
1019
+ // `state` consumer to keep waiting on a PR that actually needs action.
1020
+ case 'BEHIND': return 'ESCALATE';
1021
+ case 'BLOCKED-CONFLICT': return 'ESCALATE';
1022
+ default: return 'UNKNOWN';
1023
+ }
1024
+ }
1025
+
1026
+ /**
1027
+ * The back-compat legacy `state` for the `--pull` payload. A terminal GitHub
1028
+ * lifecycle (`MERGED`/`CLOSED`) wins over the merge verdict: the old dry-run
1029
+ * `runShepherdPass` hit `lifecycleOutcome` first (`pr-shepherd.js:403`) and
1030
+ * emitted `MERGED`/`CLOSED` so a legacy consumer stops polling / stops prompting a
1031
+ * human to merge an already-landed PR. `computeVerdict`'s 7-enum has no terminal
1032
+ * value, so we read the lifecycle (`adapter.readState().state`) here; only an open
1033
+ * PR falls through to the verdict projection.
1034
+ *
1035
+ * @param {string} verdict - a canonical MERGE_VERDICTS value.
1036
+ * @param {string} [prLifecycleState] - readState().state: OPEN | MERGED | CLOSED.
1037
+ * @returns {string} the deprecated legacy state.
1038
+ */
1039
+ function legacyStateFor(verdict, prLifecycleState) {
1040
+ const lifecycle = String(prLifecycleState || '').toUpperCase();
1041
+ if (lifecycle === 'MERGED' || lifecycle === 'CLOSED') return lifecycle;
1042
+ return verdictToLegacyState(verdict);
1043
+ }
1044
+
996
1045
  /**
997
1046
  * Run an optional read and SURFACE any failure instead of swallowing it: on
998
1047
  * throw, record `{ source, error }` into `degraded` (and optionally mark `source`
@@ -1067,7 +1116,6 @@ function gatherFailureExcerpts(runGh, checks, requiredSet, { maxFailures, maxExc
1067
1116
  * @param {string} [ctx.self] - shepherd's own login
1068
1117
  * @param {object} ctx.adapter - validated pr-state adapter
1069
1118
  * @param {(args: string[]) => string} ctx.runGh - injected `gh` runner (args → stdout)
1070
- * @param {Function} [ctx.runPass] - decision pass (default runShepherdPass), injectable for tests
1071
1119
  * @param {number} [ctx.maxFailures]
1072
1120
  * @param {number} [ctx.maxThreads]
1073
1121
  * @param {number} [ctx.maxExcerptLines]
@@ -1093,7 +1141,9 @@ async function gatherPrSnapshot(ctx) {
1093
1141
  const state = await adapter.readState(pr);
1094
1142
  const requiredSet = await safeRead(
1095
1143
  'requiredChecks',
1096
- () => adapter.readRequiredChecks({ owner, repo, base }),
1144
+ // `pr` lets the adapter fall back to the rollup `isRequired` set when branch
1145
+ // protection is unreadable (the Actions token can't read protection).
1146
+ () => adapter.readRequiredChecks({ owner, repo, base, pr }),
1097
1147
  { degraded, fallback: null },
1098
1148
  );
1099
1149
 
@@ -1195,6 +1245,11 @@ async function gatherPrSnapshot(ctx) {
1195
1245
  unreadable,
1196
1246
  });
1197
1247
 
1248
+ // Record which source answered the required-checks read (`protection` |
1249
+ // `rollup` | null) so the source is visible in the verdict evidence — the
1250
+ // rollup source is the CI path where branch protection is unreadable.
1251
+ evidence.requiredSource = adapter.lastRequiredSource || null;
1252
+
1198
1253
  return {
1199
1254
  state, requiredSet, threads, behind, conflicts, issueComments,
1200
1255
  botStatusBlockers, requiredChecks, pendingChecks, draft, reviewDecision,
@@ -1207,7 +1262,6 @@ async function gatherPrSnapshot(ctx) {
1207
1262
  async function gatherPullSignal(ctx) {
1208
1263
  const {
1209
1264
  pr, self, adapter, runGh,
1210
- runPass = runShepherdPass,
1211
1265
  maxFailures = DEFAULT_MAX_FAILURES,
1212
1266
  maxThreads = DEFAULT_MAX_THREADS,
1213
1267
  maxExcerptLines = DEFAULT_MAX_EXCERPT_LINES,
@@ -1230,12 +1284,12 @@ async function gatherPullSignal(ctx) {
1230
1284
  requiredChecks, pendingChecks, draft, reviewDecision, verdict, evidence, degraded,
1231
1285
  } = snap;
1232
1286
 
1233
- // Legacy decision pass (READ-ONLY dryRun) for back-compat `state`. Shares the
1234
- // snapshot's `degraded` so a pass-read failure is still surfaced in the payload.
1235
- const pass = await safeRead('pass', () => runPass({ ...ctx, adapter, dryRun: true }), {
1236
- degraded,
1237
- fallback: { state: 'UNKNOWN', reason: 'Decision pass could not complete a read failed; see verdict evidence.' },
1238
- });
1287
+ // `verdict` is the SINGLE consumer-facing vocabulary. The back-compat `state`
1288
+ // field is DERIVED from it (via legacyStateFor) the read-only `--pull` payload
1289
+ // no longer runs a second decision pass, so the two vocabularies can never
1290
+ // disagree. A terminal lifecycle (MERGED/CLOSED) still wins over the verdict so
1291
+ // legacy consumers see the landed outcome, matching the old dry-run pass.
1292
+ const legacyState = legacyStateFor(verdict, state.state);
1239
1293
 
1240
1294
  const failures = gatherFailureExcerpts(runGh, state.checks, requiredSet, { maxFailures, maxExcerptLines, degraded });
1241
1295
  const reviewThreads = buildReviewThreads(threads, self, { maxThreads });
@@ -1254,7 +1308,7 @@ async function gatherPullSignal(ctx) {
1254
1308
  });
1255
1309
 
1256
1310
  const summary = summarize({
1257
- state: pass.state,
1311
+ state: legacyState,
1258
1312
  failureCount: failures.length,
1259
1313
  threadCount: reviewThreads.length,
1260
1314
  blockers,
@@ -1262,11 +1316,10 @@ async function gatherPullSignal(ctx) {
1262
1316
 
1263
1317
  return buildPullPayload({
1264
1318
  pr,
1265
- state: pass.state,
1319
+ state: legacyState,
1266
1320
  verdict,
1267
1321
  evidence,
1268
1322
  degraded,
1269
- reason: pass.reason,
1270
1323
  summary,
1271
1324
  mergeable: state.mergeable || 'UNKNOWN',
1272
1325
  mergeStateStatus: state.mergeStateStatus || 'UNKNOWN',
@@ -1299,6 +1352,8 @@ module.exports = {
1299
1352
  renderPullSummary,
1300
1353
  buildPullPayload,
1301
1354
  computeVerdict,
1355
+ verdictToLegacyState,
1356
+ legacyStateFor,
1302
1357
  MERGE_VERDICTS,
1303
1358
  VERDICT_LABELS,
1304
1359
  VERDICT_LABEL_PREFIX,
@@ -140,6 +140,13 @@ function searchRanked(projectRoot, query, limit, options = {}) {
140
140
  return resolveStore(projectRoot, options).searchMemoriesRanked(query, limit);
141
141
  }
142
142
 
143
+ // Relevance-only BM25 recall that returns the raw bm25 `score` per entry, so a caller can
144
+ // apply a relevance floor. A no-match/empty query returns [] (no recency fallback). The
145
+ // per-turn memory-recall hook uses this to inject nothing unless a note clearly matches.
146
+ function searchRankedScored(projectRoot, query, limit, options = {}) {
147
+ return resolveStore(projectRoot, options).searchMemoriesRankedScored(query, limit);
148
+ }
149
+
143
150
  // Close and forget every cached default store. The CLI process is short-lived (the OS
144
151
  // closes the handle on exit), so this is mainly a lifecycle helper for long-lived hosts and
145
152
  // tests — it releases the SQLite/WAL handle before a temp dir is removed.
@@ -162,5 +169,6 @@ module.exports = {
162
169
  recent,
163
170
  count,
164
171
  searchRanked,
172
+ searchRankedScored,
165
173
  closeAll,
166
174
  };
package/lib/rules-sync.js CHANGED
@@ -48,6 +48,10 @@ const CURSOR_RULE_FILES = {
48
48
  security: 'security-scanning.mdc',
49
49
  documentation: 'documentation.mdc',
50
50
  'kernel-tracking': 'kernel-tracking.mdc',
51
+ // The using-forge dispatch pointer as an always-apply Cursor rule — Cursor's native
52
+ // always-on carrier for the skill auto-trigger bootstrap (parallels the Claude SessionStart
53
+ // hook and the committed AGENTS.md for Codex). Thin pointer; full policy stays in the skill.
54
+ 'using-forge': 'using-forge.mdc',
51
55
  };
52
56
 
53
57
  const CANONICAL_RULE_NAMES = Object.keys(CURSOR_RULE_FILES);