@dev-loops/core 0.1.0

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 (54) hide show
  1. package/bin/capture-deep-persona-signals.mjs +143 -0
  2. package/bin/ensure-phase-files.mjs +7 -0
  3. package/bin/log-bash-exit-1.mjs +7 -0
  4. package/bin/parse-review-threads.mjs +7 -0
  5. package/package.json +78 -0
  6. package/src/analysis/change-classifier.mjs +146 -0
  7. package/src/analysis/diff-analyzer.mjs +285 -0
  8. package/src/bash-exit-one.mjs +130 -0
  9. package/src/cli/helpers.mjs +22 -0
  10. package/src/cli/primitives.mjs +70 -0
  11. package/src/cli/retry-wrapper.mjs +169 -0
  12. package/src/cli/subcommand-runner.mjs +246 -0
  13. package/src/config/config.mjs +965 -0
  14. package/src/debt/cluster.mjs +240 -0
  15. package/src/debt/debt-finding.mjs +68 -0
  16. package/src/debt/debt-signal.mjs +46 -0
  17. package/src/debt/deep-persona-signals.mjs +266 -0
  18. package/src/debt/remediation-to-issue.mjs +121 -0
  19. package/src/debt/score.mjs +127 -0
  20. package/src/debt/shape.mjs +214 -0
  21. package/src/github/copilot-helpers.mjs +343 -0
  22. package/src/github/repo-slug.mjs +105 -0
  23. package/src/github/review-threads.mjs +343 -0
  24. package/src/harness/adapter.mjs +57 -0
  25. package/src/harness/index.mjs +3 -0
  26. package/src/harness/noop-adapter.mjs +22 -0
  27. package/src/harness/pi-adapter.mjs +47 -0
  28. package/src/loop/async-start-contract.mjs +170 -0
  29. package/src/loop/conductor-routing.mjs +817 -0
  30. package/src/loop/copilot-ci-status.mjs +255 -0
  31. package/src/loop/copilot-loop-iterations.mjs +161 -0
  32. package/src/loop/copilot-loop-state.mjs +510 -0
  33. package/src/loop/handoff-envelope.mjs +800 -0
  34. package/src/loop/issue-refinement-artifact.mjs +268 -0
  35. package/src/loop/lifecycle-state.mjs +342 -0
  36. package/src/loop/phase-files.mjs +187 -0
  37. package/src/loop/policy-constants.mjs +17 -0
  38. package/src/loop/pr-gate-coordination.mjs +1278 -0
  39. package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
  40. package/src/loop/public-dev-loop-routing.mjs +1746 -0
  41. package/src/loop/queue-board-ordering.mjs +38 -0
  42. package/src/loop/queue-board-sync.mjs +223 -0
  43. package/src/loop/queue-driver.mjs +164 -0
  44. package/src/loop/queue-parallel.mjs +190 -0
  45. package/src/loop/queue-state.mjs +230 -0
  46. package/src/loop/retrospective-checkpoint.mjs +178 -0
  47. package/src/loop/reviewer-loop-state.mjs +456 -0
  48. package/src/loop/run-inspection.mjs +604 -0
  49. package/src/loop/steering.mjs +793 -0
  50. package/src/loop/timeout-policy.mjs +73 -0
  51. package/src/loop/tracker-first-loop-state.mjs +87 -0
  52. package/src/loop/tracker-pr-state.mjs +301 -0
  53. package/src/loop/worktree-guard.mjs +141 -0
  54. package/src/refinement/ac-dod-matrix.mjs +95 -0
