@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.
- package/bin/capture-deep-persona-signals.mjs +143 -0
- package/bin/ensure-phase-files.mjs +7 -0
- package/bin/log-bash-exit-1.mjs +7 -0
- package/bin/parse-review-threads.mjs +7 -0
- package/package.json +78 -0
- package/src/analysis/change-classifier.mjs +146 -0
- package/src/analysis/diff-analyzer.mjs +285 -0
- package/src/bash-exit-one.mjs +130 -0
- package/src/cli/helpers.mjs +22 -0
- package/src/cli/primitives.mjs +70 -0
- package/src/cli/retry-wrapper.mjs +169 -0
- package/src/cli/subcommand-runner.mjs +246 -0
- package/src/config/config.mjs +965 -0
- package/src/debt/cluster.mjs +240 -0
- package/src/debt/debt-finding.mjs +68 -0
- package/src/debt/debt-signal.mjs +46 -0
- package/src/debt/deep-persona-signals.mjs +266 -0
- package/src/debt/remediation-to-issue.mjs +121 -0
- package/src/debt/score.mjs +127 -0
- package/src/debt/shape.mjs +214 -0
- package/src/github/copilot-helpers.mjs +343 -0
- package/src/github/repo-slug.mjs +105 -0
- package/src/github/review-threads.mjs +343 -0
- package/src/harness/adapter.mjs +57 -0
- package/src/harness/index.mjs +3 -0
- package/src/harness/noop-adapter.mjs +22 -0
- package/src/harness/pi-adapter.mjs +47 -0
- package/src/loop/async-start-contract.mjs +170 -0
- package/src/loop/conductor-routing.mjs +817 -0
- package/src/loop/copilot-ci-status.mjs +255 -0
- package/src/loop/copilot-loop-iterations.mjs +161 -0
- package/src/loop/copilot-loop-state.mjs +510 -0
- package/src/loop/handoff-envelope.mjs +800 -0
- package/src/loop/issue-refinement-artifact.mjs +268 -0
- package/src/loop/lifecycle-state.mjs +342 -0
- package/src/loop/phase-files.mjs +187 -0
- package/src/loop/policy-constants.mjs +17 -0
- package/src/loop/pr-gate-coordination.mjs +1278 -0
- package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
- package/src/loop/public-dev-loop-routing.mjs +1746 -0
- package/src/loop/queue-board-ordering.mjs +38 -0
- package/src/loop/queue-board-sync.mjs +223 -0
- package/src/loop/queue-driver.mjs +164 -0
- package/src/loop/queue-parallel.mjs +190 -0
- package/src/loop/queue-state.mjs +230 -0
- package/src/loop/retrospective-checkpoint.mjs +178 -0
- package/src/loop/reviewer-loop-state.mjs +456 -0
- package/src/loop/run-inspection.mjs +604 -0
- package/src/loop/steering.mjs +793 -0
- package/src/loop/timeout-policy.mjs +73 -0
- package/src/loop/tracker-first-loop-state.mjs +87 -0
- package/src/loop/tracker-pr-state.mjs +301 -0
- package/src/loop/worktree-guard.mjs +141 -0
- 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
|
+
}
|