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.
- package/AGENTS.md +1 -1
- package/bin/forge-cmd.js +1 -1
- package/bin/forge.js +5 -0
- package/docs/reference/COMMANDS.md +3 -1
- package/docs/reference/shepherd.md +72 -1
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +103 -8
- package/lib/agents-config.js +5 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/hooks.js +155 -8
- package/lib/commands/plan.js +23 -115
- package/lib/commands/prime.js +8 -1
- package/lib/commands/release.js +1 -2
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +0 -1
- package/lib/commands/shepherd.js +36 -3
- package/lib/commands/skill.js +275 -0
- package/lib/commands/status.js +37 -32
- package/lib/commands/test.js +32 -0
- package/lib/commands/worktree.js +27 -1
- package/lib/core/runtime-graph.js +88 -14
- package/lib/forge-issues.js +13 -464
- package/lib/harness-capability-matrix.js +2 -2
- package/lib/hook-renderer.js +54 -12
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/broker.js +28 -0
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +150 -0
- package/lib/memory-recall.js +151 -0
- package/lib/orientation.js +303 -6
- package/lib/pr-bundle.js +6 -2
- package/lib/pr-monitor/reconcile-executor.js +659 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-sticky.js +14 -0
- package/lib/pr-monitor/shepherd-lease.js +243 -0
- package/lib/pr-monitor/upsert-sticky.js +1 -1
- package/lib/pr-monitor/watch-lifecycle.js +1 -1
- package/lib/pr-pull.js +70 -15
- package/lib/project-memory.js +8 -0
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/skill-eval.js +750 -0
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/upgrade-safety.js +8 -9
- package/lib/using-forge.js +315 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +1 -1
- package/rules/using-forge.md +24 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/test.js +8 -1
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +6 -0
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +1 -0
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +76 -36
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +6 -0
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +87 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/beads-nudge.js +0 -91
- package/lib/commands/board.js +0 -64
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- 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
|
|
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
|
-
|
|
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
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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:
|
|
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:
|
|
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,
|
package/lib/project-memory.js
CHANGED
|
@@ -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);
|