dev-loops 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/refiner.md +1 -0
  4. package/.claude/commands/auto.md +7 -0
  5. package/.claude/commands/continue.md +15 -0
  6. package/.claude/commands/info.md +7 -0
  7. package/.claude/commands/start-spike.md +16 -0
  8. package/.claude/commands/start.md +7 -0
  9. package/.claude/commands/status.md +6 -0
  10. package/.claude/hooks/_run-context.mjs +11 -4
  11. package/.claude/skills/dev-loop/SKILL.md +21 -6
  12. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  13. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  14. package/.claude/skills/docs/local-planning-flow.md +63 -0
  15. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  16. package/.claude/skills/docs/merge-preconditions.md +35 -0
  17. package/.claude/skills/docs/plan-file-contract.md +37 -0
  18. package/.claude/skills/docs/release-runbook.md +45 -0
  19. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  20. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  21. package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
  22. package/.claude/skills/local-implementation/SKILL.md +1 -1
  23. package/CHANGELOG.md +73 -0
  24. package/README.md +21 -1
  25. package/agents/dev-loop.agent.md +8 -1
  26. package/agents/refiner.agent.md +1 -0
  27. package/cli/index.mjs +2 -0
  28. package/extension/index.ts +10 -1
  29. package/extension/presentation.ts +15 -0
  30. package/lib/dev-loops-core.mjs +141 -0
  31. package/package.json +8 -3
  32. package/scripts/claude/generate-claude-assets.mjs +15 -1
  33. package/scripts/github/capture-review-threads.mjs +20 -2
  34. package/scripts/github/comment-issue.mjs +181 -0
  35. package/scripts/github/fetch-ci-logs.mjs +215 -0
  36. package/scripts/github/list-issues.mjs +191 -0
  37. package/scripts/github/probe-copilot-review.mjs +69 -3
  38. package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
  39. package/scripts/lib/jq-output.mjs +297 -0
  40. package/scripts/loop/_handoff-contract.mjs +1 -0
  41. package/scripts/loop/check-retro-tooling.mjs +246 -0
  42. package/scripts/loop/copilot-pr-handoff.mjs +21 -3
  43. package/scripts/loop/detect-pr-gate-coordination-state.mjs +65 -2
  44. package/scripts/loop/docs-grill-contract.mjs +70 -0
  45. package/scripts/loop/info.mjs +21 -2
  46. package/scripts/loop/pr-runner-coordination.mjs +20 -4
  47. package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
  48. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  49. package/scripts/loop/run-conductor-cycle.mjs +5 -0
  50. package/scripts/loop/run-watch-cycle.mjs +77 -3
  51. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  52. package/scripts/pages/build-site.mjs +136 -0
  53. package/scripts/projects/add-queue-item.mjs +12 -2
  54. package/scripts/projects/list-queue-items.mjs +12 -2
  55. package/scripts/projects/move-queue-item.mjs +12 -2
  56. package/scripts/projects/resolve-active-board-item.mjs +193 -0
  57. package/scripts/refine/_refine-helpers.mjs +20 -0
  58. package/scripts/refine/exit-spike.mjs +186 -0
  59. package/scripts/refine/promote-plan.mjs +387 -0
  60. package/scripts/refine/refine-plan-file.mjs +165 -0
  61. package/scripts/refine/scaffold-spike-file.mjs +183 -0
  62. package/scripts/refine/validate-plan-file.mjs +64 -0
  63. package/scripts/refine/validate-spike-file.mjs +87 -0
  64. package/scripts/release/extract-changelog-section.mjs +111 -0
  65. package/skills/dev-loop/SKILL.md +24 -2
  66. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  67. package/skills/docs/artifact-authority-contract.md +86 -31
  68. package/skills/docs/local-planning-flow.md +63 -0
  69. package/skills/docs/local-planning-worked-example.md +139 -0
  70. package/skills/docs/merge-preconditions.md +35 -0
  71. package/skills/docs/plan-file-contract.md +37 -0
  72. package/skills/docs/release-runbook.md +45 -0
  73. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  74. package/skills/docs/spike-mode-contract.md +237 -0
  75. package/skills/docs/ui-e2e-scoping-step.md +102 -0
  76. package/skills/local-implementation/SKILL.md +1 -1
