forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5
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 +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +21 -1
- package/bin/forge.js +16 -369
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +9 -4
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +40 -0
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -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/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +184 -44
- 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 +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- 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 +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- 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 +92 -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/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -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
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR-monitor Actions-summary renderer. It turns one read-only
|
|
5
|
+
* `gatherPrBundle` result (lib/pr-bundle.js) into deterministic Markdown for
|
|
6
|
+
* the workflow's `GITHUB_STEP_SUMMARY` surface.
|
|
7
|
+
*
|
|
8
|
+
* This is presentation only: it displays the canonical verdict supplied by
|
|
9
|
+
* `forge shepherd <pr> --pull --json`, lists unresolved review threads and CI
|
|
10
|
+
* state, and never merges or resolves anything. The `pr-verdict:*` label is a
|
|
11
|
+
* cheap visibility projection of that same verdict, not merge authority.
|
|
12
|
+
*
|
|
13
|
+
* @module pr-monitor/render-summary
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Presentation-only headline for each canonical merge verdict (lib/pr-pull.js).
|
|
18
|
+
* The verdict is computed once by pr-pull and passed in; this map only decides
|
|
19
|
+
* how it is displayed, so there is no second verdict ladder to drift.
|
|
20
|
+
*/
|
|
21
|
+
const VERDICT_HEADLINE = {
|
|
22
|
+
UNKNOWN: '⚪ **Verdict: `unknown`** — a signal was unreadable; state unconfirmed (fail-closed).',
|
|
23
|
+
'BLOCKED-CONFLICT': '🔀 **Verdict: `blocked-conflict`** — branch conflicts with base; rebase/merge and resolve.',
|
|
24
|
+
BEHIND: '⬇️ **Verdict: `behind`** — branch is behind base; update/rebase (protection requires up-to-date).',
|
|
25
|
+
'BLOCKED-CHECKS': '🔴 **Verdict: `blocked-checks`** — a required check is failing/missing; fix it.',
|
|
26
|
+
'BLOCKED-THREADS': '🟠 **Verdict: `blocked-threads`** — unresolved review threads need addressing.',
|
|
27
|
+
'REVIEW-PENDING': '🟡 **Verdict: `review-pending`** — awaiting review / settle window; not ready yet.',
|
|
28
|
+
'CLEAN-MERGEABLE': '🟢 **Verdict: `clean-mergeable`** — green + zero unresolved threads; ready for a human to merge.',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Render the one-line headline for a canonical verdict, failing closed. */
|
|
32
|
+
function verdictHeadline(verdict) {
|
|
33
|
+
return VERDICT_HEADLINE[String(verdict || '').toUpperCase()] || VERDICT_HEADLINE.UNKNOWN;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render untrusted text as a Markdown code span without allowing its backticks
|
|
38
|
+
* or line breaks to change the surrounding summary structure.
|
|
39
|
+
*
|
|
40
|
+
* CommonMark permits a code span to use more than one backtick. Pick a fence
|
|
41
|
+
* longer than every run in the value, and flatten CR/LF so the summary stays
|
|
42
|
+
* one line per diagnostic.
|
|
43
|
+
*/
|
|
44
|
+
function mdCode(value) {
|
|
45
|
+
const text = String(value ?? '').replace(/[\r\n]+/g, ' ');
|
|
46
|
+
let longestRun = 0;
|
|
47
|
+
let currentRun = 0;
|
|
48
|
+
for (const character of text) {
|
|
49
|
+
if (character === '`') {
|
|
50
|
+
currentRun += 1;
|
|
51
|
+
longestRun = Math.max(longestRun, currentRun);
|
|
52
|
+
} else {
|
|
53
|
+
currentRun = 0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const fence = '`'.repeat(longestRun + 1);
|
|
57
|
+
const content = text.startsWith('`') || text.endsWith('`') ? ` ${text} ` : text;
|
|
58
|
+
return `${fence}${content}${fence}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Cap threads listed per author so a noisy PR cannot produce an enormous summary. */
|
|
62
|
+
const MAX_THREADS_PER_AUTHOR = 8;
|
|
63
|
+
|
|
64
|
+
/** Group unresolved review-thread comments by author in deterministic order. */
|
|
65
|
+
function groupByAuthor(comments) {
|
|
66
|
+
const byAuthor = new Map();
|
|
67
|
+
for (const comment of (Array.isArray(comments) ? comments : [])) {
|
|
68
|
+
const author = String(comment.author || 'unknown');
|
|
69
|
+
if (!byAuthor.has(author)) byAuthor.set(author, []);
|
|
70
|
+
byAuthor.get(author).push(comment);
|
|
71
|
+
}
|
|
72
|
+
return [...byAuthor.entries()].sort(
|
|
73
|
+
(a, b) => (b[1].length - a[1].length) || a[0].localeCompare(b[0]),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One-line locator for a thread: `path:line` when known, else its id. */
|
|
78
|
+
function threadLocator(thread) {
|
|
79
|
+
if (thread.path) return thread.line != null ? `${thread.path}:${thread.line}` : thread.path;
|
|
80
|
+
return thread.threadId || '(thread)';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Render unresolved review threads, preserving fail-closed availability. */
|
|
84
|
+
function renderThreads(bundle, lines) {
|
|
85
|
+
// Empty arrays are ambiguous when the adapter could not read comments. Only
|
|
86
|
+
// an explicit available:true read may report zero unresolved threads.
|
|
87
|
+
if (bundle.unresolvedCommentsAvailable !== true) {
|
|
88
|
+
const why = bundle.unresolvedCommentsError || 'thread read unavailable (capability absent)';
|
|
89
|
+
lines.push('### Review threads');
|
|
90
|
+
lines.push(`⚠️ Review threads were **unreadable** this pass (${mdCode(why)}) — not treated as zero. Re-run once the read recovers.`);
|
|
91
|
+
lines.push('');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const comments = Array.isArray(bundle.unresolvedComments) ? bundle.unresolvedComments : [];
|
|
96
|
+
if (comments.length === 0) {
|
|
97
|
+
lines.push('### Review threads');
|
|
98
|
+
lines.push('✅ No unresolved review threads.');
|
|
99
|
+
lines.push('');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const groups = groupByAuthor(comments);
|
|
104
|
+
lines.push(`### Unresolved review threads (${comments.length})`);
|
|
105
|
+
lines.push('');
|
|
106
|
+
for (const [author, threads] of groups) {
|
|
107
|
+
lines.push(`- **${author}** — ${threads.length}`);
|
|
108
|
+
for (const thread of threads.slice(0, MAX_THREADS_PER_AUTHOR)) {
|
|
109
|
+
lines.push(` - ${mdCode(threadLocator(thread))}`);
|
|
110
|
+
}
|
|
111
|
+
if (threads.length > MAX_THREADS_PER_AUTHOR) {
|
|
112
|
+
lines.push(` - …and ${threads.length - MAX_THREADS_PER_AUTHOR} more`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
lines.push('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Render failing and pending checks, preserving fail-closed availability. */
|
|
119
|
+
function renderChecks(bundle, lines) {
|
|
120
|
+
// Only ciAvailable:true permits a clean-check claim. Missing or false means
|
|
121
|
+
// the read did not complete, so empty arrays must not look green.
|
|
122
|
+
if (bundle.ciAvailable !== true) {
|
|
123
|
+
lines.push('### Checks');
|
|
124
|
+
lines.push('⚠️ Checks were **unreadable** this pass — not treated as green. Re-run once the read recovers.');
|
|
125
|
+
lines.push('');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const ci = bundle.ci || {};
|
|
130
|
+
const failing = Array.isArray(ci.failing) ? ci.failing : [];
|
|
131
|
+
const pending = Array.isArray(ci.pending) ? ci.pending : [];
|
|
132
|
+
lines.push('### Checks');
|
|
133
|
+
if (failing.length === 0 && pending.length === 0) {
|
|
134
|
+
lines.push('✅ No failing or pending checks.');
|
|
135
|
+
} else {
|
|
136
|
+
if (failing.length > 0) {
|
|
137
|
+
lines.push(`- ❌ **Failing (${failing.length}):** ${failing.map((check) => mdCode(check.name || '?')).join(', ')}`);
|
|
138
|
+
}
|
|
139
|
+
if (pending.length > 0) {
|
|
140
|
+
lines.push(`- ⏳ **Pending (${pending.length}):** ${pending.map((check) => mdCode(check.name || '?')).join(', ')}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
lines.push('');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Render the PR monitor's Actions job summary.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} bundle - a `gatherPrBundle` result (lib/pr-bundle.js)
|
|
150
|
+
* @param {object} [opts]
|
|
151
|
+
* @param {Date} [opts.now] - injected clock for deterministic output
|
|
152
|
+
* @param {string} [opts.verdict] - canonical `--pull` verdict
|
|
153
|
+
* @param {string[]} [opts.unreadable] - unreadable signal names from `--pull`
|
|
154
|
+
* @param {string|number} [opts.pr] - PR number for the CLI diagnostics hint
|
|
155
|
+
* @returns {{ body: string }}
|
|
156
|
+
*/
|
|
157
|
+
function renderSummary(bundle = {}, opts = {}) {
|
|
158
|
+
const now = opts.now instanceof Date ? opts.now : new Date();
|
|
159
|
+
const lines = ['## 🔭 Forge PR Monitor', ''];
|
|
160
|
+
const verdict = String(opts.verdict || '').toUpperCase();
|
|
161
|
+
const isUnknown = verdict === 'UNKNOWN' || !VERDICT_HEADLINE[verdict];
|
|
162
|
+
const unreadable = Array.isArray(opts.unreadable) ? opts.unreadable.filter(Boolean) : [];
|
|
163
|
+
|
|
164
|
+
lines.push(verdictHeadline(opts.verdict));
|
|
165
|
+
if (isUnknown && unreadable.length > 0) {
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push(`> Unreadable signal(s): ${unreadable.map((signal) => mdCode(signal)).join(', ')}.`);
|
|
168
|
+
}
|
|
169
|
+
lines.push('');
|
|
170
|
+
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._');
|
|
171
|
+
lines.push('');
|
|
172
|
+
|
|
173
|
+
renderThreads(bundle, lines);
|
|
174
|
+
renderChecks(bundle, lines);
|
|
175
|
+
|
|
176
|
+
const branch = bundle.branch || {};
|
|
177
|
+
if ((branch.behind || 0) > 0) {
|
|
178
|
+
lines.push(`> Branch is **${branch.behind}** commit(s) behind base.`);
|
|
179
|
+
lines.push('');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const pr = opts.pr || bundle.pr || '<pr>';
|
|
183
|
+
lines.push('---');
|
|
184
|
+
lines.push(`Detailed JSON: ${mdCode(`forge shepherd ${pr} --pull --json`)}`);
|
|
185
|
+
lines.push(`_Updated ${now.toISOString()} · summary-only monitor · labels state, never merges, never resolves threads._`);
|
|
186
|
+
|
|
187
|
+
return { body: lines.join('\n') };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
renderSummary,
|
|
192
|
+
verdictHeadline,
|
|
193
|
+
groupByAuthor,
|
|
194
|
+
threadLocator,
|
|
195
|
+
MAX_THREADS_PER_AUTHOR,
|
|
196
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
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
|
+
// Must exceed the longest synchronous daemon read (`gh pr list`, 30s) with
|
|
41
|
+
// enough margin that a blocked event loop cannot be reclaimed mid-read.
|
|
42
|
+
const STALE_MS = 90000;
|
|
43
|
+
const LOCK_FILE_MODE = 0o600;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the shared lock path. `gitCommonDir` may be injected (tests, or a
|
|
47
|
+
* caller that already resolved it); otherwise it is resolved from `projectRoot`
|
|
48
|
+
* with the same resolver the kernel broker uses.
|
|
49
|
+
*/
|
|
50
|
+
function lockFilePath(projectRoot, opts = {}) {
|
|
51
|
+
const gitCommonDir = opts.gitCommonDir
|
|
52
|
+
? path.resolve(opts.gitCommonDir)
|
|
53
|
+
: resolveGitCommonDir(projectRoot, opts);
|
|
54
|
+
return path.join(gitCommonDir, 'forge', 'shepherd.lock');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Parse the lock payload, or null when missing/unreadable/corrupt. */
|
|
58
|
+
function readLock(file) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Is `pid` a live process? `process.kill(pid, 0)` sends no signal but throws
|
|
67
|
+
// ESRCH when the pid is gone. EPERM means it exists but isn't ours — still live.
|
|
68
|
+
function pidAlive(pid) {
|
|
69
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 0);
|
|
72
|
+
return true;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return err.code === 'EPERM';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Is a held lock stale (dead owner, or heartbeat older than STALE_MS)? */
|
|
79
|
+
function isHeldStale(held, { isAlive, now }) {
|
|
80
|
+
if (!isAlive(held.pid)) return true;
|
|
81
|
+
const beat = Date.parse(held.heartbeatAt);
|
|
82
|
+
if (!Number.isFinite(beat)) return true;
|
|
83
|
+
return now() - beat >= STALE_MS;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A lock is "ours" iff its unique lease `token` matches the one `acquire`
|
|
88
|
+
* returned. Token is authoritative: a pid can be reused after a crash/reboot and
|
|
89
|
+
* a wedged owner can revive after being reclaimed, so a pid match alone is NOT
|
|
90
|
+
* proof of ownership. Only a hypothetical legacy lock with no `token` field falls
|
|
91
|
+
* back to pid comparison.
|
|
92
|
+
*/
|
|
93
|
+
function ownsLock(held, { token, pid }) {
|
|
94
|
+
if (held.token) return token !== undefined && held.token === token;
|
|
95
|
+
return held.pid === pid;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function writeLock(file, payload) {
|
|
99
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
100
|
+
fs.writeFileSync(file, JSON.stringify(payload), { mode: LOCK_FILE_MODE });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Atomically create the lock with O_EXCL and write `payload`. Returns true when
|
|
105
|
+
* we won the create, false on EEXIST (a concurrent contender holds it); other
|
|
106
|
+
* errors rethrow. The O_EXCL create is the single atomic arbiter — of N racers
|
|
107
|
+
* attempting it against the same absent path, exactly one succeeds.
|
|
108
|
+
*/
|
|
109
|
+
function tryExclusiveCreate(file, payload) {
|
|
110
|
+
let fd;
|
|
111
|
+
try {
|
|
112
|
+
fd = fs.openSync(file, 'wx', LOCK_FILE_MODE);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err.code === 'EEXIST') return false;
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
fs.writeSync(fd, JSON.stringify(payload));
|
|
119
|
+
} finally {
|
|
120
|
+
fs.closeSync(fd);
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Try to claim the singleton lease.
|
|
127
|
+
* -> { ok:true, file, token } first claim
|
|
128
|
+
* -> { ok:true, file, token, reclaimed } stale lock (dead/wedged owner) reclaimed
|
|
129
|
+
* -> { ok:false, held } a LIVE, FRESH foreign owner holds it
|
|
130
|
+
* -> { ok:false, held } we lost the atomic takeover race
|
|
131
|
+
*
|
|
132
|
+
* `pid`/`isAlive`/`now`/`token` are injectable for testing. `onBeforeTakeover` is
|
|
133
|
+
* a test seam invoked AFTER the stale lock is removed and BEFORE our exclusive
|
|
134
|
+
* re-create, so a test can simulate a competitor winning the O_EXCL create first
|
|
135
|
+
* (making our takeover lose).
|
|
136
|
+
*/
|
|
137
|
+
function acquire(projectRoot, {
|
|
138
|
+
gitCommonDir,
|
|
139
|
+
pid = process.pid,
|
|
140
|
+
isAlive = pidAlive,
|
|
141
|
+
now = () => Date.now(),
|
|
142
|
+
token = crypto.randomUUID(),
|
|
143
|
+
onBeforeTakeover = null,
|
|
144
|
+
} = {}) {
|
|
145
|
+
const file = lockFilePath(projectRoot, { gitCommonDir });
|
|
146
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
147
|
+
const iso = new Date(now()).toISOString();
|
|
148
|
+
const payload = { pid, token, startedAt: iso, heartbeatAt: iso, watchers: [] };
|
|
149
|
+
|
|
150
|
+
// Fast path: atomic exclusive create. Only ONE caller can win O_EXCL.
|
|
151
|
+
if (tryExclusiveCreate(file, payload)) {
|
|
152
|
+
return { ok: true, file, token };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A lock exists. A DIFFERENT, live, fresh owner blocks us.
|
|
156
|
+
const held = readLock(file);
|
|
157
|
+
if (held && held.pid !== pid && !isHeldStale(held, { isAlive, now })) {
|
|
158
|
+
return { ok: false, held };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Stale (dead/wedged owner, unreadable, or already ours). Take over ATOMICALLY:
|
|
162
|
+
// remove the stale lock, then re-create it with O_EXCL. If a competitor
|
|
163
|
+
// recreated it first, our exclusive create fails (EEXIST) and we back off — so
|
|
164
|
+
// two racers reclaiming the same stale lock can NEVER both win. A wedged owner
|
|
165
|
+
// that revives after we delete its lock is stopped by the per-lease `token`
|
|
166
|
+
// guard on stamp()/updateWatchers()/release(), never able to resurrect it here.
|
|
167
|
+
fs.rmSync(file, { force: true });
|
|
168
|
+
if (typeof onBeforeTakeover === 'function') onBeforeTakeover();
|
|
169
|
+
if (!tryExclusiveCreate(file, payload)) {
|
|
170
|
+
return { ok: false, held: readLock(file) };
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, file, token, reclaimed: true };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Refresh `heartbeatAt` on OUR lock. Returns false (a no-op) when the lock is
|
|
177
|
+
* missing or NOT ours by `token` — we never stamp a lease we no longer own, so a
|
|
178
|
+
* revived wedged owner cannot resurrect a reclaimed lease.
|
|
179
|
+
*/
|
|
180
|
+
function stamp(projectRoot, { gitCommonDir, token, pid = process.pid, now = () => Date.now() } = {}) {
|
|
181
|
+
const file = lockFilePath(projectRoot, { gitCommonDir });
|
|
182
|
+
const held = readLock(file);
|
|
183
|
+
if (!held || !ownsLock(held, { token, pid })) return false;
|
|
184
|
+
held.heartbeatAt = new Date(now()).toISOString();
|
|
185
|
+
writeLock(file, held);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Start a heartbeat timer that stamps `heartbeatAt` every STALE_MS/3. The timer
|
|
191
|
+
* is `.unref()`ed so it never keeps the process alive. `opts` MUST carry the
|
|
192
|
+
* `token` returned by `acquire` (threaded straight through to `stamp`). Returns
|
|
193
|
+
* the handle for `stopHeartbeat`.
|
|
194
|
+
*/
|
|
195
|
+
function startHeartbeat(projectRoot, opts = {}) {
|
|
196
|
+
const timer = setInterval(() => stamp(projectRoot, opts), Math.max(1, Math.floor(STALE_MS / 3)));
|
|
197
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
198
|
+
return timer;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Stop a heartbeat timer started by `startHeartbeat`. */
|
|
202
|
+
function stopHeartbeat(timer) {
|
|
203
|
+
if (timer) clearInterval(timer);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Rewrite the `watchers[]` array on OUR lock. Returns false when the lock is
|
|
208
|
+
* missing or NOT ours by `token`.
|
|
209
|
+
*/
|
|
210
|
+
function updateWatchers(projectRoot, prNumbers, { gitCommonDir, token, pid = process.pid } = {}) {
|
|
211
|
+
const file = lockFilePath(projectRoot, { gitCommonDir });
|
|
212
|
+
const held = readLock(file);
|
|
213
|
+
if (!held || !ownsLock(held, { token, pid })) return false;
|
|
214
|
+
held.watchers = Array.isArray(prNumbers) ? prNumbers : [];
|
|
215
|
+
writeLock(file, held);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Release the lease — delete the lock ONLY when it is ours by `token`, so a
|
|
221
|
+
* foreign or already-reclaimed lock is never removed out from under its owner
|
|
222
|
+
* (and a reused pid can never delete someone else's lease).
|
|
223
|
+
*/
|
|
224
|
+
function release(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
|
|
225
|
+
const file = lockFilePath(projectRoot, { gitCommonDir });
|
|
226
|
+
try {
|
|
227
|
+
const held = readLock(file);
|
|
228
|
+
if (held && ownsLock(held, { token, pid })) fs.rmSync(file, { force: true });
|
|
229
|
+
} catch {
|
|
230
|
+
/* best effort — a failed release just leaves a stale lock to be reclaimed */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** True only while this exact pid+token still owns the shared lease. */
|
|
235
|
+
function owns(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
|
|
236
|
+
const held = readLock(lockFilePath(projectRoot, { gitCommonDir }));
|
|
237
|
+
return Boolean(held && ownsLock(held, { token, pid }));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = {
|
|
241
|
+
STALE_MS,
|
|
242
|
+
lockFilePath,
|
|
243
|
+
pidAlive,
|
|
244
|
+
ownsLock,
|
|
245
|
+
acquire,
|
|
246
|
+
stamp,
|
|
247
|
+
startHeartbeat,
|
|
248
|
+
stopHeartbeat,
|
|
249
|
+
updateWatchers,
|
|
250
|
+
release,
|
|
251
|
+
owns,
|
|
252
|
+
};
|
|
@@ -20,6 +20,7 @@ const path = require('node:path');
|
|
|
20
20
|
const { spawn, execFileSync } = require('node:child_process');
|
|
21
21
|
|
|
22
22
|
const journal = require('./journal');
|
|
23
|
+
const brokerMod = require('../kernel/broker');
|
|
23
24
|
|
|
24
25
|
/** Absolute path to the forge CLI entrypoint (this file is lib/pr-monitor/). */
|
|
25
26
|
function forgeBin() {
|
|
@@ -35,7 +36,7 @@ function forgeBin() {
|
|
|
35
36
|
function defaultResolveSlug({ cwd, exec = execFileSync }) {
|
|
36
37
|
try {
|
|
37
38
|
const url = exec('git', ['remote', 'get-url', 'origin'], {
|
|
38
|
-
cwd, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
39
|
+
cwd, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
|
|
39
40
|
}).trim();
|
|
40
41
|
const match = /[/:][^/]+\/([^/]+?)(?:\.git)?$/.exec(url);
|
|
41
42
|
return match ? match[1] : null;
|
|
@@ -66,7 +67,18 @@ function startPrWatcherDetached(opts = {}) {
|
|
|
66
67
|
|
|
67
68
|
const slug = resolveSlug({ cwd, exec: opts.exec });
|
|
68
69
|
if (slug) {
|
|
69
|
-
|
|
70
|
+
let gitCommonDir = opts.gitCommonDir;
|
|
71
|
+
if (!gitCommonDir) {
|
|
72
|
+
try {
|
|
73
|
+
const resolveGitCommonDir = opts.resolveGitCommonDir || brokerMod.resolveGitCommonDir;
|
|
74
|
+
gitCommonDir = resolveGitCommonDir(cwd, { warn: () => {} });
|
|
75
|
+
} catch {
|
|
76
|
+
/* unavailable common-dir keeps the legacy per-root journal fallback */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const dir = journalMod.journalDir({
|
|
80
|
+
root: cwd, gitCommonDir, repo: slug, pr: prNumber,
|
|
81
|
+
});
|
|
70
82
|
if (journalMod.watcherRunning(dir)) return { started: false, reason: 'already-running' };
|
|
71
83
|
}
|
|
72
84
|
|