@dev-loops/core 0.3.0 → 0.5.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.
@@ -119,6 +119,19 @@ function normalizeMergeStateStatus(value) {
119
119
  return value.trim().toUpperCase();
120
120
  }
121
121
 
122
+ function normalizeMergeable(value) {
123
+ if (typeof value !== "string" || value.trim().length === 0) {
124
+ return null;
125
+ }
126
+
127
+ const upper = value.trim().toUpperCase();
128
+ if (upper === "MERGEABLE" || upper === "CONFLICTING" || upper === "UNKNOWN") {
129
+ return upper;
130
+ }
131
+
132
+ return null;
133
+ }
134
+
122
135
  function normalizeConflictFiles(value) {
123
136
  if (!Array.isArray(value)) {
124
137
  return [];
@@ -142,14 +155,18 @@ function hasBlockedMergeStatus(mergeStateStatus) {
142
155
  return mergeStateStatus !== null && BLOCKED_MERGE_STATE_STATUSES.has(mergeStateStatus);
143
156
  }
144
157
 
145
- function formatBlockedMergeReason(mergeStateStatus, conflictFiles) {
158
+ function formatBlockedMergeReason(mergeStateStatus, conflictFiles, mergeable = null) {
146
159
  if (mergeStateStatus === "BEHIND") {
147
160
  let reason = "Branch must be updated from base before entering any gate.";
148
161
  reason += ` GitHub mergeStateStatus: ${mergeStateStatus}.`;
149
162
  return reason;
150
163
  }
151
164
 
152
- let reason = "The current branch conflicts with the base branch, so resolve the conflict locally on the PR branch, rerun validation, rerun gate detection, and only then resume the normal gate path.";
165
+ let reason = "The current branch conflicts with the base branch, so resolve the conflict locally on the PR branch (run `node scripts/loop/resolve-pr-conflicts.mjs --push` for the safe additive-CHANGELOG case), rerun validation, rerun gate detection, and only then resume the normal gate path.";
166
+
167
+ if (mergeable === "CONFLICTING") {
168
+ reason += " GitHub mergeable: CONFLICTING.";
169
+ }
153
170
 
154
171
  if (mergeStateStatus !== null) {
155
172
  reason += ` GitHub mergeStateStatus: ${mergeStateStatus}.`;
@@ -218,7 +235,23 @@ function buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopi
218
235
  return `Copilot review rounds exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}); current head has zero unresolved threads and green or credibly green CI, so pre_approval_gate fallback is allowed without another Copilot re-request.`;
219
236
  }
220
237
 
221
- function evaluateRetrospectiveMergeApproval(checkpoint) {
238
+ /**
239
+ * Render the (user-authored) rawCallViolations array into a bounded, single-line
240
+ * fragment for the gate failure reason. Collapses whitespace/newlines per entry,
241
+ * caps per-entry length, and caps the number of entries shown so a large or
242
+ * garbled checkpoint cannot bloat or break gate output. Still fails closed —
243
+ * this only formats the reason; the violation count above does the gating.
244
+ */
245
+ function summarizeRawCallViolations(violations, { maxEntries = 10, maxEntryLen = 200 } = {}) {
246
+ const shown = violations.slice(0, maxEntries).map((v) => {
247
+ const flat = String(v).replace(/\s+/g, " ").trim();
248
+ return flat.length > maxEntryLen ? `${flat.slice(0, maxEntryLen)}…` : flat;
249
+ });
250
+ const more = violations.length - shown.length;
251
+ return more > 0 ? `${shown.join("; ")}; …(+${more} more)` : shown.join("; ");
252
+ }
253
+
254
+ function evaluateRetrospectiveMergeApproval(checkpoint, { developerMode = false } = {}) {
222
255
  if (!checkpoint || typeof checkpoint !== "object") {
223
256
  return { approved: false, reason: "No retrospective checkpoint was found." };
224
257
  }
@@ -278,6 +311,37 @@ function evaluateRetrospectiveMergeApproval(checkpoint) {
278
311
  return { approved: false, reason: "Retrospective is missing explicit `mergeRecommendation`." };
279
312
  }
280
313
 
314
+ // internalToolingOnly: the loop's own execution must have used internal dev-loops
315
+ // tooling only — no agent-level raw `gh`/`python`/`python3`/`node -e` (issue #982).
316
+ // This is a DEVELOPER-MODE retro step: it enforces the dev-loops maintainers'
317
+ // own dogfooding discipline and is opt-in via `workflow.requireRetrospectiveInternalTooling`
318
+ // (default OFF). CONSUMERS of the extension are never blocked by it — they may
319
+ // legitimately use raw gh/python/node -e in their own workflow — so when the flag
320
+ // is OFF these fields are neither required nor enforced (a complete checkpoint
321
+ // without them passes exactly as it did before #982). When ON it fails closed:
322
+ // a complete checkpoint must explicitly attest a clean tooling record, and an OLD
323
+ // checkpoint missing `internalToolingOnly` fails (not a silent pass). Re-record the
324
+ // retrospective with the new fields to clear it.
325
+ if (developerMode) {
326
+ const internalToolingOnly = br !== null ? br.internalToolingOnly : checkpoint.internalToolingOnly;
327
+ if (internalToolingOnly !== true) {
328
+ return {
329
+ approved: false,
330
+ reason: "Retrospective does not attest internal-tooling-only execution (`internalToolingOnly: true` is required in developer mode; agent-level raw gh/python/node -e is a violation). — re-record the retrospective with internalToolingOnly + rawCallViolations.",
331
+ };
332
+ }
333
+ const rawCallViolations = br !== null ? br.rawCallViolations : checkpoint.rawCallViolations;
334
+ if (!Array.isArray(rawCallViolations)) {
335
+ return { approved: false, reason: "Retrospective is missing `rawCallViolations` (array; empty when clean). — re-record the retrospective with internalToolingOnly + rawCallViolations." };
336
+ }
337
+ if (rawCallViolations.length > 0) {
338
+ return {
339
+ approved: false,
340
+ reason: `Retrospective records ${rawCallViolations.length} raw-call violation(s) (agent-level gh/python/node -e): ${summarizeRawCallViolations(rawCallViolations)}.`,
341
+ };
342
+ }
343
+ }
344
+
281
345
  return { approved: true, reason: null };
282
346
  }
283
347
 
@@ -610,6 +674,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
610
674
  ? "internal_only"
611
675
  : (typeof input.reviewMode === "string" ? input.reviewMode.trim().toLowerCase() : null);
612
676
  const mergeStateStatus = normalizeMergeStateStatus(input.mergeStateStatus);
677
+ const mergeable = normalizeMergeable(input.mergeable);
613
678
  const conflictFiles = normalizeConflictFiles(input.conflictFiles);
614
679
  const ciStatus = normalizeCiStatus(input.ciStatus);
615
680
  const draftGateRequireCi = input.draftGateRequireCi !== false;
@@ -617,6 +682,9 @@ function evaluatePrGateCoordinationCore(input = {}) {
617
682
  const maxCopilotRounds = normalizePositiveInteger(input.maxCopilotRounds);
618
683
  const roundCapReached = maxCopilotRounds !== null && copilotReviewRoundCount >= maxCopilotRounds;
619
684
  const requireRetrospectiveGate = input.requireRetrospectiveGate === true;
685
+ // Developer-mode flag (#982): only the dev-loops repo dogfooding itself enforces the
686
+ // internal-tooling-only retro discipline. Default OFF so consumer state changes pass.
687
+ const requireRetrospectiveInternalTooling = input.requireRetrospectiveInternalTooling === true;
620
688
  const retrospectiveCheckpoint = input.retrospectiveCheckpoint;
621
689
  const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
622
690
  const refinementArtifact = input.refinementArtifact && typeof input.refinementArtifact === "object"
@@ -694,7 +762,44 @@ function evaluatePrGateCoordinationCore(input = {}) {
694
762
  });
695
763
  }
696
764
 
697
- if (hasBlockedMergeStatus(mergeStateStatus) || conflictFiles.length > 0) {
765
+ // Mergeability is a required precondition at every gate (issue #980). GitHub
766
+ // computes `mergeable` asynchronously, so an unsettled UNKNOWN must fail closed
767
+ // to a recheck — never a pass. The detect layer already re-polls a bounded
768
+ // number of times; if it still reads UNKNOWN here, hold gate progression and
769
+ // recheck rather than guess.
770
+ if (mergeable === "UNKNOWN") {
771
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
772
+ pushUnique(forbiddenActions, [
773
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
774
+ PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE,
775
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
776
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
777
+ PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
778
+ PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
779
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
780
+ ]);
781
+ return buildResult({
782
+ repo: input.repo ?? null,
783
+ pr: Number.isInteger(input.pr) ? input.pr : null,
784
+ currentHeadSha,
785
+ lifecycleState: effectiveLifecycleState,
786
+ loopDisposition: DISPOSITION.PENDING,
787
+ gateBoundary: PR_CHECKPOINT.CONFLICT_RESOLUTION,
788
+ draftGateAlreadySatisfied,
789
+ draftGate,
790
+ preApprovalGate,
791
+ allowedNextActions,
792
+ forbiddenActions,
793
+ nextAction: PR_CHECKPOINT_ACTION.WAIT_FOR_CI,
794
+ reason: "GitHub has not yet computed mergeability (mergeable=UNKNOWN), so gate progression is held: recheck before proceeding rather than treating an unsettled merge state as clean.",
795
+ mergeStateStatus,
796
+ conflictFiles,
797
+ refinementArtifact,
798
+ copilotReviewRoundCount,
799
+ });
800
+ }
801
+
802
+ if (hasBlockedMergeStatus(mergeStateStatus) || mergeable === "CONFLICTING" || conflictFiles.length > 0) {
698
803
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RESOLVE_MERGE_CONFLICTS]);
699
804
  pushUnique(forbiddenActions, [
700
805
  PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
@@ -723,7 +828,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
723
828
  allowedNextActions,
724
829
  forbiddenActions,
725
830
  nextAction: PR_CHECKPOINT_ACTION.RESOLVE_MERGE_CONFLICTS,
726
- reason: formatBlockedMergeReason(mergeStateStatus, conflictFiles),
831
+ reason: formatBlockedMergeReason(mergeStateStatus, conflictFiles, mergeable),
727
832
  mergeStateStatus,
728
833
  conflictFiles,
729
834
  refinementArtifact,
@@ -911,7 +1016,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
911
1016
  });
912
1017
  }
913
1018
  if (requireRetrospectiveGate) {
914
- const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
1019
+ const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
915
1020
  if (!retrospectiveGate.approved) {
916
1021
  return buildRetrospectiveGatePendingResult({
917
1022
  input,
@@ -1183,7 +1288,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1183
1288
  });
1184
1289
  }
1185
1290
  if (requireRetrospectiveGate) {
1186
- const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
1291
+ const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
1187
1292
  if (!retrospectiveGate.approved) {
1188
1293
  return buildRetrospectiveGatePendingResult({
1189
1294
  input,
@@ -1349,7 +1454,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1349
1454
  });
1350
1455
  }
1351
1456
  if (requireRetrospectiveGate) {
1352
- const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
1457
+ const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
1353
1458
  if (!retrospectiveGate.approved) {
1354
1459
  return buildRetrospectiveGatePendingResult({
1355
1460
  input,
@@ -1509,7 +1614,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1509
1614
  });
1510
1615
  }
1511
1616
  if (requireRetrospectiveGate) {
1512
- const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
1617
+ const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
1513
1618
  if (!retrospectiveGate.approved) {
1514
1619
  return buildRetrospectiveGatePendingResult({
1515
1620
  input,
@@ -25,7 +25,11 @@ export async function resolveNextUpOrder(
25
25
  const listItems = dependencies.listQueueItems ?? listQueueItemsMain;
26
26
  try {
27
27
  const result = await listItems(
28
- { repo, project: projectNumber, column: "Next Up" },
28
+ // list-queue-items validates `project` as a string ref (CLI contract);
29
+ // resolveProjectNumber yields a number, so stringify it. Passing the raw
30
+ // number trips parseProjectRef's `typeof raw !== "string"` guard, which
31
+ // surfaces as a misleading "--project is required" (#901).
32
+ { repo, project: String(projectNumber), column: "Next Up" },
29
33
  { env, runChild: dependencies.runChild },
30
34
  );
31
35
  const order = (result?.items ?? [])
@@ -57,6 +57,28 @@ export async function runQueue(repoRoot, repo, options = {}) {
57
57
  const opts = { ...DEFAULT_QUEUE_DRIVER_OPTIONS, ...options };
58
58
  const queue = await readQueue(repoRoot);
59
59
 
60
+ // Data-integrity guard (#913): this driver is a deterministic ADAPTER over the
61
+ // board, not the orchestration harness. Completion (`done` / move to Done) may
62
+ // only ever REFLECT a real terminal signal supplied by an orchestrator via
63
+ // `runEntry` (e.g. a merged PR). With no `runEntry` wired in the current
64
+ // harness there is nothing that can produce a verifiable terminal state, so
65
+ // the run MUST be a no-op: leave every entry and board column untouched and
66
+ // report the reason. Previously the missing-orchestrator path fell back to a
67
+ // fabricated `{ ok: true, pr: null }` per entry, which silently marked an
68
+ // entire Next Up `done` and moved it to Done without any work happening.
69
+ if (typeof opts.runEntry !== "function") {
70
+ return {
71
+ ok: true,
72
+ noop: true,
73
+ reason: "no-orchestrator",
74
+ message:
75
+ "queue run is a deterministic adapter with no orchestrator wired (no runEntry); " +
76
+ "leaving board columns unchanged. Items move to Done only on a real terminal signal.",
77
+ results: [],
78
+ queue,
79
+ };
80
+ }
81
+
60
82
  // Config-driven loop-state → board-column mapping (#793, AC1/AC3). Loaded
61
83
  // once per run; resolves logical columns to configured display names, with
62
84
  // the AC1 defaults when no `queue.statusColumns`/`queue.stateColumnMap` is set.
@@ -135,9 +157,9 @@ export async function runQueue(repoRoot, repo, options = {}) {
135
157
  await syncColumn(entry.target, columnFor("implementation"));
136
158
 
137
159
  try {
138
- const entryResult = opts.runEntry
139
- ? await opts.runEntry(entry, repo, opts)
140
- : { ok: true, pr: null };
160
+ // runEntry is guaranteed a function here (guarded at function entry):
161
+ // the adapter never fabricates a terminal result for an undispatched item.
162
+ const entryResult = await opts.runEntry(entry, repo, opts);
141
163
 
142
164
  if (entryResult.ok) {
143
165
  if (entryResult.pr) {
@@ -1,15 +1,11 @@
1
1
  /**
2
2
  * Neutral run-id / async-context contract.
3
3
  *
4
- * The dev-loop async path historically keyed off Pi's `PI_SUBAGENT_RUN_ID` env var to
4
+ * The dev-loop async path keys off the harness-neutral `DEVLOOPS_RUN_ID` env var to
5
5
  * identify an inspectable per-subagent run (runner ownership, async-start enforcement,
6
- * human-comment gating). This module generalizes that into a harness-neutral
7
- * `DEVLOOPS_RUN_ID`, keeping `PI_SUBAGENT_RUN_ID` as a backward-compatible alias, and
8
- * provides a mint-and-propagate path for harnesses (e.g. Claude Code) that inject no
9
- * native per-subagent run id.
10
- *
11
- * Marker precedence is neutral-first: a present `DEVLOOPS_RUN_ID` wins; otherwise the Pi
12
- * alias is honored. Existing Pi runs that set only `PI_SUBAGENT_RUN_ID` behave identically.
6
+ * human-comment gating), and provides a mint-and-propagate path for harnesses (e.g. Claude
7
+ * Code) that inject no native per-subagent run id. The harness sets `DEVLOOPS_RUN_ID` when
8
+ * dispatching an async subagent.
13
9
  *
14
10
  * This module is pure except for the explicit file/IO helpers (writeRunContext/readRunContext),
15
11
  * which take an injectable `fs` and `root` for testability.
@@ -21,16 +17,13 @@ import path from "node:path";
21
17
 
22
18
  /**
23
19
  * Env var names that carry the async-context run id, in resolution precedence order.
24
- * Neutral `DEVLOOPS_RUN_ID` first; Pi `PI_SUBAGENT_RUN_ID` retained as a compatibility alias.
20
+ * The neutral `DEVLOOPS_RUN_ID` is the sole marker.
25
21
  */
26
- export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID", "PI_SUBAGENT_RUN_ID"]);
22
+ export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID"]);
27
23
 
28
24
  /** Neutral env var name used when minting/propagating a run id. */
29
25
  export const NEUTRAL_RUN_ID_VAR = "DEVLOOPS_RUN_ID";
30
26
 
31
- /** Pi-compatibility alias env var name. */
32
- export const PI_RUN_ID_ALIAS_VAR = "PI_SUBAGENT_RUN_ID";
33
-
34
27
  /** State-file name (under `.pi/`, consistent with existing dev-loop checkpoint files). */
35
28
  export const RUN_CONTEXT_FILENAME = "dev-loop-run-context.json";
36
29
 
@@ -56,7 +49,7 @@ export function isClaudeHarness(env = process.env) {
56
49
  }
57
50
 
58
51
  /**
59
- * Resolve the active run id from the environment, neutral marker first.
52
+ * Resolve the active run id from the environment.
60
53
  *
61
54
  * @param {Record<string, string|undefined>} [env]
62
55
  * @returns {string|null} The trimmed run id, or null when none is set.
@@ -154,8 +147,8 @@ export function readRunContext({ root, fs = fsDefault }) {
154
147
  * Resolve the active run id, or mint one and persist a run-context state file.
155
148
  *
156
149
  * This is the "mint at startup and propagate" primitive a Claude dev-loop agent (or a
157
- * headless entry) calls before dispatching child work. When the env already carries a run
158
- * id (Pi alias or neutral), it is reused and no new id is minted.
150
+ * headless entry) calls before dispatching child work. When the env already carries a
151
+ * `DEVLOOPS_RUN_ID`, it is reused and no new id is minted.
159
152
  *
160
153
  * @param {object} [params]
161
154
  * @param {Record<string, string|undefined>} [params.env]
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Spike-mode exit (P2 of the spike-mode track #965).
3
+ *
4
+ * Phase 1 (#964) shipped the spike intake state machine
5
+ * (`evaluateSpikeIntakeState` → SPIKE_INTAKE_STATE). A spike becomes exitable
6
+ * only once it carries a Recommendation (`spike_ready_for_exit`). This module is
7
+ * the exit decision: from that ready state, the operator picks a disposition —
8
+ * - DISCARD — the recommendation is "don't pursue"; drop the spike with ZERO
9
+ * tracker artifacts (the findings doc is the whole record).
10
+ * - GRADUATE — promote the exploration into a #947-consumable plan file
11
+ * (Status/Objective/In scope/Explicit non-goals) built from the
12
+ * spike's Question/Approach/Findings/Recommendation, which then
13
+ * enters the existing local-first plan→PR promotion path (#952).
14
+ *
15
+ * Pure: no fs/network/gh, no `scripts/` import (mirrors spike-intake-contract
16
+ * and plan-file-promote-contract). The CLI (`scripts/refine/exit-spike.mjs`)
17
+ * owns all I/O. Fail-closed on an unknown disposition or a non-ready state so
18
+ * the CLI makes zero mutation on those paths.
19
+ */
20
+
21
+ import { SPIKE_INTAKE_STATE } from "./spike-intake-contract.mjs";
22
+
23
+ /** Dispositions the operator can choose at a ready spike's exit. */
24
+ export const SPIKE_EXIT_DISPOSITION = Object.freeze({
25
+ /** Drop the spike with no tracker artifact (recommendation: don't pursue). */
26
+ DISCARD: "discard",
27
+ /** Promote into a plan file consumable by the #947 local-first flow. */
28
+ GRADUATE: "graduate",
29
+ });
30
+
31
+ /** Actions the exit decision can return (1:1 with the eligible dispositions). */
32
+ export const SPIKE_EXIT_ACTION = Object.freeze({
33
+ DISCARD: "discard",
34
+ GRADUATE: "graduate",
35
+ });
36
+
37
+ const DISPOSITION_TO_ACTION = Object.freeze({
38
+ [SPIKE_EXIT_DISPOSITION.DISCARD]: SPIKE_EXIT_ACTION.DISCARD,
39
+ [SPIKE_EXIT_DISPOSITION.GRADUATE]: SPIKE_EXIT_ACTION.GRADUATE,
40
+ });
41
+
42
+ /**
43
+ * Pure exit-eligibility decision.
44
+ *
45
+ * Eligible ONLY from `spike_ready_for_exit` (a Recommendation has been reached).
46
+ * Any other state — in-progress or ambiguous — fails closed with
47
+ * `not_ready_for_exit` and no action; the CLI must make zero tracker mutation.
48
+ * An unrecognized disposition fails closed with `unknown_disposition`.
49
+ *
50
+ * @param {object} facts
51
+ * @param {string} facts.spikeIntakeState one of SPIKE_INTAKE_STATE values (from evaluateSpikeIntakeState)
52
+ * @param {string} facts.disposition one of SPIKE_EXIT_DISPOSITION values
53
+ * @returns {{ ok: boolean, action?: string, reason?: string, spikeIntakeState?: string | null }}
54
+ */
55
+ export function evaluateSpikeExit({ spikeIntakeState, disposition } = {}) {
56
+ // The ready gate: an exit decision is only meaningful once a recommendation
57
+ // exists. Fail closed otherwise — never guess an exit for an in-progress or
58
+ // malformed spike.
59
+ if (spikeIntakeState !== SPIKE_INTAKE_STATE.SPIKE_READY_FOR_EXIT) {
60
+ return { ok: false, reason: "not_ready_for_exit", spikeIntakeState: spikeIntakeState ?? null };
61
+ }
62
+
63
+ // Own-property check: a bare index lookup would also match inherited
64
+ // Object.prototype keys (`toString`, `__proto__`, `constructor`), letting an
65
+ // unknown disposition resolve to a truthy value and bypass the fail-closed
66
+ // contract. Require a string that is an own key of the map.
67
+ if (typeof disposition !== "string" || !Object.hasOwn(DISPOSITION_TO_ACTION, disposition)) {
68
+ return { ok: false, reason: "unknown_disposition", spikeIntakeState };
69
+ }
70
+ const action = DISPOSITION_TO_ACTION[disposition];
71
+
72
+ return { ok: true, action, spikeIntakeState };
73
+ }
74
+
75
+ /**
76
+ * Build a #947-consumable plan-file body from a ready spike's sections.
77
+ *
78
+ * The emitted body carries the four base authoring sections the plan-file
79
+ * format requires (Status / Objective / In scope / Explicit non-goals — see
80
+ * scripts/refine/validate-plan-file.mjs), so it passes `validatePlanFile` and
81
+ * enters the existing local-first plan→PR promotion path (#952) unchanged.
82
+ *
83
+ * The spike sections map onto the plan as: the Question + Approach become the
84
+ * Objective's context, the Recommendation becomes the In-scope work, the
85
+ * Findings record the evidence, and a fixed non-goal keeps the plan from
86
+ * re-opening the (now-concluded) exploration. Status starts as Draft.
87
+ *
88
+ * Idempotent and pure: same input → identical output, no side effects. Fails
89
+ * closed (throws) on an empty required section so a graduate exit cannot emit a
90
+ * plan that the validator would reject.
91
+ *
92
+ * @param {object} sections
93
+ * @param {string} sections.question
94
+ * @param {string} sections.approach
95
+ * @param {string} sections.findings
96
+ * @param {string} sections.recommendation
97
+ * @returns {string} markdown plan-file body
98
+ */
99
+ export function buildGraduatedPlanBody({ question, approach, findings, recommendation } = {}) {
100
+ const q = String(question ?? "").trim();
101
+ const a = String(approach ?? "").trim();
102
+ const f = String(findings ?? "").trim();
103
+ const r = String(recommendation ?? "").trim();
104
+ if (q.length === 0) throw new Error("buildGraduatedPlanBody requires a non-empty question");
105
+ if (a.length === 0) throw new Error("buildGraduatedPlanBody requires a non-empty approach");
106
+ if (f.length === 0) throw new Error("buildGraduatedPlanBody requires non-empty findings");
107
+ if (r.length === 0) throw new Error("buildGraduatedPlanBody requires a non-empty recommendation");
108
+
109
+ return [
110
+ "# Graduated spike plan",
111
+ "",
112
+ "## Status",
113
+ "",
114
+ "Draft (graduated from a spike). Needs refinement before promotion.",
115
+ "",
116
+ "## Objective",
117
+ "",
118
+ `Act on the spike's recommendation. The spike asked: ${q}`,
119
+ "",
120
+ "Approach explored:",
121
+ "",
122
+ a,
123
+ "",
124
+ "## In scope",
125
+ "",
126
+ r,
127
+ "",
128
+ "Supporting findings from the spike:",
129
+ "",
130
+ f,
131
+ "",
132
+ "## Explicit non-goals",
133
+ "",
134
+ "- Re-running the spike's exploration; that question is concluded.",
135
+ "- Work beyond the recommendation above.",
136
+ "",
137
+ ].join("\n");
138
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Spike-mode intake state machine.
3
+ *
4
+ * A `--spike` startup hands the dev-loop a time-boxed, exploratory artifact that
5
+ * lives outside the tracker — startable from a local question with no GitHub
6
+ * issue. A spike is NOT a plan-needing-refinement, so its states are distinct
7
+ * from PLAN_FILE_INTAKE_STATE: it classifies how far the exploration has
8
+ * progressed toward an exit decision, not how far a plan has progressed toward
9
+ * promotion.
10
+ *
11
+ * This evaluator mirrors `evaluatePlanFileIntakeState`: a frozen enum plus a
12
+ * pure, deterministic function with no GitHub or filesystem side effects — the
13
+ * caller supplies the section-presence facts it has already read.
14
+ *
15
+ * The two non-ambiguous states are the seam phase 2 (#965) consumes for its
16
+ * discard/graduate exits:
17
+ * - SPIKE_IN_PROGRESS — exploration ongoing (no recommendation yet); a
18
+ * discard exit can drop it with no artifact.
19
+ * - SPIKE_READY_FOR_EXIT — a recommendation has been reached; phase 2 routes
20
+ * this to graduate (promote into a plan/PR) or
21
+ * discard (recommendation is "don't pursue").
22
+ */
23
+
24
+ export const SPIKE_INTAKE_STATE = Object.freeze({
25
+ /** Valid spike artifact; the Recommendation is not yet reached. */
26
+ SPIKE_IN_PROGRESS: "spike_in_progress",
27
+ /** Valid spike artifact carrying a Recommendation; an exit decision can be made. */
28
+ SPIKE_READY_FOR_EXIT: "spike_ready_for_exit",
29
+ /** Inputs are malformed or unusable; fail closed. */
30
+ AMBIGUOUS_FAIL_CLOSED: "ambiguous_fail_closed",
31
+ });
32
+
33
+ /**
34
+ * Pure intake-state classifier.
35
+ *
36
+ * @param {object} facts
37
+ * @param {boolean} facts.baseSectionsValid whether the spike's exploration scaffold (Question/Approach/Findings) is present and non-empty. Recommendation is NOT part of this fact — it is the separate exit-marker carried by `hasRecommendation`, so that a scaffold-valid spike without a Recommendation classifies as in-progress rather than failing closed.
38
+ * @param {boolean} facts.hasRecommendation whether a non-empty Recommendation section is present (the exit-marker that flips in-progress → ready-for-exit)
39
+ * @returns {{ state: string }} one of SPIKE_INTAKE_STATE values
40
+ */
41
+ export function evaluateSpikeIntakeState({ baseSectionsValid, hasRecommendation } = {}) {
42
+ // A malformed spike artifact (missing/empty base sections) is unusable intake
43
+ // input; fail closed instead of guessing an exit.
44
+ if (baseSectionsValid !== true) {
45
+ return { state: SPIKE_INTAKE_STATE.AMBIGUOUS_FAIL_CLOSED };
46
+ }
47
+ return {
48
+ state: hasRecommendation === true
49
+ ? SPIKE_INTAKE_STATE.SPIKE_READY_FOR_EXIT
50
+ : SPIKE_INTAKE_STATE.SPIKE_IN_PROGRESS,
51
+ };
52
+ }
@@ -121,32 +121,22 @@ export function isListedWorktree(cwd, worktreePaths) {
121
121
  * Neutral environment variable name checked by `detectSubagentAvailability`.
122
122
  *
123
123
  * Set `DEVLOOPS_SUBAGENT_AVAILABLE=1` when the runtime supports subagent dispatch.
124
- * This is consistent with the `PI_WORKTREE_BYPASS` pattern and other repo-local
124
+ * This is consistent with the `DEVLOOPS_WORKTREE_BYPASS` pattern and other repo-local
125
125
  * runtime configuration gates already present in the repo.
126
126
  */
127
127
  export const DEVLOOPS_SUBAGENT_AVAILABLE_VAR = "DEVLOOPS_SUBAGENT_AVAILABLE";
128
128
 
129
- /**
130
- * Pi-compatibility alias for {@link DEVLOOPS_SUBAGENT_AVAILABLE_VAR}; honored when the
131
- * neutral var is unset so existing Pi runtimes keep working unchanged.
132
- */
133
- export const PI_SUBAGENT_AVAILABLE_VAR = "PI_SUBAGENT_AVAILABLE";
134
-
135
- /** Availability env var names, neutral-first. */
136
- export const SUBAGENT_AVAILABLE_VARS = Object.freeze([
137
- DEVLOOPS_SUBAGENT_AVAILABLE_VAR,
138
- PI_SUBAGENT_AVAILABLE_VAR,
139
- ]);
129
+ /** Availability env var names. */
130
+ export const SUBAGENT_AVAILABLE_VARS = Object.freeze([DEVLOOPS_SUBAGENT_AVAILABLE_VAR]);
140
131
 
141
132
  /**
142
133
  * Detect whether subagent dispatch is available in the current runtime.
143
134
  *
144
135
  * This is an env-var-based heuristic, consistent with other bypass/availability
145
136
  * patterns in the repo. It is intentionally simple — the gate's subagent check
146
- * is advisory (fails-open) and never hard-blocks on subagent absence. Precedence is
147
- * neutral-first: the first var that is *set* (non-blank) is authoritative — so an explicit
148
- * `DEVLOOPS_SUBAGENT_AVAILABLE=0` is respected even when `PI_SUBAGENT_AVAILABLE=1`. The Pi
149
- * alias is only consulted when the neutral var is unset/blank.
137
+ * is advisory (fails-open) and never hard-blocks on subagent absence. The var that is
138
+ * *set* (non-blank) is authoritative — so an explicit `DEVLOOPS_SUBAGENT_AVAILABLE=0` is
139
+ * respected as a hard "not available".
150
140
  *
151
141
  * @param {{ env?: Record<string, string | undefined> }} [options]
152
142
  * @returns {boolean}