@@ -17,6 +17,7 @@ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
17
  import { buildSnapshotFromPrFacts, interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
18
18
  import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
19
19
  import { shouldGuardCopilotReviewRequest } from "@dev-loops/core/loop/pr-gate-coordination";
20
+ import { UI_E2E_CHECK_NAMES } from "@dev-loops/core/loop/ui-e2e-scoping";
20
21
  import { fetchGithubReviewThreadsPayload } from "../github/capture-review-threads.mjs";
21
22
  import { detectCheckpointEvidence } from "../github/detect-checkpoint-evidence.mjs";
22
23
  import { parseArgs } from "node:util";
@@ -163,7 +164,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
163
164
  async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
164
165
  const result = await runChild(
165
166
  ghCommand,
166
- ["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup"],
167
+ ["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeable,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup,files"],
167
168
  env,
168
169
  );
169
170
  if (result.code !== 0) {
@@ -172,6 +173,58 @@ async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh"
172
173
  }
173
174
  return parseJsonText(result.stdout, { label: "gh pr view" });
174
175
  }
176
+
177
+ // GitHub computes `mergeable` asynchronously, so a freshly-pushed head briefly
178
+ // reads `UNKNOWN`. After the initial fetch, re-poll up to `maxPolls` more times
179
+ // while the value stays UNKNOWN (so at most 1 + maxPolls total fetches) before
180
+ // deciding; never treat a transient UNKNOWN as a pass — the caller fails closed
181
+ // to recheck if it never settles. (issue #980)
182
+ export async function fetchPrFactsWithSettledMergeable(
183
+ options,
184
+ {
185
+ env = process.env,
186
+ ghCommand = "gh",
187
+ maxPolls = 3,
188
+ pollDelayMs = 1500,
189
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
190
+ fetch = fetchPrFacts,
191
+ } = {},
192
+ ) {
193
+ let prData = await fetch(options, { env, ghCommand });
194
+ let polls = 0;
195
+ while (String(prData?.mergeable || "").toUpperCase() === "UNKNOWN" && polls < maxPolls) {
196
+ polls += 1;
197
+ await sleep(pollDelayMs);
198
+ prData = await fetch(options, { env, ghCommand });
199
+ }
200
+ return prData;
201
+ }
202
+ // Changed-file paths from `gh pr view --json files` (issue #976). Feeds the
203
+ // path-triggered UI e2e scoping precondition in the evaluator.
204
+ export function extractChangedFiles(prData) {
205
+ const files = Array.isArray(prData?.files) ? prData.files : [];
206
+ return files
207
+ .map((entry) => (typeof entry?.path === "string" ? entry.path : null))
208
+ .filter((p) => typeof p === "string" && p.length > 0);
209
+ }
210
+
211
+ // Whether the shared UI e2e suite passed for this head, read deterministically
212
+ // from the statusCheckRollup: every UI e2e check that is present must be
213
+ // SUCCESS. Returns null when no UI e2e check is present in the rollup (unknown
214
+ // → the evaluator fails closed), false if any present UI e2e check is not a
215
+ // success, true if all present ones succeeded.
216
+ export function deriveUiE2ePassed(prData, checkNames = UI_E2E_CHECK_NAMES) {
217
+ const rollup = Array.isArray(prData?.statusCheckRollup) ? prData.statusCheckRollup : [];
218
+ const wanted = new Set(checkNames);
219
+ const present = rollup.filter((entry) => wanted.has(entry?.name) || wanted.has(entry?.context));
220
+ if (present.length === 0) return null;
221
+ return present.every((entry) => {
222
+ const conclusion = String(entry?.conclusion ?? "").toUpperCase();
223
+ const state = String(entry?.state ?? "").toUpperCase();
224
+ return conclusion === "SUCCESS" || state === "SUCCESS";
225
+ });
226
+ }
227
+
175
228
  export function resolveLinkedIssueFromPr(prData) {
176
229
  if (!prData || typeof prData !== "object") return null;
177
230
  const closing = Array.isArray(prData.closingIssuesReferences) ? prData.closingIssuesReferences : [];
@@ -290,7 +343,7 @@ async function loadRetrospectiveCheckpoint(repoRoot) {
290
343
  }
291
344
  }
