@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.
- package/package.json +9 -1
- package/src/config/config.mjs +213 -6
- package/src/config/extension-defaults.yaml +32 -1
- package/src/loop/async-start-contract.mjs +8 -27
- package/src/loop/handoff-envelope.mjs +50 -8
- package/src/loop/lifecycle-state.mjs +13 -1
- package/src/loop/plan-file-intake-contract.mjs +56 -0
- package/src/loop/plan-file-promote-contract.mjs +234 -0
- package/src/loop/plan-file-refine-contract.mjs +229 -0
- package/src/loop/pr-gate-coordination.mjs +114 -9
- package/src/loop/queue-board-ordering.mjs +5 -1
- package/src/loop/queue-driver.mjs +25 -3
- package/src/loop/run-context.mjs +9 -16
- package/src/loop/spike-exit-contract.mjs +138 -0
- package/src/loop/spike-intake-contract.mjs +52 -0
- package/src/loop/worktree-guard.mjs +6 -16
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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) {
|
package/src/loop/run-context.mjs
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Neutral run-id / async-context contract.
|
|
3
3
|
*
|
|
4
|
-
* The dev-loop async path
|
|
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)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
20
|
+
* The neutral `DEVLOOPS_RUN_ID` is the sole marker.
|
|
25
21
|
*/
|
|
26
|
-
export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_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
|
|
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
|
|
158
|
-
*
|
|
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 `
|
|
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
|
-
|
|
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.
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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}
|