@@ -0,0 +1,73 @@
1
+ export const DEV_LOOP_TIMEOUT_CLASSIFICATION = Object.freeze({
2
+ PERSISTENT_INTERNAL_WAIT: "persistent_internal_wait",
3
+ EXTERNAL_HEALTHY_WAIT: "external_healthy_wait",
4
+ PROBE_STATUS: "probe_status",
5
+ });
6
+
7
+ export const PERSISTENT_INTERNAL_WAIT_TIMEOUT_POLICY = Object.freeze({
8
+ classification: DEV_LOOP_TIMEOUT_CLASSIFICATION.PERSISTENT_INTERNAL_WAIT,
9
+ minimumTimeoutMs: 3_600_000,
10
+ defaultTimeoutMs: 3_600_000,
11
+ });
12
+
13
+ export const EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY = Object.freeze({
14
+ classification: DEV_LOOP_TIMEOUT_CLASSIFICATION.EXTERNAL_HEALTHY_WAIT,
15
+ minimumTimeoutMs: 1_800_000,
16
+ defaultTimeoutMs: 1_800_000,
17
+ });
18
+
19
+ function formatTimeoutDuration(timeoutMs) {
20
+ if (timeoutMs === 3_600_000) {
21
+ return "1 hour";
22
+ }
23
+
24
+ if (timeoutMs === 1_800_000) {
25
+ return "30 minutes";
26
+ }
27
+
28
+ if (timeoutMs === 86_400_000) {
29
+ return "24 hours";
30
+ }
31
+
32
+ return `${timeoutMs} ms`;
33
+ }
34
+
35
+ function enforceTimeoutPolicy(policy, { timeoutMs = policy.defaultTimeoutMs, explicitProbe = false, contextLabel }) {
36
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 0) {
37
+ throw new Error(`${contextLabel} timeout must be a non-negative integer`);
38
+ }
39
+
40
+ if (explicitProbe) {
41
+ return 0;
42
+ }
43
+
44
+ if (timeoutMs === 0) {
45
+ throw new Error(`${contextLabel} uses the persistent unattended timeout contract; use an explicit probe/status path instead of timeout 0.`);
46
+ }
47
+
48
+ if (timeoutMs < policy.minimumTimeoutMs) {
49
+ throw new Error(`${contextLabel} requires at least ${policy.minimumTimeoutMs} ms (${formatTimeoutDuration(policy.minimumTimeoutMs)}) for persistent unattended waits; received ${timeoutMs}.`);
50
+ }
51
+
52
+ return timeoutMs;
53
+ }
54
+
55
+ export function enforcePersistentInternalWaitTimeout(options = {}) {
56
+ return enforceTimeoutPolicy(
57
+ PERSISTENT_INTERNAL_WAIT_TIMEOUT_POLICY,
58
+ {
59
+ contextLabel: "Persistent internal wait",
60
+ ...options,
61
+ },
62
+ );
63
+ }
64
+
65
+ export function enforceExternalHealthyWaitTimeout(options = {}) {
66
+ return enforceTimeoutPolicy(
67
+ EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
68
+ {
69
+ contextLabel: "External healthy wait",
70
+ ...options,
71
+ },
72
+ );
73
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Tracker-first loop state machine — pure logic layer.
3
+ *
4
+ * Standalone interpreter that maps a raw tracker/issue state string
5
+ * (plus optional PR context) to a canonical loop state with the same
6
+ * { ok, state, snapshot, allowedTransitions, nextAction } interface as
7
+ * detect-copilot-loop-state.mjs.
8
+ *
9
+ * Fail-closed contract: unknown/ambiguous tracker state maps to `needs_triage`,
10
+ * not to the canonical `unknown` state. Only an explicit `trackerState: "unknown"`
11
+ * produces the `unknown` canonical state.
12
+ */
13
+
14
+ /** @typedef {"drafting"|"needs_triage"|"in_progress"|"in_review"|"merge_ready"|"blocked"|"completed"|"unknown"} TrackerState */
15
+
16
+ /** @type {readonly TrackerState[]} */
17
+ export const TRACKER_STATES = Object.freeze([
18
+ "drafting",
19
+ "needs_triage",
20
+ "in_progress",
21
+ "in_review",
22
+ "merge_ready",
23
+ "blocked",
24
+ "completed",
25
+ "unknown",
26
+ ]);
27
+
28
+ /** @type {Readonly<Record<TrackerState, readonly TrackerState[]>>} */
29
+ export const TRACKER_TRANSITIONS = Object.freeze({
30
+ drafting: ["needs_triage", "blocked", "unknown"],
31
+ needs_triage: ["in_progress", "blocked", "drafting", "unknown"],
32
+ in_progress: ["in_review", "blocked", "needs_triage", "unknown"],
33
+ in_review: ["merge_ready", "in_progress", "blocked", "unknown"],
34
+ merge_ready: ["completed", "in_review", "blocked", "unknown"],
35
+ blocked: ["needs_triage", "in_progress", "in_review", "drafting", "unknown"],
36
+ completed: ["unknown"],
37
+ unknown: Object.freeze([...TRACKER_STATES]),
38
+ });
39
+
40
+ /**
41
+ * Build a tracker-first loop state snapshot from PR-level tracker data.
42
+ *
43
+ * @param {object} input
44
+ * @param {string} input.trackerState - Raw tracker/issue state (e.g. from gh issue view --jq .state)
45
+ * @param {object} [input.prContext] - Optional PR context (linked PR, CI status)
46
+ * @returns {{ ok: true, state: TrackerState, snapshot: object, allowedTransitions: readonly TrackerState[], nextAction: string }}
47
+ */
48
+ export function interpretTrackerLoopState(input) {
49
+ const raw = input.trackerState;
50
+
51
+ // Map raw tracker states to canonical states.
52
+ // Fail-closed: unrecognized/ambiguous states → needs_triage.
53
+ // Only explicit "unknown" → the canonical `unknown` state.
54
+ const lower = (raw || "").toLowerCase().trim();
55
+ let state = /** @type {TrackerState} */ ("needs_triage");
56
+
57
+ if (lower === "draft" || lower === "drafting") state = "drafting";
58
+ else if (lower === "open" || lower === "needs_triage") state = "needs_triage";
59
+ else if (lower === "in_progress" || lower === "in progress") state = "in_progress";
60
+ else if (lower === "in_review" || lower === "review") state = "in_review";
61
+ else if (lower === "merge_ready" || lower === "ready") state = "merge_ready";
62
+ else if (lower === "blocked") state = "blocked";
63
+ else if (lower === "closed" || lower === "completed" || lower === "done") state = "completed";
64
+ else if (lower === "unknown") state = "unknown";
65
+
66
+ const allowedTransitions = TRACKER_TRANSITIONS[state];
67
+ const snapshot = {
68
+ trackerState: state,
69
+ rawTrackerState: raw,
70
+ prLinked: Boolean(input.prContext),
71
+ prContext: input.prContext || null,
72
+ };
73
+
74
+ let nextAction = "inspect";
75
+ switch (state) {
76
+ case "drafting": nextAction = "triage_or_block"; break;
77
+ case "needs_triage": nextAction = "start_work"; break;
78
+ case "in_progress": nextAction = "review"; break;
79
+ case "in_review": nextAction = "merge_or_fix"; break;
80
+ case "merge_ready": nextAction = "merge"; break;
81
+ case "blocked": nextAction = "resolve_blocker"; break;
82
+ case "completed": nextAction = "done"; break;
83
+ default: nextAction = "reconcile"; break;
84
+ }
85
+
86
+ return { ok: true, state, snapshot, allowedTransitions, nextAction };
87
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Deterministic state machine for the tracker-first story-to-PR lifecycle.
3
+ *
4
+ * This module provides:
5
+ * - TRACKER_PR_STATE: stable state name constants
6
+ * - normalizeTrackerPrSnapshot: validate and canonicalize a raw snapshot
7
+ * - interpretTrackerPrState: map a snapshot to one current state + allowed transitions + next action
8
+ * - REVERSE_SYNC_ACTION: canonical reverse-sync action for each state
9
+ *
10
+ * The transition graph (`TRACKER_PR_TRANSITIONS`) is an internal implementation detail.
11
+ * Callers obtain the allowed transitions for a specific state from the
12
+ * `allowedTransitions` field returned by `interpretTrackerPrState`.
13
+ *
14
+ * MVP invariant: one tracker work item -> one GitHub PR.
15
+ *
16
+ * The state machine captures observable facts (tracker item identity, PR lifecycle)
17
+ * and maps them deterministically to exactly one current state, a list of allowed
18
+ * next transitions, a recommended next action, and the canonical reverse-sync action
19
+ * that should be applied to the tracker when entering that state.
20
+ *
21
+ * This snapshot intentionally does not encode tracker-native workflow readiness.
22
+ * Higher-level callers may combine tracker-owned readiness or blocked/done state
23
+ * with this helper when deciding whether PR creation is appropriate.
24
+ *
25
+ * Source-of-truth ownership:
26
+ * - Tracker: work-item identity, planning hierarchy, and tracker-native state
27
+ * - GitHub: PR lifecycle, review state, CI/check results, and merge facts
28
+ * - dev-loops: projection and sync logic only; never the canonical owner of
29
+ * business fields
30
+ */
31
+
32
+ /** Stable state name constants for the tracker-first story-to-PR lifecycle. */
33
+ export const TRACKER_PR_STATE = Object.freeze({
34
+ /**
35
+ * No tracker work item was found. Nothing can proceed without a valid
36
+ * tracker item to anchor the PR.
37
+ */
38
+ NO_TRACKER_ITEM: "no_tracker_item",
39
+
40
+ /**
41
+ * A tracker work item exists and no PR has been created for it yet.
42
+ * This helper does not infer tracker-native readiness beyond that no-PR
43
+ * execution fact.
44
+ */
45
+ READY_NO_PR: "ready_no_pr",
46
+
47
+ /**
48
+ * A draft PR exists for the tracker work item. The tracker should reflect
49
+ * an in-progress state. PR metadata must include the required tracker
50
+ * identifier/link and follow the deterministic title/body projection rules.
51
+ */
52
+ DRAFT_PR_OPEN: "draft_pr_open",
53
+
54
+ /**
55
+ * The PR has been marked ready for review (no longer draft). The tracker
56
+ * should reflect a reviewable / in-review state.
57
+ */
58
+ PR_REVIEWABLE: "pr_reviewable",
59
+
60
+ /**
61
+ * The PR has been merged. This is the terminal success state. The tracker
62
+ * should be moved to done/completed.
63
+ */
64
+ PR_MERGED: "pr_merged",
65
+
66
+ /**
67
+ * The PR was closed without being merged. There is no automatic tracker
68
+ * state transition for this event by default. A human decision is required.
69
+ */
70
+ PR_CLOSED_UNMERGED: "pr_closed_unmerged",
71
+
72
+ /**
73
+ * Stop state for ambiguous or contradictory lifecycle snapshots that need
74
+ * an explicit user decision before the workflow can continue.
75
+ */
76
+ BLOCKED_NEEDS_USER_DECISION: "blocked_needs_user_decision",
77
+ });
78
+
79
+ /**
80
+ * Legal transitions for each state.
81
+ * The agent layer selects among allowed transitions; the state machine enforces
82
+ * the graph.
83
+ *
84
+ * Internal implementation detail. Callers receive the allowed transitions for a
85
+ * given state via the `allowedTransitions` field of `interpretTrackerPrState`.
86
+ */
87
+ const TRACKER_PR_TRANSITIONS = Object.freeze({
88
+ [TRACKER_PR_STATE.NO_TRACKER_ITEM]: [],
89
+ [TRACKER_PR_STATE.READY_NO_PR]: [TRACKER_PR_STATE.DRAFT_PR_OPEN],
90
+ [TRACKER_PR_STATE.DRAFT_PR_OPEN]: [TRACKER_PR_STATE.PR_REVIEWABLE],
91
+ [TRACKER_PR_STATE.PR_REVIEWABLE]: [
92
+ TRACKER_PR_STATE.PR_MERGED,
93
+ TRACKER_PR_STATE.PR_CLOSED_UNMERGED,
94
+ TRACKER_PR_STATE.DRAFT_PR_OPEN,
95
+ ],
96
+ [TRACKER_PR_STATE.PR_MERGED]: [],
97
+ [TRACKER_PR_STATE.PR_CLOSED_UNMERGED]: [
98
+ TRACKER_PR_STATE.READY_NO_PR,
99
+ TRACKER_PR_STATE.BLOCKED_NEEDS_USER_DECISION,
100
+ ],
101
+ [TRACKER_PR_STATE.BLOCKED_NEEDS_USER_DECISION]: [],
102
+ });
103
+
104
+ /**
105
+ * Canonical reverse-sync action for each state.
106
+ *
107
+ * Each value names the tracker-side transition that should be applied when
108
+ * the lifecycle enters that state. Adapter implementations map these canonical
109
+ * action names to tracker-native field updates.
110
+ *
111
+ * "none" means no automatic tracker state mutation is required.
112
+ */
113
+ export const REVERSE_SYNC_ACTION = Object.freeze({
114
+ [TRACKER_PR_STATE.NO_TRACKER_ITEM]: "none",
115
+ [TRACKER_PR_STATE.READY_NO_PR]: "none",
116
+ [TRACKER_PR_STATE.DRAFT_PR_OPEN]: "set_in_progress",
117
+ [TRACKER_PR_STATE.PR_REVIEWABLE]: "set_reviewable",
118
+ [TRACKER_PR_STATE.PR_MERGED]: "set_done",
119
+ [TRACKER_PR_STATE.PR_CLOSED_UNMERGED]: "none",
120
+ [TRACKER_PR_STATE.BLOCKED_NEEDS_USER_DECISION]: "none",
121
+ });
122
+
123
+ const GATE_REVIEW_VERDICT_SET = new Set(["clean", "findings_present", "blocked"]);
124
+
125
+ function normalizeSha(value) {
126
+ return typeof value === "string" && value.trim().length > 0
127
+ ? value.trim()
128
+ : null;
129
+ }
130
+
131
+ function normalizeGateReviewVerdict(value) {
132
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
133
+ return GATE_REVIEW_VERDICT_SET.has(normalized) ? normalized : null;
134
+ }
135
+
136
+ function hasCleanVisibleCurrentHeadDraftGate(snapshot) {
137
+ return snapshot.prHeadSha !== null
138
+ && snapshot.draftGateCommentVisible
139
+ && snapshot.draftGateCommentVerdict === "clean"
140
+ && snapshot.draftGateCommentHeadSha === snapshot.prHeadSha;
141
+ }
142
+
143
+
144
+ function normalizeBooleanLike(value) {
145
+ if (typeof value === "boolean") {
146
+ return value;
147
+ }
148
+
149
+ if (typeof value === "number") {
150
+ if (value === 1) {
151
+ return true;
152
+ }
153
+
154
+ if (value === 0) {
155
+ return false;
156
+ }
157
+
158
+ return false;
159
+ }
160
+
161
+ if (typeof value === "string") {
162
+ const normalized = value.trim().toLowerCase();
163
+
164
+ if (normalized === "true" || normalized === "1") {
165
+ return true;
166
+ }
167
+
168
+ if (normalized === "false" || normalized === "0" || normalized.length === 0) {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ return false;
174
+ }
175
+
176
+ /** Recommended next action for each state. */
177
+ const NEXT_ACTIONS = Object.freeze({
178
+ [TRACKER_PR_STATE.NO_TRACKER_ITEM]:
179
+ "Obtain a valid tracker work item before creating a PR",
180
+ [TRACKER_PR_STATE.READY_NO_PR]:
181
+ "If tracker workflow says the item is ready, create a draft PR with required tracker metadata (identifier link, title pattern, body sections, labels)",
182
+ [TRACKER_PR_STATE.DRAFT_PR_OPEN]:
183
+ "Complete development work, run draft gate, post a visible clean current-head draft_gate comment, then mark the draft PR as ready for review",
184
+ [TRACKER_PR_STATE.PR_REVIEWABLE]:
185
+ "Wait for review and CI; merge when approved, or convert back to draft if rework is needed",
186
+ [TRACKER_PR_STATE.PR_MERGED]:
187
+ "Sync tracker item to done/completed terminal state",
188
+ [TRACKER_PR_STATE.PR_CLOSED_UNMERGED]:
189
+ "Report to user; no automatic tracker transition — decide whether to reopen, create a new PR, or close the tracker item",
190
+ [TRACKER_PR_STATE.BLOCKED_NEEDS_USER_DECISION]:
191
+ "Report the blocked state to the user and stop; do not proceed without explicit authorization",
192
+ });
193
+
194
+ /**
195
+ * Normalize a raw tracker-PR snapshot into a validated, canonical snapshot.
196
+ *
197
+ * Unknown or invalid field values are replaced with safe defaults.
198
+ * Throws if `raw` is not a non-null object.
199
+ *
200
+ * Snapshot schema:
201
+ * - trackerItemExists {boolean} — whether a tracker work item was found
202
+ * - trackerItemId {string|null} — opaque tracker item identifier (e.g. "PROJ-123")
203
+ * - prExists {boolean} — whether a GitHub PR exists for this item
204
+ * - prNumber {number|null} — PR number if known; treat `prNumber` with `prExists=false` as contradictory input
205
+ * - prDraft {boolean} — whether the PR is in draft state
206
+ * - prMerged {boolean} — whether the PR has been merged
207
+ * - prClosed {boolean} — whether the PR is closed on GitHub (merged PRs are also closed)
208
+ * - prHeadSha {string|null} — current PR head SHA
209
+ * - draftGateCommentVisible {boolean} — whether the draft-gate PR comment is visible on the PR thread
210
+ * - draftGateCommentHeadSha {string|null} — head SHA encoded in the draft-gate PR comment
211
+ * - draftGateCommentVerdict {"clean"|"findings_present"|"blocked"|null} — draft-gate comment verdict
212
+ *
213
+ * @param {object} raw - raw snapshot input
214
+ * @returns {object} normalized snapshot
215
+ */
216
+ export function normalizeTrackerPrSnapshot(raw) {
217
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
218
+ throw new Error("Snapshot must be a non-null object");
219
+ }
220
+
221
+ const trackerItemExists = normalizeBooleanLike(raw.trackerItemExists);
222
+ const prExists = normalizeBooleanLike(raw.prExists);
223
+ const prNumber =
224
+ typeof raw.prNumber === "number" && Number.isInteger(raw.prNumber) && raw.prNumber > 0
225
+ ? raw.prNumber
226
+ : null;
227
+
228
+ return {
229
+ trackerItemExists,
230
+ trackerItemId:
231
+ trackerItemExists && typeof raw.trackerItemId === "string" && raw.trackerItemId.trim().length > 0
232
+ ? raw.trackerItemId.trim()
233
+ : null,
234
+ prExists,
235
+ prNumber,
236
+ prDraft: normalizeBooleanLike(raw.prDraft),
237
+ prMerged: normalizeBooleanLike(raw.prMerged),
238
+ prClosed: normalizeBooleanLike(raw.prClosed),
239
+ prHeadSha: prExists ? normalizeSha(raw.prHeadSha) : null,
240
+ draftGateCommentVisible: normalizeBooleanLike(raw.draftGateCommentVisible),
241
+ draftGateCommentHeadSha: prExists ? normalizeSha(raw.draftGateCommentHeadSha) : null,
242
+ draftGateCommentVerdict: normalizeGateReviewVerdict(raw.draftGateCommentVerdict),
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Interpret a tracker-PR lifecycle snapshot into one current state, allowed
248
+ * next transitions, a recommended next action, and the canonical reverse-sync
249
+ * action for the tracker.
250
+ *
251
+ * Interpretation is deterministic: the same snapshot always yields the same
252
+ * result. The function normalizes the snapshot before interpreting, so raw
253
+ * inputs are accepted.
254
+ *
255
+ * Routing priority:
256
+ * 1. Contradictory snapshot -> blocked_needs_user_decision
257
+ * 2. No tracker item and no PR facts -> no_tracker_item (nothing to anchor a PR to)
258
+ * 3. PR merged -> pr_merged (terminal success)
259
+ * 4. PR closed without merge -> pr_closed_unmerged (terminal, no auto-sync; `prClosed && !prMerged`, even if the closed PR still reports draft)
260
+ * 5. Draft PR exists -> draft_pr_open (in-progress)
261
+ * 6. PR exists and not draft, not merged, not closed -> pr_reviewable
262
+ * 7. Tracker item exists and no PR exists -> ready_no_pr (no-PR execution state)
263
+ *
264
+ * @param {object} snapshot - raw or normalized snapshot
265
+ * @returns {{ state: string, allowedTransitions: string[], nextAction: string, reverseSyncAction: string }}
266
+ */
267
+ export function interpretTrackerPrState(snapshot) {
268
+ const s = normalizeTrackerPrSnapshot(snapshot);
269
+
270
+ const contradictorySnapshot =
271
+ (!s.trackerItemExists && (s.prExists || s.prNumber !== null || s.prDraft || s.prMerged || s.prClosed)) ||
272
+ (!s.prExists && (s.prNumber !== null || s.prDraft || s.prMerged || s.prClosed)) ||
273
+ (s.prMerged && s.prDraft);
274
+
275
+ let state;
276
+
277
+ if (contradictorySnapshot) {
278
+ state = TRACKER_PR_STATE.BLOCKED_NEEDS_USER_DECISION;
279
+ } else if (!s.trackerItemExists) {
280
+ state = TRACKER_PR_STATE.NO_TRACKER_ITEM;
281
+ } else if (s.prExists && s.prMerged) {
282
+ state = TRACKER_PR_STATE.PR_MERGED;
283
+ } else if (s.prExists && s.prClosed) {
284
+ state = TRACKER_PR_STATE.PR_CLOSED_UNMERGED;
285
+ } else if (s.prExists && s.prDraft) {
286
+ state = TRACKER_PR_STATE.DRAFT_PR_OPEN;
287
+ } else if (s.prExists) {
288
+ state = hasCleanVisibleCurrentHeadDraftGate(s)
289
+ ? TRACKER_PR_STATE.PR_REVIEWABLE
290
+ : TRACKER_PR_STATE.BLOCKED_NEEDS_USER_DECISION;
291
+ } else {
292
+ state = TRACKER_PR_STATE.READY_NO_PR;
293
+ }
294
+
295
+ return {
296
+ state,
297
+ allowedTransitions: [...TRACKER_PR_TRANSITIONS[state]],
298
+ nextAction: NEXT_ACTIONS[state],
299
+ reverseSyncAction: REVERSE_SYNC_ACTION[state],
300
+ };
301
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Shared worktree and subagent guard primitives.
3
+ *
4
+ * Extracted from `scripts/loop/pre-commit-branch-guard.mjs` so that both the
5
+ * pre-commit guard and the new pre-flight gate share one implementation.
6
+ *
7
+ * This module is intentionally pure and side-effect free.
8
+ */
9
+
10
+ import { realpathSync } from "node:fs";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Worktree path helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * Check whether `cwd` is under a tmp/worktrees path segment.
18
+ *
19
+ * @param {string} cwd - Absolute or relative path to the current working directory.
20
+ * @returns {boolean}
21
+ */
22
+ export function isUnderWorktreePath(cwd) {
23
+ const normalized = cwd.replace(/\\/g, "/");
24
+ return /(?:^|\/)tmp\/worktrees(?:\/|$)/.test(normalized);
25
+ }
26
+
27
+ /**
28
+ * Parse the main (primary) git worktree path from `git worktree list` output.
29
+ *
30
+ * The first line of `git worktree list` is the primary worktree.
31
+ * Format: `<path> <sha> [<branch>]`
32
+ *
33
+ * @param {string} worktreeListOutput - Raw stdout from `git worktree list`.
34
+ * @returns {string | null} The main worktree path, or null if it cannot be parsed.
35
+ */
36
+ export function parseMainWorktreePath(worktreeListOutput) {
37
+ const firstLine = worktreeListOutput.split("\n")[0].trim();
38
+ if (!firstLine) return null;
39
+ // Find the first hex SHA (7+ chars) preceded by whitespace; take everything before it as the path.
40
+ const shaIdx = firstLine.search(/\s[0-9a-f]{7,64}\b/iu);
41
+ if (shaIdx === -1) return null;
42
+ return firstLine.slice(0, shaIdx).trim();
43
+ }
44
+
45
+ /**
46
+ * Check whether `cwd` is the main git checkout (or a subdirectory of it).
47
+ *
48
+ * @param {string} cwd - Absolute or relative path to the current working directory.
49
+ * @param {string | null} mainWorktreePath - The main worktree path from `parseMainWorktreePath`.
50
+ * @returns {boolean}
51
+ */
52
+ export function isMainCheckout(cwd, mainWorktreePath) {
53
+ if (!mainWorktreePath) return false;
54
+ let resolvedCwd;
55
+ try { resolvedCwd = realpathSync(cwd); } catch { resolvedCwd = cwd; }
56
+ let resolvedMain;
57
+ try { resolvedMain = realpathSync(mainWorktreePath); } catch { resolvedMain = mainWorktreePath; }
58
+ const normalizedCwd = resolvedCwd.replace(/\\/g, "/").replace(/\/+$/u, "");
59
+ const normalizedMain = resolvedMain.replace(/\\/g, "/").replace(/\/+$/u, "");
60
+ return normalizedCwd === normalizedMain || normalizedCwd.startsWith(normalizedMain + "/");
61
+ }
62
+
63
+ /**
64
+ * Parse all worktree paths from `git worktree list` output.
65
+ *
66
+ * Each line in the output has the format `<path> <sha> [<branch>]`.
67
+ * Returns absolute paths (one per worktree), preserving list order.
68
+ *
69
+ * @param {string} worktreeListOutput - Raw stdout from `git worktree list`.
70
+ * @returns {string[]}
71
+ */
72
+ export function parseAllWorktreePaths(worktreeListOutput) {
73
+ const paths = [];
74
+ for (const line of worktreeListOutput.split("\n")) {
75
+ const trimmed = line.trim();
76
+ if (!trimmed) continue;
77
+ const shaIdx = trimmed.search(/\s[0-9a-f]{7,64}\b/iu);
78
+ if (shaIdx === -1) continue;
79
+ paths.push(trimmed.slice(0, shaIdx).trim());
80
+ }
81
+ return paths;
82
+ }
83
+
84
+ /**
85
+ * Check whether `cwd` is listed as a git worktree by `git worktree list`.
86
+ *
87
+ * This is stricter than `isUnderWorktreePath()` — a manually-created
88
+ * `tmp/worktrees/<slug>/` directory inside the main checkout passes
89
+ * `isUnderWorktreePath()` but fails `isListedWorktree()` because it is
90
+ * not a real git worktree.
91
+ *
92
+ * Resolves symlinks via realpathSync so that /var vs /private/var
93
+ * differences on macOS do not cause false negatives.
94
+ *
95
+ * @param {string} cwd - Absolute or relative path to the current working directory.
96
+ * @param {string[]} worktreePaths - Array of paths from `parseAllWorktreePaths`.
97
+ * @returns {boolean}
98
+ */
99
+ export function isListedWorktree(cwd, worktreePaths) {
100
+ let resolvedCwd;
101
+ try { resolvedCwd = realpathSync(cwd); } catch { resolvedCwd = cwd; }
102
+ const normalized = resolvedCwd.replace(/\\/g, "/").replace(/\/+$/u, "");
103
+ return worktreePaths.some((p) => {
104
+ let resolvedP;
105
+ try { resolvedP = realpathSync(p); } catch { resolvedP = p; }
106
+ const normalizedP = resolvedP.replace(/\\/g, "/").replace(/\/+$/u, "");
107
+ // Only match worktree paths that are under tmp/worktrees/ (exclude main checkout).
108
+ if (!isUnderWorktreePath(normalizedP)) return false;
109
+ // Accept exact match or cwd is a subdirectory of a listed worktree root.
110
+ return normalized === normalizedP || normalized.startsWith(normalizedP + "/");
111
+ });
112
+ }
113
+
114
+
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Subagent availability
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Environment variable name checked by `detectSubagentAvailability`.
122
+ *
123
+ * Set `PI_SUBAGENT_AVAILABLE=1` when the runtime supports subagent dispatch.
124
+ * This is consistent with the `PI_WORKTREE_BYPASS` pattern and other repo-local
125
+ * runtime configuration gates already present in the repo.
126
+ */
127
+ export const PI_SUBAGENT_AVAILABLE_VAR = "PI_SUBAGENT_AVAILABLE";
128
+
129
+ /**
130
+ * Detect whether subagent dispatch is available in the current runtime.
131
+ *
132
+ * This is an env-var-based heuristic, consistent with other bypass/availability
133
+ * patterns in the repo. It is intentionally simple — the gate's subagent check
134
+ * is advisory (fails-open) and never hard-blocks on subagent absence.
135
+ *
136
+ * @param {{ env?: Record<string, string | undefined> }} [options]
137
+ * @returns {boolean}
138
+ */
139
+ export function detectSubagentAvailability({ env = process.env } = {}) {
140
+ return (env[PI_SUBAGENT_AVAILABLE_VAR] ?? "").trim() === "1";
141
+ }