292
345
  export async function loadPrGateCoordinationContext(options, runtime = {}) {
293
- const prData = await fetchPrFacts(options, runtime);
346
+ const prData = await fetchPrFactsWithSettledMergeable(options, runtime);
294
347
  const currentHeadSha = typeof prData?.headRefOid === "string" && prData.headRefOid.trim().length > 0
295
348
  ? prData.headRefOid.trim()
296
349
  : null;
@@ -349,6 +402,9 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
349
402
  const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
350
403
  ? prData.mergeStateStatus.trim().toUpperCase()
351
404
  : null;
405
+ const mergeable = typeof prData?.mergeable === "string" && prData.mergeable.trim().length > 0
406
+ ? prData.mergeable.trim().toUpperCase()
407
+ : null;
352
408
  const isDraft = Boolean(prData?.isDraft);
353
409
  const isClosed = String(prData?.state || "").toUpperCase() === "CLOSED";
354
410
  const isMerged = String(prData?.state || "").toUpperCase() === "MERGED";
@@ -361,6 +417,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
361
417
  pr: options.pr,
362
418
  currentHeadSha,
363
419
  mergeStateStatus,
420
+ mergeable,
364
421
  conflictFiles,
365
422
  prData,
366
423
  snapshot,
@@ -396,6 +453,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
396
453
  const draftGateConfig = resolveGateConfig(config, "draft");
397
454
  const maxCopilotRounds = resolveRefinementConfig(config, "maxCopilotRounds");
398
455
  const requireRetrospectiveGate = resolveWorkflowConfig(config, "requireRetrospectiveGate");
456
+ const requireRetrospectiveInternalTooling = resolveWorkflowConfig(config, "requireRetrospectiveInternalTooling");
399
457
  const retrospectiveCheckpoint = await loadRetrospectiveCheckpoint(repoRoot);
400
458
  const result = evaluatePrGateCoordination({
401
459
  repo: context.repo,
@@ -406,7 +464,11 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
406
464
  prMerged: String(context.prData?.state || "").toUpperCase() === "MERGED",
407
465
  prTitle: context.prData?.title,
408
466
  mergeStateStatus: context.mergeStateStatus,
467
+ mergeable: context.mergeable,
409
468
  conflictFiles: context.conflictFiles,
469
+ // UI e2e auto-scoping (#976): path-triggered + fail-closed precondition.
470
+ changedFiles: extractChangedFiles(context.prData),
471
+ uiE2ePassed: deriveUiE2ePassed(context.prData),
410
472
  lifecycleState: context.interpretation.state,
411
473
  loopDisposition: context.disposition.loopDisposition,
412
474
  ciStatus: context.snapshot?.ciStatus ?? null,
@@ -415,6 +477,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
415
477
  sameHeadCleanConverged: context.interpretation.sameHeadCleanConverged,
416
478
  draftGateRequireCi: draftGateConfig.requireCi,
417
479
  requireRetrospectiveGate,
480
+ requireRetrospectiveInternalTooling,
418
481
  retrospectiveCheckpoint,
419
482
  draftGate: context.gateEvidence.draftGate,
420
483
  draftGateMarker: context.gateEvidence.draftGateMarker,
@@ -0,0 +1,70 @@
1
+ // Bounded docs-grill disposition classifier behind `dev-loop`. Sibling of
2
+ // scripts/loop/slides-story-review-contract.mjs: this one codifies the keep/fix
3
+ // rule for the autonomous docs-grill step (claims vs the actual contracts,
4
+ // code-vs-doc drift, stale references, contract-surface accuracy). Pure module,
5
+ // no I/O. See docs/docs-grill-step.md.
6
+
7
+ // What each finding asserts is wrong. `drift` covers any divergence between a
8
+ // claim/reference and the contract surface it points at; `stale_reference`
9
+ // covers a link/path/command that no longer resolves; `cosmetic` covers
10
+ // wording-only nits with no behavioral or reference impact.
11
+ export const DOCS_GRILL_FINDING_KINDS = Object.freeze([
12
+ 'drift',
13
+ 'stale_reference',
14
+ 'cosmetic',
15
+ ]);
16
+
17
+ // The bounded disposition the step assigns to each finding.
18
+ export const DOCS_GRILL_DISPOSITIONS = Object.freeze([
19
+ 'record_finding', // real drift between code/behavior and a contract claim — record it
20
+ 'fix_in_place', // doc-only drift the loop can correct on this branch
21
+ 'route_followup', // doc-only drift too large for this branch — route a follow-up
22
+ 'ignore_cosmetic', // wording nit that does not justify a block or a fix here
23
+ ]);
24
+
25
+ /**
26
+ * Classify one docs-grill finding into its keep/fix disposition.
27
+ *
28
+ * The keep/fix rule (docs/docs-grill-step.md):
29
+ * - real drift between code/behavior and a contract claim -> record_finding
30
+ * - doc-only drift the loop can correct here -> fix_in_place
31
+ * - doc-only drift too large for this branch -> route_followup
32
+ * - cosmetic wording nit -> ignore_cosmetic
33
+ *
34
+ * A coerced/invalid finding returns a structured invalid result rather than
35
+ * throwing (the module's contract is a structured result, never an exception).
36
+ *
37
+ * @param {object} finding
38
+ * @param {string} finding.kind - one of DOCS_GRILL_FINDING_KINDS
39
+ * @param {boolean} [finding.docOnly] - true when only docs (no code/behavior) diverge
40
+ * @param {boolean} [finding.fixableHere] - true when a doc-only fix is small enough for this branch
41
+ * @returns {{ ok: boolean, disposition?: string, status: string, reason: string, invalid?: string[] }}
42
+ */
43
+ export function classifyDocsGrillFinding(finding = {}) {
44
+ if (!finding || typeof finding !== 'object') finding = {};
45
+ if (!DOCS_GRILL_FINDING_KINDS.includes(finding.kind)) {
46
+ return {
47
+ ok: false,
48
+ status: 'invalid_finding',
49
+ reason: 'unknown_finding_kind',
50
+ invalid: ['kind'],
51
+ };
52
+ }
53
+
54
+ if (finding.kind === 'cosmetic') {
55
+ return { ok: true, disposition: 'ignore_cosmetic', status: 'classified', reason: 'cosmetic_nit' };
56
+ }
57
+
58
+ // drift | stale_reference against live code/behavior is always recorded; the
59
+ // reason reflects the kind so downstream filtering stays accurate.
60
+ if (finding.docOnly !== true) {
61
+ const reason = finding.kind === 'stale_reference' ? 'stale_reference' : 'real_drift';
62
+ return { ok: true, disposition: 'record_finding', status: 'classified', reason };
63
+ }
64
+
65
+ // Doc-only drift: fix it here when small, otherwise route a follow-up.
66
+ if (finding.fixableHere === true) {
67
+ return { ok: true, disposition: 'fix_in_place', status: 'classified', reason: 'doc_only_fixable_here' };
68
+ }
69
+ return { ok: true, disposition: 'route_followup', status: 'classified', reason: 'doc_only_too_large' };
70
+ }
@@ -100,13 +100,32 @@ function formatCiDisplay(ciStatus, ciConclusion) {
100
100
  return `CI ${ciStatus}`;
101
101
  }
102
102
 
103
+ function formatMergeableDisplay(mergeable, mergeStateStatus) {
104
+ const m = typeof mergeable === "string" ? mergeable.toUpperCase() : null;
105
+ const s = typeof mergeStateStatus === "string" ? mergeStateStatus.toUpperCase() : null;
106
+ if (m === "CONFLICTING" || s === "DIRTY" || s === "CONFLICTING") {
107
+ return `❌ CONFLICTING${s ? ` (${s})` : ""} — resolve before any gate`;
108
+ }
109
+ if (s === "BEHIND") {
110
+ return "⚠️ BEHIND — update branch from base before any gate";
111
+ }
112
+ if (m === "UNKNOWN") {
113
+ return "⏳ UNKNOWN — GitHub still computing; recheck before proceeding";
114
+ }
115
+ if (m === "MERGEABLE") {
116
+ return `✅ MERGEABLE${s ? ` (${s})` : ""}`;
117
+ }
118
+ return s || m || "unknown";
119
+ }
120
+
103
121
  function formatPrSummary(prData, handoffResult) {
104
122
  const lines = [];
105
123
  lines.push(`PR #${prData.number}: ${prData.title}`);
106
124
  lines.push(` Branch: ${formatBranchDisplay(prData.headRefName, prData.baseRefName)}`);
107
125
  lines.push(` State: ${prData.state}${prData.isDraft ? " (draft)" : ""}`);
108
126
  lines.push(` Author: ${prData.author?.login || "unknown"}`);
109
-
127
+ lines.push(` Mergeable: ${formatMergeableDisplay(prData.mergeable, prData.mergeStateStatus)}`);
128
+
110
129
  if (handoffResult?.snapshot) {
111
130
  const s = handoffResult.snapshot;
112
131
  if (s.ciStatus !== undefined) {
@@ -189,7 +208,7 @@ function formatIssueSummary(issueData, startupBundle, linkedPrData) {
189
208
  }
190
209
 
191
210
  function buildPrInfo(prNumber, repo, cwd) {
192
- const prData = ghJson(["pr", "view", String(prNumber), "--repo", repo, "--json", "number,title,body,state,isDraft,headRefName,baseRefName,author,mergedAt,url,reviewRequests"], cwd);
211
+ const prData = ghJson(["pr", "view", String(prNumber), "--repo", repo, "--json", "number,title,body,state,isDraft,headRefName,baseRefName,author,mergedAt,mergeable,mergeStateStatus,url,reviewRequests"], cwd);
193
212
 
194
213
  let handoffResult = null;
195
214
  try {
@@ -5,6 +5,7 @@ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helper
5
5
  import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
6
6
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
7
7
  import { resolveRunId as resolveEnvRunId } from "@dev-loops/core/loop/run-context";
8
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
8
9
  import {
9
10
  assertRunnerOwnership,
10
11
  claimRunnerOwnership,
@@ -23,6 +24,7 @@ If --run-id is omitted for claim/assert/release/takeover, DEVLOOPS_RUN_ID is use
23
24
  Output:
24
25
  stdout: { "ok": true, ... }
25
26
  stderr: { "ok": false, "error": "...", ... }
27
+ ${JQ_OUTPUT_USAGE}
26
28
  Exit codes:
27
29
  0 Success / clean stop-compatible result
28
30
  1 Argument error or coordination conflict`.trim();
@@ -36,6 +38,8 @@ function parseCliArgs(argv) {
36
38
  pr: undefined,
37
39
  runId: undefined,
38
40
  requireExisting: false,
41
+ jq: undefined,
42
+ silent: false,
39
43
  };
40
44
  const command = args.shift();
41
45
  if (command === undefined || command === "--help" || command === "-h") {
@@ -51,6 +55,7 @@ function parseCliArgs(argv) {
51
55
  pr: { type: "string" },
52
56
  "run-id": { type: "string" },
53
57
  "require-existing": { type: "boolean" },
58
+ ...JQ_OUTPUT_PARSE_OPTIONS,
54
59
  },
55
60
  allowPositionals: true,
56
61
  strict: false,
@@ -83,6 +88,14 @@ function parseCliArgs(argv) {
83
88
  options.requireExisting = true;
84
89
  continue;
85
90
  }
91
+ if (token.name === "jq") {
92
+ options.jq = requireTokenValue(token, parseError);
93
+ continue;
94
+ }
95
+ if (token.name === "silent") {
96
+ options.silent = true;
97
+ continue;
98
+ }
86
99
  throw parseError(`Unknown argument: ${token.rawName}`);
87
100
  }
88
101
  const validCommands = new Set(["status", "claim", "takeover", "assert", "release"]);
@@ -146,11 +159,14 @@ async function main() {
146
159
  }
147
160
  const result = await runPrRunnerCoordination(options, { env: process.env });
148
161
  if (!result.ok) {
149
- console.error(JSON.stringify(result));
150
- process.exitCode = 1;
151
- return;
162
+ // Preserve the established stderr-on-failure contract when not filtering.
163
+ if (options.jq === undefined && !options.silent) {
164
+ console.error(JSON.stringify(result));
165
+ process.exitCode = 1;
166
+ return;
167
+ }
152
168
  }
153
- console.log(JSON.stringify(result));
169
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
154
170
  } catch (error) {
155
171
  const payload = formatCliError(error, { usage: USAGE });
156
172
  console.error(JSON.stringify(payload));
@@ -23,18 +23,37 @@ import { detectRepoSlug } from "@dev-loops/core/github/repo-slug";
23
23
  import { isCopilotLogin } from "@dev-loops/core/github/copilot-helpers";
24
24
  import { loadDevLoopConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
25
25
  import { createPiAdapter } from "@dev-loops/core/harness";
26
+ import { validatePlanFile } from "../refine/validate-plan-file.mjs";
27
+ import {
28
+ validateSpikeExplorationSections,
29
+ SPIKE_FILE_EXIT_MARKER_SECTION,
30
+ } from "../refine/validate-spike-file.mjs";
31
+ import { extractSection } from "../refine/_refine-helpers.mjs";
32
+ import {
33
+ evaluatePlanFileIntakeState,
34
+ PLAN_FILE_REFINEMENT_SECTIONS,
35
+ } from "@dev-loops/core/loop/plan-file-intake-contract";
36
+ import { evaluateSpikeIntakeState } from "@dev-loops/core/loop/spike-intake-contract";
26
37
  import { parseArgs } from "node:util";
27
38
  const USAGE = `Usage:
28
39
  resolve-dev-loop-startup.mjs --issue <number>
29
40
  resolve-dev-loop-startup.mjs --pr <number>
30
41
  resolve-dev-loop-startup.mjs --input <path>
42
+ resolve-dev-loop-startup.mjs --plan-file <path>
43
+ resolve-dev-loop-startup.mjs --spike <path>
31
44
  Resolve the authoritative public dev-loop startup/resume bundle.
32
45
  Auto-resolves state from GitHub API, git remote, and settings when
33
46
  --issue or --pr is used. Use --input for non-standard states.
47
+ Use --plan-file to start local planning from a phase-doc-format plan
48
+ (read-only: no tracker mutation, no issue/PR number).
49
+ Use --spike to start a time-boxed exploratory loop from a local spike
50
+ artifact (read-only: no tracker mutation, no issue/PR number).
34
51
  Required (exactly one):
35
52
  --issue <n> Target an issue by number (auto-resolves all state)
36
53
  --pr <n> Target a PR by number (auto-resolves all state)
37
54
  --input <path> Path to a JSON file with canonical-state payload
55
+ --plan-file <path> Path to a phase-doc-format plan to start locally
56
+ --spike <path> Path to a spike artifact to start a spike loop locally
38
57
  Exit codes:
39
58
  0 Success
40
59
  1 Argument error, runtime failure, or async-start contract rejection`.trim();
@@ -102,6 +121,8 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
102
121
  inputPath: undefined,
103
122
  issue: undefined,
104
123
  pr: undefined,
124
+ planFile: undefined,
125
+ spike: undefined,
105
126
  };
106
127
  const { tokens } = parseArgs({
107
128
  args: [...argv],
@@ -110,6 +131,8 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
110
131
  input: { type: "string" },
111
132
  issue: { type: "string" },
112
133
  pr: { type: "string" },
134
+ "plan-file": { type: "string" },
135
+ spike: { type: "string" },
113
136
  },
114
137
  allowPositionals: true,
115
138
  strict: false,
@@ -138,14 +161,22 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
138
161
  options.pr = parsePositiveInteger(requireTokenValue(token, parseError), "--pr", parseError);
139
162
  continue;
140
163
  }
164
+ if (token.name === "plan-file") {
165
+ options.planFile = requireTokenValue(token, parseError);
166
+ continue;
167
+ }
168
+ if (token.name === "spike") {
169
+ options.spike = requireTokenValue(token, parseError);
170
+ continue;
171
+ }
141
172
  throw parseError(`Unknown argument: ${token.rawName}`);
142
173
  }
143
- const modeCount = [options.inputPath, options.issue, options.pr].filter(v => v !== undefined).length;
174
+ const modeCount = [options.inputPath, options.issue, options.pr, options.planFile, options.spike].filter(v => v !== undefined).length;
144
175
  if (modeCount > 1) {
145
- throw parseError("--issue, --pr, and --input are mutually exclusive; provide exactly one");
176
+ throw parseError("--issue, --pr, --input, --plan-file, and --spike are mutually exclusive; provide exactly one");
146
177
  }
147
178
  if (modeCount === 0) {
148
- throw parseError("--input <path>, --issue <n>, or --pr <n> is required");
179
+ throw parseError("--input <path>, --issue <n>, --pr <n>, --plan-file <path>, or --spike <path> is required");
149
180
  }
150
181
  return options;
151
182
  }
@@ -368,6 +399,118 @@ export function buildAutoResolvedInput({ issue, pr, cwd, targetPreference, input
368
399
  },
369
400
  };
370
401
  }
402
+ /**
403
+ * Read + validate a `--plan-file` path and build a local_phase startup input.
404
+ *
405
+ * Read-only: no tracker mutation, no GitHub calls, no issue/PR number. A
406
+ * missing/unreadable file, or one failing the base-section validator, throws so
407
+ * the CLI fails closed (exit 1, no readiness bundle). The plan-file path is
408
+ * carried as the target `phase` and is exempt from the worktree-isolation guard
409
+ * because there is no issue to key a worktree on before promotion.
410
+ *
411
+ * @returns {object} startup input with a `planFileIntakeState` field threaded onto output
412
+ */
413
+ export function buildPlanFileInput({ planFilePath }) {
414
+ const resolvedPath = path.resolve(planFilePath);
415
+ let markdownText;
416
+ try {
417
+ markdownText = readFileSync(resolvedPath, "utf8");
418
+ } catch (err) {
419
+ throw new Error(`Plan file is missing or unreadable: ${resolvedPath} (${err instanceof Error ? err.message : String(err)})`);
420
+ }
421
+ const validation = validatePlanFile(markdownText);
422
+ if (!validation.ok) {
423
+ const codes = validation.errors.map(e => e.code).join(", ");
424
+ throw new Error(`Plan file failed validation (${resolvedPath}): ${codes}`);
425
+ }
426
+ // Refined-vs-needs-refinement: refinement adds Acceptance criteria + Definition
427
+ // of done on top of the base authoring sections. Detect each via extractSection
428
+ // (non-null trimmed body == present and non-empty), then classify with the pure
429
+ // intake evaluator.
430
+ const [acHeading, dodHeading] = PLAN_FILE_REFINEMENT_SECTIONS;
431
+ const hasAcceptanceCriteria = extractSection(markdownText, acHeading) ? true : false;
432
+ const hasDefinitionOfDone = extractSection(markdownText, dodHeading) ? true : false;
433
+ const { state: planFileIntakeState } = evaluatePlanFileIntakeState({
434
+ baseSectionsValid: true,
435
+ hasAcceptanceCriteria,
436
+ hasDefinitionOfDone,
437
+ });
438
+ return {
439
+ intent: "start_issue_locally",
440
+ mode: "bounded_handoff",
441
+ targetPreference: "prefer_local",
442
+ artifactState: "not_applicable",
443
+ issueLinkageResolution: "not_applicable",
444
+ issueReadiness: "not_applicable",
445
+ issueAssignmentState: "not_applicable",
446
+ loopState: "implementation_pending",
447
+ planFileIntakeState,
448
+ planFileExempt: true,
449
+ currentState: {
450
+ target: { kind: "local_phase", issue: null, pr: null, linkedPr: null, branch: null, phase: resolvedPath },
451
+ ownership: "local",
452
+ nextActor: "local",
453
+ status: "active",
454
+ authorization: "authorized",
455
+ },
456
+ };
457
+ }
458
+ /**
459
+ * Read + validate a `--spike` path and build a local_phase startup input for a
460
+ * time-boxed exploratory loop.
461
+ *
462
+ * Read-only: no tracker mutation, no GitHub calls, no issue/PR number — a spike
463
+ * is startable from a local question with no GitHub issue and no production-gate
464
+ * ceremony at entry. A missing/unreadable file, or one missing the exploration
465
+ * scaffold (Question/Approach/Findings), throws so the CLI fails closed (exit 1,
466
+ * no readiness bundle). The Recommendation section is filled in during the spike;
467
+ * its presence flips the intake state to spike_ready_for_exit (the seam phase 2's
468
+ * discard/graduate exits consume). The spike path is carried as the target
469
+ * `phase` and is exempt from the worktree-isolation guard because there is no
470
+ * issue to key a worktree on.
471
+ *
472
+ * @returns {object} startup input with a `spikeIntakeState` field threaded onto output
473
+ */
474
+ export function buildSpikeInput({ spikeFilePath }) {
475
+ const resolvedPath = path.resolve(spikeFilePath);
476
+ let markdownText;
477
+ try {
478
+ markdownText = readFileSync(resolvedPath, "utf8");
479
+ } catch (err) {
480
+ throw new Error(`Spike file is missing or unreadable: ${resolvedPath} (${err instanceof Error ? err.message : String(err)})`);
481
+ }
482
+ // Entry gate: the exploration scaffold must be present and non-empty. A
483
+ // malformed spike artifact fails closed before any intake classification.
484
+ const validation = validateSpikeExplorationSections(markdownText);
485
+ if (!validation.ok) {
486
+ const codes = validation.errors.map(e => e.code).join(", ");
487
+ throw new Error(`Spike file failed validation (${resolvedPath}): ${codes}`);
488
+ }
489
+ const hasRecommendation = extractSection(markdownText, SPIKE_FILE_EXIT_MARKER_SECTION) ? true : false;
490
+ const { state: spikeIntakeState } = evaluateSpikeIntakeState({
491
+ baseSectionsValid: true,
492
+ hasRecommendation,
493
+ });
494
+ return {
495
+ intent: "start_issue_locally",
496
+ mode: "bounded_handoff",
497
+ targetPreference: "prefer_local",
498
+ artifactState: "not_applicable",
499
+ issueLinkageResolution: "not_applicable",
500
+ issueReadiness: "not_applicable",
501
+ issueAssignmentState: "not_applicable",
502
+ loopState: "implementation_pending",
503
+ spikeIntakeState,
504
+ planFileExempt: true,
505
+ currentState: {
506
+ target: { kind: "local_phase", issue: null, pr: null, linkedPr: null, branch: null, phase: resolvedPath },
507
+ ownership: "local",
508
+ nextActor: "local",
509
+ status: "active",
510
+ authorization: "authorized",
511
+ },
512
+ };
513
+ }
371
514
  export function summarizeCanonicalState(bundle) {
372
515
  return {
373
516
  target: bundle.canonicalState?.target ?? null,
@@ -390,6 +533,16 @@ export function summarizeCanonicalState(bundle) {
390
533
  export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdapter(), env, cwd, asyncStartMode = "required" } = {}) {
391
534
  const effectiveEnv = env ?? adapter.getEnv();
392
535
  const effectiveCwd = cwd ?? adapter.getCwd();
536
+ // Normalize a non-object input (e.g. `--input null`, which parses to a legal
537
+ // JSON null) to {} so the destructure below cannot throw before routing can
538
+ // fail closed with a structured reconcile bundle.
539
+ if (input === null || typeof input !== "object") input = {};
540
+ // Plan-file intake carries two resolver-only fields that the pure routing
541
+ // evaluator does not model. Strip them before evaluation and re-apply them to
542
+ // the result; `planFileExempt` waives the worktree-isolation guard because a
543
+ // pre-promotion plan has no issue to key a worktree on.
544
+ const { planFileExempt = false, planFileIntakeState = null, spikeIntakeState = null, ...routingInput } = input;
545
+ input = routingInput;
393
546
  try {
394
547
  const checkpointText = readFileSync(
395
548
  path.join(effectiveCwd, ".pi", "dev-loop-retrospective-checkpoint.json"),
@@ -440,6 +593,7 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
440
593
  const DEVLOOPS_WORKTREE_BYPASS_VAR = "DEVLOOPS_WORKTREE_BYPASS";
441
594
  if (
442
595
  strategyKey === "local_implementation" &&
596
+ !planFileExempt &&
443
597
  (effectiveEnv[DEVLOOPS_WORKTREE_BYPASS_VAR] ?? "").trim() !== "1"
444
598
  ) {
445
599
  try {
@@ -496,6 +650,8 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
496
650
  requiredReads: STRATEGY_REQUIRED_READS[strategyKey],
497
651
  nextAction: bundle.nextAction,
498
652
  canonicalStateSummary: summarizeCanonicalState(bundle),
653
+ ...(planFileIntakeState !== null ? { planFileIntakeState } : {}),
654
+ ...(spikeIntakeState !== null ? { spikeIntakeState } : {}),
499
655
  bundle,
500
656
  };
501
657
  }
@@ -521,9 +677,24 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
521
677
  ? normalizeConfigInputSource(devLoopConfig?.inputSource?.default)
522
678
  : "tracker";
523
679
  let input;
524
- if (options.inputPath !== undefined) {
680
+ if (options.spike !== undefined) {
681
+ input = buildSpikeInput({ spikeFilePath: options.spike });
682
+ } else if (options.planFile !== undefined) {
683
+ input = buildPlanFileInput({ planFilePath: options.planFile });
684
+ } else if (options.inputPath !== undefined) {
525
685
  const text = await readFile(path.resolve(options.inputPath), "utf8");
526
- input = parseJsonText(text);
686
+ const parsed = parseJsonText(text);
687
+ // `--input` is untrusted external JSON. Strip the resolver-only intake fields
688
+ // so it cannot inject them — `planFileExempt` would otherwise waive the
689
+ // worktree-isolation guard for a normal local_implementation, and the intake
690
+ // state is owned by the internal plan-file path (buildPlanFileInput), not the
691
+ // caller.
692
+ if (parsed && typeof parsed === "object") {
693
+ delete parsed.planFileExempt;
694
+ delete parsed.planFileIntakeState;
695
+ delete parsed.spikeIntakeState;
696
+ }
697
+ input = parsed;
527
698
  } else if (options.issue !== undefined) {
528
699
  input = buildAutoResolvedInput({
529
700
  issue: options.issue,