dev-loops 0.5.0 → 0.7.1

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 (177) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/agents/review.md +2 -1
  4. package/.claude/commands/loop-auto.md +7 -0
  5. package/.claude/commands/loop-continue.md +15 -0
  6. package/.claude/commands/loop-enqueue.md +22 -0
  7. package/.claude/commands/loop-grill.md +17 -0
  8. package/.claude/commands/loop-info.md +7 -0
  9. package/.claude/commands/loop-queue-status.md +24 -0
  10. package/.claude/commands/loop-start-spike.md +16 -0
  11. package/.claude/commands/loop-start.md +7 -0
  12. package/.claude/commands/loop-status.md +6 -0
  13. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  14. package/.claude/hooks/_hook-decisions.mjs +138 -15
  15. package/.claude/hooks/_run-context.mjs +11 -4
  16. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  17. package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
  18. package/.claude/skills/dev-loop/SKILL.md +19 -10
  19. package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
  20. package/.claude/skills/docs/anti-patterns.md +2 -1
  21. package/.claude/skills/docs/artifact-authority-contract.md +22 -4
  22. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  23. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  24. package/.claude/skills/docs/issue-intake-procedure.md +1 -0
  25. package/.claude/skills/docs/local-planning-flow.md +1 -1
  26. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  27. package/.claude/skills/docs/merge-preconditions.md +17 -1
  28. package/.claude/skills/docs/plan-file-contract.md +1 -1
  29. package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
  30. package/.claude/skills/docs/release-runbook.md +45 -0
  31. package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
  32. package/.claude/skills/docs/ui-e2e-scoping-step.md +134 -0
  33. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  34. package/.claude/skills/local-implementation/SKILL.md +26 -15
  35. package/.claude/skills/loop-grill/SKILL.md +163 -0
  36. package/AGENTS.md +1 -0
  37. package/CHANGELOG.md +125 -0
  38. package/README.md +29 -10
  39. package/agents/dev-loop.agent.md +11 -3
  40. package/agents/developer.agent.md +1 -1
  41. package/agents/docs.agent.md +1 -1
  42. package/agents/fixer.agent.md +1 -1
  43. package/agents/quality.agent.md +1 -1
  44. package/agents/refiner.agent.md +1 -1
  45. package/agents/review.agent.md +3 -2
  46. package/cli/index.mjs +35 -40
  47. package/extension/README.md +4 -4
  48. package/extension/checks.ts +1 -5
  49. package/extension/harness-types.ts +1 -0
  50. package/extension/index.ts +16 -1
  51. package/extension/pi-extension-adapter.ts +2 -0
  52. package/extension/presentation.ts +16 -13
  53. package/lib/dev-loops-core.mjs +141 -0
  54. package/package.json +10 -8
  55. package/scripts/claude/generate-claude-assets.mjs +15 -1
  56. package/scripts/github/capture-review-threads.mjs +2 -9
  57. package/scripts/github/comment-issue.mjs +174 -0
  58. package/scripts/github/create-label.mjs +133 -0
  59. package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
  60. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  61. package/scripts/github/edit-pr.mjs +259 -0
  62. package/scripts/github/fetch-ci-logs.mjs +208 -0
  63. package/scripts/github/list-issues.mjs +184 -0
  64. package/scripts/github/manage-sub-issues.mjs +46 -10
  65. package/scripts/github/offer-human-handoff.mjs +8 -5
  66. package/scripts/github/post-gate-findings.mjs +14 -2
  67. package/scripts/github/probe-ci-status.mjs +8 -2
  68. package/scripts/github/probe-copilot-review.mjs +21 -14
  69. package/scripts/github/ready-for-review.mjs +26 -8
  70. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  71. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  72. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  73. package/scripts/github/request-copilot-review.mjs +8 -2
  74. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  75. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  76. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  77. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  78. package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
  79. package/scripts/github/verify-fresh-review-context.mjs +150 -31
  80. package/scripts/github/view-pr.mjs +150 -0
  81. package/scripts/github/write-gate-context.mjs +248 -61
  82. package/scripts/github/write-gate-findings-log.mjs +75 -1
  83. package/scripts/lib/jq-output.mjs +18 -0
  84. package/scripts/loop/_handoff-contract.mjs +1 -0
  85. package/scripts/loop/_post-convergence-change.mjs +211 -0
  86. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  87. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  88. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  89. package/scripts/loop/check-retro-tooling.mjs +14 -3
  90. package/scripts/loop/checkpoint-contract.mjs +7 -4
  91. package/scripts/loop/cleanup-worktree.mjs +12 -3
  92. package/scripts/loop/conductor-monitor.mjs +12 -18
  93. package/scripts/loop/copilot-pr-handoff.mjs +135 -14
  94. package/scripts/loop/debt-remediate.mjs +24 -12
  95. package/scripts/loop/detect-change-scope.mjs +36 -7
  96. package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
  97. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  98. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  99. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  100. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  101. package/scripts/loop/detect-pr-gate-coordination-state.mjs +179 -66
  102. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  103. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  104. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  105. package/scripts/loop/ensure-worktree.mjs +8 -3
  106. package/scripts/loop/info.mjs +12 -4
  107. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  108. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  109. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  110. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  111. package/scripts/loop/inspect-run.mjs +8 -2
  112. package/scripts/loop/outer-loop.mjs +8 -4
  113. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  114. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  115. package/scripts/loop/pre-flight-gate.mjs +9 -9
  116. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  117. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  118. package/scripts/loop/provision-worktree.mjs +74 -3
  119. package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
  120. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  121. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  122. package/scripts/loop/run-conductor-cycle.mjs +13 -2
  123. package/scripts/loop/run-queue.mjs +18 -9
  124. package/scripts/loop/run-refinement-audit.mjs +8 -9
  125. package/scripts/loop/run-watch-cycle.mjs +2 -9
  126. package/scripts/loop/sanctioned-commands.mjs +104 -0
  127. package/scripts/loop/steer-loop.mjs +29 -16
  128. package/scripts/loop/validate-pr-body-spec.mjs +207 -0
  129. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  130. package/scripts/pages/build-site.mjs +59 -8
  131. package/scripts/pages/build-state-atlas.mjs +441 -0
  132. package/scripts/projects/_resolve-project.mjs +148 -0
  133. package/scripts/projects/add-queue-item.mjs +60 -54
  134. package/scripts/projects/archive-done-items.mjs +49 -84
  135. package/scripts/projects/ensure-queue-board.mjs +10 -36
  136. package/scripts/projects/list-queue-items.mjs +116 -65
  137. package/scripts/projects/move-queue-item.mjs +28 -49
  138. package/scripts/projects/reconcile-queue.mjs +253 -0
  139. package/scripts/projects/reorder-queue-item.mjs +41 -47
  140. package/scripts/projects/resolve-active-board-item.mjs +255 -0
  141. package/scripts/projects/sync-item-status.mjs +15 -10
  142. package/scripts/refine/_refine-helpers.mjs +21 -5
  143. package/scripts/refine/exit-spike.mjs +18 -8
  144. package/scripts/refine/promote-plan.mjs +22 -16
  145. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  146. package/scripts/refine/refine-plan-file.mjs +13 -4
  147. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  148. package/scripts/refine/scaffold-spike-file.mjs +179 -0
  149. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  150. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  151. package/scripts/refine/validate-plan-file.mjs +1 -1
  152. package/scripts/refine/validate-spike-file.mjs +1 -1
  153. package/scripts/refine/verify.mjs +11 -6
  154. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  155. package/scripts/release/extract-changelog-section.mjs +125 -0
  156. package/skills/copilot-pr-followup/SKILL.md +24 -12
  157. package/skills/dev-loop/SKILL.md +22 -6
  158. package/skills/docs/acceptance-criteria-verification.md +6 -2
  159. package/skills/docs/anti-patterns.md +2 -1
  160. package/skills/docs/artifact-authority-contract.md +22 -4
  161. package/skills/docs/copilot-loop-operations.md +1 -1
  162. package/skills/docs/cross-harness-regression-contract.md +60 -0
  163. package/skills/docs/issue-intake-procedure.md +1 -0
  164. package/skills/docs/local-planning-flow.md +1 -1
  165. package/skills/docs/local-planning-worked-example.md +1 -1
  166. package/skills/docs/merge-preconditions.md +17 -1
  167. package/skills/docs/plan-file-contract.md +1 -1
  168. package/skills/docs/public-dev-loop-contract.md +1 -1
  169. package/skills/docs/release-runbook.md +45 -0
  170. package/skills/docs/retrospective-checkpoint-contract.md +90 -76
  171. package/skills/docs/ui-e2e-scoping-step.md +134 -0
  172. package/skills/docs/workflow-handoff-contract.md +39 -0
  173. package/skills/local-implementation/SKILL.md +26 -15
  174. package/skills/loop-grill/SKILL.md +165 -0
  175. package/scripts/loop/conductor.mjs +0 -233
  176. package/scripts/loop/detect-stale-runner.mjs +0 -265
  177. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -0,0 +1,165 @@
1
+ ---
2
+ name: loop-grill
3
+ description: >-
4
+ Standalone pre-loop Socratic Q&A grill for issues (tracker-first) or local
5
+ plan files (local-planning). Detects spec gaps, asks clarifying questions
6
+ (interactive) or self-answers them from codebase context (--auto), then
7
+ writes a ## Grill findings section back to the source artifact.
8
+ allowed-tools: read bash edit write
9
+ user-invocable: false
10
+ ---
11
+ # Loop-grill skill
12
+
13
+ A standalone, on-demand pre-loop grilling skill. Run it against an issue or a local plan file **before** the dev loop starts to surface underspecified acceptance criteria, fuzzy scope boundaries, unresolved primary actors, and undocumented hard-to-reverse decisions.
14
+
15
+ It is entirely separate from the in-loop docs-grill (`docs/docs-grill-step.md`), which audits code/doc drift while the loop runs. This skill operates on the *spec* before any implementation begins.
16
+
17
+ ## Interface
18
+
19
+ ```
20
+ /loop-grill <issue-number> # tracker-first, interactive
21
+ /loop-grill <issue-number> --auto # tracker-first, auto-answer
22
+ /loop-grill <path/to/plan.md> # local-planning, interactive
23
+ /loop-grill <path/to/plan.md> --auto # local-planning, auto-answer
24
+ ```
25
+
26
+ ## Argument validation (fail-closed)
27
+
28
+ Before doing anything else:
29
+
30
+ 1. Confirm exactly one positional argument is present (issue number or path). No argument → error, stop.
31
+ 2. Confirm the only optional flag is `--auto`. Any other flag → error, stop.
32
+ 3. **Tracker-first:** verify the issue exists. Non-existent issue → `Error: issue #<n> not found.`, stop.
33
+ 4. **Local-planning:** verify the file exists. Missing file → `Error: plan file not found: <path>`, stop.
34
+ 5. Never mutate any artifact when argument validation fails.
35
+
36
+ ## Step 1 — Load the target
37
+
38
+ - **Tracker-first:** fetch the issue body (title + description + any existing `## Grill findings` section).
39
+ - **Local-planning:** read the plan file from disk.
40
+
41
+ ## Step 1b — Surface external resources
42
+
43
+ Before detecting gaps, scan the loaded content for external resource references: links, other repo URLs, API endpoints, doc URLs, screenshots, or Playwright navigation descriptors.
44
+
45
+ - **Interactive mode:** if external resources are present, ask the operator to confirm they are accessible or to provide them before the Q&A starts.
46
+ - **`--auto` mode:** attempt to fetch each resource using bounded wrapper commands (e.g. `gh`, API wrapper scripts under `scripts/`). Do not fetch resources inline with raw `curl` or token-heavy calls. Flag any inaccessible resource as `unresolved` in the findings rather than silently skipping it.
47
+ - When no external resources are present, skip this step silently.
48
+
49
+ ## Step 2 — Detect gaps
50
+
51
+ Scan the loaded content and identify each gap. The minimum required gap detectors are:
52
+
53
+ | Gap kind | Detection signal |
54
+ |---|---|
55
+ | Missing acceptance criteria | No `## Acceptance criteria` section, or section is empty / stub |
56
+ | Missing scope boundary | No explicit in-scope / out-of-scope statement or non-goals section |
57
+ | Unresolved primary actor | No named user, system, or role that is the main beneficiary of the feature |
58
+ | Undocumented hard-to-reverse decision | Destructive or irreversible operations described without a rationale or rollback note |
59
+
60
+ Additional gaps discovered through semantic reading of the spec are also recorded.
61
+
62
+ For each gap, classify it as either:
63
+ - **Bounded choice** — the answer is one of a small discrete set (e.g. yes/no, A/B/C).
64
+ - **Open-ended** — the answer requires free-form elaboration.
65
+
66
+ ## Step 3 — Fill gaps
67
+
68
+ ### Interactive mode (default)
69
+
70
+ For each gap, in order:
71
+
72
+ - **Bounded choice gap:** use `AskUserQuestion` with the question text and the choice options, plus an "Other / free text" option. Block until the user answers.
73
+ - **Open-ended gap:** present the question as a plain text turn. Block until the user answers.
74
+
75
+ Record each answer with its source: `human`.
76
+
77
+ > `AskUserQuestion` is a Claude Code–native construct. If you are running outside Claude Code, use `--auto` mode instead.
78
+
79
+ ### Auto mode (`--auto`)
80
+
81
+ Answer every grilling question yourself without prompting the user. Source answers from (in priority order):
82
+
83
+ 1. **`codebase`** — inspectable source files, tests, scripts, config in the repository.
84
+ 2. **`docs`** — markdown files under `docs/`, `skills/docs/`, and adjacent contract docs.
85
+ 3. **`context`** — `CONTEXT.md` at the repo root, if present. When absent, skip silently — do not crash, no warning required.
86
+ 4. **`inferred`** — reasoning from the issue/plan text alone, with no external citation.
87
+
88
+ Record the evidence source for every answer. Flag a question as **`unresolved`** when:
89
+ - The only available source is `inferred`, **and**
90
+ - No codebase path, doc section, or issue/plan text can be cited as the basis for the answer.
91
+
92
+ Do not silently guess an `inferred` answer when no evidence can be cited — flag it `unresolved` instead.
93
+
94
+ ## Step 4 — Write back (replace-section semantics)
95
+
96
+ Write a `## Grill findings` section back to the source artifact using **replace-section** semantics:
97
+
98
+ - **Find** the existing `## Grill findings` section: the range from the `## Grill findings` heading through the next `##`-level heading (exclusive) or end of file.
99
+ - **Replace** that range in place with the new section content.
100
+ - If no `## Grill findings` section exists, **append** it.
101
+ - This makes re-runs idempotent — no accumulated noise, no duplicate sections.
102
+ - If parsing the section boundary fails, **abort with an error** rather than silently truncating.
103
+
104
+ **Tracker-first write-back:** update the GitHub issue body using:
105
+
106
+ ```
107
+ gh issue edit <n> --repo <owner/repo> --body-file <tmp-path>
108
+ ```
109
+
110
+ The `## Grill findings` section lives in the issue body, not as a comment. Do not use `comment-issue.mjs` here — that creates a comment, not a body update.
111
+
112
+ **GitHub body size guard:** issue bodies are capped at 65,536 characters. Before writing back, check whether the updated body would exceed this limit. If so, warn: `Warning: updated body would exceed GitHub's 65,536-character limit — write-back skipped. Truncate the findings or the issue body manually.` Do not silently truncate.
113
+
114
+ **Local-planning write-back:** update the plan file in place using the edit tool.
115
+
116
+ ## Output artifact format
117
+
118
+ The `## Grill findings` section written to the artifact:
119
+
120
+ ```markdown
121
+ ## Grill findings
122
+
123
+ <!-- loop-grill: <timestamp> mode:<interactive|auto> -->
124
+
125
+ ### Resolved gaps
126
+
127
+ | # | Gap | Question | Answer | Source |
128
+ |---|-----|----------|--------|--------|
129
+ | 1 | Missing AC | <question text> | <answer text> | codebase \| docs \| context \| inferred \| human |
130
+
131
+ ### Unresolved gaps
132
+
133
+ | # | Gap | Question | Reason unresolved |
134
+ |---|-----|----------|-------------------|
135
+ | 1 | Unresolved primary actor | <question text> | No citable evidence found |
136
+
137
+ ### Verdict
138
+
139
+ grill-clean
140
+ ```
141
+
142
+ Replace `grill-clean` with `N unresolved items` when unresolved gaps remain.
143
+
144
+ ## Step 5 — Emit verdict
145
+
146
+ After write-back, emit the verdict line to stdout:
147
+
148
+ - `grill-clean` when no unresolved gaps remain.
149
+ - `N unresolved items` (e.g. `3 unresolved items`) when gaps remain after all questions are answered.
150
+
151
+ ## CONTEXT.md degradation rule
152
+
153
+ When `CONTEXT.md` is absent from the repo root, skip the context-source check silently. Do not crash. Do not emit a warning. The grill continues with the remaining sources (`codebase`, `docs`, `inferred`).
154
+
155
+ ## Idempotency guarantee
156
+
157
+ Running `/loop-grill` twice on the same target must produce a single `## Grill findings` section, not two. The replace-section logic handles the already-present-section case. On the second run, if the gap set is identical to the first run, the section content is replaced with an equivalent section (same questions, same answers, updated timestamp).
158
+
159
+ ## Non-goals
160
+
161
+ - Auto-triggering from `issue_intake` — this is on-demand only.
162
+ - Replacing or modifying the in-loop docs-grill (`docs/docs-grill-step.md`, `scripts/loop/docs-grill-contract.mjs`) — different concern, different firing surface.
163
+ - Full DDD `CONTEXT.md` management.
164
+ - Scheduling or storing grill runs — stateless and on-demand.
165
+ - Any CI/CD integration.
@@ -1,233 +0,0 @@
1
- #!/usr/bin/env node
2
- import { runConductorCycle } from "./run-conductor-cycle.mjs";
3
- import { runConductorMonitor } from "./conductor-monitor.mjs";
4
- import { requireTokenValue } from "../_cli-primitives.mjs";
5
- import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
6
- import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
7
- import {
8
- loadDevLoopConfig,
9
- resolveWorkflowConfig,
10
- resolveAutonomyStopAt,
11
- resolveGateConfig,
12
- } from "@dev-loops/core/config";
13
- import { readFileSync } from "node:fs";
14
- import path from "node:path";
15
- import { parseArgs } from "node:util";
16
- const USAGE = `Usage: conductor.mjs --repo <owner/name> [--auto-resume] [--cycle-only] [--monitor-only] [--require-retrospective]
17
- Unified conductor entrypoint for dev-loop lifecycle orchestration.`.trim();
18
- const parseError = buildParseError(USAGE);
19
- function checkRetrospectiveGate(cwd, requireRetrospective) {
20
- if (!requireRetrospective) return { blocked: false };
21
- try {
22
- const checkpointPath = path.join(cwd, ".pi", "dev-loop-retrospective-checkpoint.json");
23
- const checkpointText = readFileSync(checkpointPath, "utf8");
24
- const checkpoint = JSON.parse(checkpointText);
25
- const state = typeof checkpoint?.state === "string" ? checkpoint.state.trim().toLowerCase() : null;
26
- if (state === "none" || state === "complete" || state === "skipped") {
27
- return { blocked: false };
28
- }
29
- if (state === "required" || state === "missing" || state === null || state === "") {
30
- return {
31
- blocked: true,
32
- reason: state === "required" || state === "missing"
33
- ? `Retrospective checkpoint pending (state: ${state}). Complete the retrospective before running the conductor.`
34
- : "Retrospective checkpoint file exists but has an unrecognized or empty state; cannot determine retrospective status safely.",
35
- };
36
- }
37
- return {
38
- blocked: true,
39
- reason: `Retrospective checkpoint has an unrecognized state: "${state}".`,
40
- };
41
- } catch (err) {
42
- if (err?.code === "ENOENT") return { blocked: false };
43
- return { blocked: true, reason: `Cannot read retrospective checkpoint: ${err.message}` };
44
- }
45
- }
46
- function parseCliArgs(argv) {
47
- const options = {
48
- help: false,
49
- repo: undefined,
50
- autoResume: false,
51
- cycleOnly: false,
52
- monitorOnly: false,
53
- requireRetrospective: false,
54
- };
55
- const { tokens } = parseArgs({
56
- args: [...argv],
57
- options: {
58
- help: { type: "boolean", short: "h" },
59
- repo: { type: "string" },
60
- "auto-resume": { type: "boolean" },
61
- "cycle-only": { type: "boolean" },
62
- "monitor-only": { type: "boolean" },
63
- "require-retrospective": { type: "boolean" },
64
- },
65
- allowPositionals: true,
66
- strict: false,
67
- tokens: true,
68
- });
69
- for (const token of tokens) {
70
- if (token.kind === "positional") {
71
- throw parseError(`Unknown argument: ${token.value}`);
72
- }
73
- if (token.kind !== "option") {
74
- continue;
75
- }
76
- if (token.name === "help") {
77
- options.help = true;
78
- return options;
79
- }
80
- if (token.name === "repo") {
81
- options.repo = requireTokenValue(token, parseError).trim();
82
- continue;
83
- }
84
- if (token.name === "auto-resume") {
85
- options.autoResume = true;
86
- continue;
87
- }
88
- if (token.name === "cycle-only") {
89
- options.cycleOnly = true;
90
- continue;
91
- }
92
- if (token.name === "monitor-only") {
93
- options.monitorOnly = true;
94
- continue;
95
- }
96
- if (token.name === "require-retrospective") {
97
- options.requireRetrospective = true;
98
- continue;
99
- }
100
- throw parseError(`Unknown argument: ${token.rawName}`);
101
- }
102
- if (options.repo === undefined) {
103
- throw parseError("conductor requires --repo <owner/name>");
104
- }
105
- if (options.cycleOnly && options.monitorOnly) {
106
- throw parseError("--cycle-only and --monitor-only are mutually exclusive");
107
- }
108
- try {
109
- parseRepoSlug(options.repo);
110
- } catch (error) {
111
- throw parseError(error instanceof Error ? error.message : String(error));
112
- }
113
- return options;
114
- }
115
- export async function runConductor(options, runtime = {}) {
116
- const { cycleOnly = false, monitorOnly = false, autoResume = false, requireRetrospective: forceRetrospective = false } = options;
117
- const cwd = runtime.repoRoot || process.cwd();
118
- const loadConfig = runtime.loadConfigImpl || loadDevLoopConfig;
119
- let configLoadResult;
120
- let requireRetrospective = false;
121
- let autonomyStopAt = ["merge"];
122
- let gateConfig = { draft: { requireCi: true }, preApproval: { requireCi: true } };
123
- try {
124
- configLoadResult = await loadConfig({ repoRoot: cwd });
125
- const hasErrors = Array.isArray(configLoadResult.errors) && configLoadResult.errors.length > 0;
126
- if (hasErrors) {
127
- configLoadResult = { ...configLoadResult };
128
- } else {
129
- const cfg = configLoadResult.config ?? {};
130
- requireRetrospective = resolveWorkflowConfig(cfg, "requireRetrospective");
131
- autonomyStopAt = resolveAutonomyStopAt(cfg);
132
- const draftCfg = resolveGateConfig(cfg, "draft");
133
- const preApprovalCfg = resolveGateConfig(cfg, "preApproval");
134
- gateConfig = {
135
- draft: { requireCi: draftCfg.requireCi },
136
- preApproval: { requireCi: preApprovalCfg.requireCi },
137
- };
138
- }
139
- } catch (error) {
140
- const errorMessage = error instanceof Error ? error.message : String(error);
141
- configLoadResult = { config: null, warnings: [], errors: [{ path: "<config>", message: `Failed to load config: ${errorMessage}`, layer: "merged" }] };
142
- }
143
- const effectiveRequireRetrospective = forceRetrospective || requireRetrospective;
144
- const retroGate = checkRetrospectiveGate(cwd, effectiveRequireRetrospective);
145
- if (retroGate.blocked) {
146
- return {
147
- ok: false,
148
- error: retroGate.reason,
149
- repo: options.repo,
150
- blockedByRetrospective: true,
151
- checkedAt: new Date().toISOString(),
152
- };
153
- }
154
- const runCycle = !monitorOnly;
155
- const runMonitor = !cycleOnly;
156
- const cycleResult = runCycle
157
- ? await runConductorCycle({ repo: options.repo, autonomyStopAt, gateConfig }, runtime).catch((error) => ({
158
- ok: false,
159
- error: error instanceof Error ? error.message : String(error),
160
- }))
161
- : null;
162
- const monitorResult = runMonitor
163
- ? await runConductorMonitor({ repo: options.repo, autoResume }, runtime).catch((error) => ({
164
- ok: false,
165
- error: error instanceof Error ? error.message : String(error),
166
- }))
167
- : null;
168
- const cycleOk = cycleResult?.ok === true;
169
- const monitorOk = monitorResult?.ok === true;
170
- return {
171
- ok: (runCycle ? cycleOk : true) && (runMonitor ? monitorOk : true),
172
- repo: options.repo,
173
- checkedAt: new Date().toISOString(),
174
- cycle: cycleResult ?? null,
175
- monitor: monitorResult ?? null,
176
- cycleOk,
177
- monitorOk,
178
- config: {
179
- requireRetrospective: effectiveRequireRetrospective,
180
- configRequireRetrospective: requireRetrospective,
181
- autonomyStopAt,
182
- gateConfig,
183
- configErrors: configLoadResult?.errors?.length ?? 0,
184
- },
185
- summary: {
186
- totalPrs: (cycleResult?.prCount ?? 0) || (monitorResult?.prCount ?? 0),
187
- cycleActions: cycleResult?.actions?.length ?? 0,
188
- needsSubagent: cycleResult?.summary?.needsSubagent ?? 0,
189
- readyToMerge: cycleResult?.summary?.readyToMerge ?? 0,
190
- waiting: cycleResult?.summary?.waiting ?? 0,
191
- blocked: cycleResult?.summary?.blocked ?? 0,
192
- done: cycleResult?.summary?.done ?? 0,
193
- errors: cycleResult?.summary?.errors ?? 0,
194
- queueStatus: monitorResult?.queueStatus ?? "unknown",
195
- needsAttentionCount: monitorResult?.needsAttentionCount ?? 0,
196
- orphanedPrCount: monitorResult?.orphanedPrCount ?? 0,
197
- resumePlanCount: monitorResult?.resumePlanCount ?? 0,
198
- manualAttentionCount: monitorResult?.manualAttentionCount ?? 0,
199
- },
200
- };
201
- }
202
- export async function runCli(
203
- argv = process.argv.slice(2),
204
- {
205
- stdout = process.stdout,
206
- env = process.env,
207
- ghCommand = "gh",
208
- cwd = process.cwd(),
209
- } = {},
210
- ) {
211
- const options = parseCliArgs(argv);
212
- if (options.help) {
213
- stdout.write(`${USAGE}\n`);
214
- return;
215
- }
216
- const result = await runConductor(options, {
217
- env,
218
- ghCommand,
219
- repoRoot: cwd,
220
- });
221
- if (result.ok === false) {
222
- process.stderr.write(`${JSON.stringify(result)}\n`);
223
- process.exitCode = 1;
224
- return;
225
- }
226
- stdout.write(`${JSON.stringify(result)}\n`);
227
- }
228
- if (isDirectCliRun(import.meta.url)) {
229
- runCli().catch((error) => {
230
- process.stderr.write(`${formatCliError(error)}\n`);
231
- process.exitCode = 1;
232
- });
233
- }
@@ -1,265 +0,0 @@
1
- #!/usr/bin/env node
2
- import process from "node:process";
3
- import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
4
- import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
5
- import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
- import { resolveRunId as resolveEnvRunId } from "@dev-loops/core/loop/run-context";
7
- import { parseArgs } from "node:util";
8
- import {
9
- detectStaleRunner,
10
- STALE_RUNNER_ERROR,
11
- } from "./_stale-runner-detection.mjs";
12
- const USAGE = `Usage: detect-stale-runner.mjs --repo <owner/name> --pr <number>
13
- Detect whether the active runner for a PR is stale or has received an exit
14
- signal. Fails closed with status "stale_runner" or "exit_signal_recorded" so
15
- the pre-merge guard can refuse to proceed.
16
- Required:
17
- --repo <owner/name> Repository slug (e.g. owner/repo)
18
- --pr <number> Pull request number
19
- Optional:
20
- --stale-runner-max-age-ms <ms>
21
- Override the staleness threshold (default 30 minutes,
22
- or $DEVLOOPS_STALE_RUNNER_MAX_AGE_MS).
23
- --run-id <id> Override the active run id (default: read from
24
- DEVLOOPS_RUN_ID). When supplied, the detector additionally
25
- verifies the current run id is still the active owner.
26
- Output (stdout, JSON; always includes staleRunnerCheck):
27
- {
28
- "ok": true,
29
- "repo": "owner/repo",
30
- "pr": 17,
31
- "status": "fresh_runner",
32
- "activeRun": { "runId": "...", "claimedAt": "...", "updatedAt": "..." },
33
- "staleRunnerCheck": {
34
- "ok": true,
35
- "failures": []
36
- }
37
- }
38
- or on failure:
39
- {
40
- "ok": false,
41
- "error": "stale_runner" | "exit_signal_recorded",
42
- "message": "...",
43
- "staleRunnerCheck": {
44
- "ok": false,
45
- "failures": ["stale runner: run X claimed N ms ago, last updated M ms ago (max age K ms)"]
46
- }
47
- }
48
- Exit codes:
49
- 0 Success / fresh runner / no owner record
50
- 1 Argument error or stale/exit-signal condition detected`.trim();
51
- const parseError = buildParseError(USAGE);
52
- function parseCliArgs(argv) {
53
- const options = {
54
- help: false,
55
- repo: undefined,
56
- pr: undefined,
57
- staleRunnerMaxAgeMs: undefined,
58
- runId: undefined,
59
- };
60
- const { tokens } = parseArgs({
61
- args: [...argv],
62
- options: {
63
- help: { type: "boolean", short: "h" },
64
- repo: { type: "string" },
65
- pr: { type: "string" },
66
- "stale-runner-max-age-ms": { type: "string" },
67
- "run-id": { type: "string" },
68
- },
69
- allowPositionals: true,
70
- strict: false,
71
- tokens: true,
72
- });
73
- for (const token of tokens) {
74
- if (token.kind === "positional") {
75
- throw parseError(`Unknown argument: ${token.value}`);
76
- }
77
- if (token.kind !== "option") {
78
- continue;
79
- }
80
- if (token.name === "help") {
81
- options.help = true;
82
- return options;
83
- }
84
- if (token.name === "repo") {
85
- options.repo = requireTokenValue(token, parseError).trim();
86
- continue;
87
- }
88
- if (token.name === "pr") {
89
- options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
90
- continue;
91
- }
92
- if (token.name === "stale-runner-max-age-ms") {
93
- const raw = requireTokenValue(token, parseError).trim();
94
- const parsed = Number(raw);
95
- if (!Number.isFinite(parsed) || parsed <= 0) {
96
- throw parseError(`--stale-runner-max-age-ms must be a positive integer (ms), got: ${raw}`);
97
- }
98
- options.staleRunnerMaxAgeMs = Math.floor(parsed);
99
- continue;
100
- }
101
- if (token.name === "run-id") {
102
- options.runId = requireTokenValue(token, parseError).trim();
103
- continue;
104
- }
105
- throw parseError(`Unknown argument: ${token.rawName}`);
106
- }
107
- if (options.repo === undefined || options.pr === undefined) {
108
- throw parseError("detect-stale-runner requires both --repo <owner/name> and --pr <number>");
109
- }
110
- try {
111
- parseRepoSlug(options.repo);
112
- } catch (error) {
113
- throw parseError(error instanceof Error ? error.message : String(error));
114
- }
115
- return options;
116
- }
117
- function resolveRunId(explicitRunId, env) {
118
- if (typeof explicitRunId === "string" && explicitRunId.trim().length > 0) {
119
- return explicitRunId.trim();
120
- }
121
- return resolveEnvRunId(env);
122
- }
123
- function buildStaleRunnerCheck(detection) {
124
- if (detection.status === "no_owner_record") {
125
- return {
126
- ok: true,
127
- failures: [],
128
- };
129
- }
130
- if (detection.status === "exit_signal_recorded") {
131
- return {
132
- ok: false,
133
- failures: [`exit signal recorded for run ${detection.activeRun?.runId ?? "unknown"}: refuse to merge`],
134
- };
135
- }
136
- if (detection.status === "stale_runner") {
137
- return {
138
- ok: false,
139
- failures: [
140
- `stale runner: run ${detection.staleRunner.runId} claimed ${detection.staleRunner.claimedAgeMs}ms ago, last updated ${detection.staleRunner.updatedAgeMs}ms ago (max age ${detection.staleRunner.maxAgeMs}ms)`,
141
- ],
142
- };
143
- }
144
- return { ok: true, failures: [] };
145
- }
146
- export async function runDetectStaleRunner(options, { env = process.env, cwd = process.cwd() } = {}) {
147
- const detection = await detectStaleRunner({
148
- repo: options.repo,
149
- pr: options.pr,
150
- maxAgeMs: options.staleRunnerMaxAgeMs,
151
- cwd,
152
- });
153
- const explicitRunId = resolveRunId(options.runId, env);
154
- const ownershipLost = explicitRunId !== null
155
- && detection.activeRun !== null
156
- && detection.activeRun.runId !== explicitRunId;
157
- const ownershipMissing = explicitRunId !== null && detection.activeRun === null;
158
- const staleRunnerCheck = buildStaleRunnerCheck(detection);
159
- if (ownershipMissing) {
160
- return {
161
- ok: false,
162
- error: "ownership_lost",
163
- repo: options.repo.trim().toLowerCase(),
164
- pr: options.pr,
165
- status: "ownership_lost",
166
- activeRun: null,
167
- runId: explicitRunId,
168
- exitSignals: [],
169
- filePath: detection.filePath,
170
- maxAgeMs: detection.maxAgeMs,
171
- message: `Stale-runner check: run ${explicitRunId} is no longer the active owner of ${options.repo}#${options.pr}; no active owner record exists.`,
172
- staleRunnerCheck: {
173
- ok: false,
174
- failures: [`ownership_lost: no active owner record exists; run ${explicitRunId} is not the owner`],
175
- },
176
- };
177
- }
178
- if (ownershipLost) {
179
- return {
180
- ok: false,
181
- error: "ownership_lost",
182
- repo: options.repo.trim().toLowerCase(),
183
- pr: options.pr,
184
- status: "ownership_lost",
185
- activeRun: detection.activeRun,
186
- runId: explicitRunId,
187
- exitSignals: detection.exitSignal?.signals ?? [],
188
- filePath: detection.filePath,
189
- maxAgeMs: detection.maxAgeMs,
190
- message: `Stale-runner check: run ${explicitRunId} is no longer the active owner of ${options.repo}#${options.pr}; current owner is ${detection.activeRun?.runId}.`,
191
- staleRunnerCheck: {
192
- ok: false,
193
- failures: [`ownership_lost: active owner is ${detection.activeRun?.runId ?? "unknown"}, not ${explicitRunId}`],
194
- },
195
- };
196
- }
197
- if (detection.status === "exit_signal_recorded") {
198
- return {
199
- ok: false,
200
- error: STALE_RUNNER_ERROR.EXIT_SIGNAL_RECORDED,
201
- repo: options.repo.trim().toLowerCase(),
202
- pr: options.pr,
203
- status: "exit_signal_recorded",
204
- activeRun: detection.activeRun,
205
- runId: explicitRunId,
206
- exitSignals: detection.exitSignal?.signals ?? [],
207
- filePath: detection.filePath,
208
- maxAgeMs: detection.maxAgeMs,
209
- message: detection.message,
210
- staleRunnerCheck,
211
- };
212
- }
213
- if (detection.status === "stale_runner") {
214
- return {
215
- ok: false,
216
- error: STALE_RUNNER_ERROR.STALE_RUNNER,
217
- repo: options.repo.trim().toLowerCase(),
218
- pr: options.pr,
219
- status: "stale_runner",
220
- activeRun: detection.activeRun,
221
- runId: explicitRunId,
222
- exitSignals: [],
223
- staleRunner: detection.staleRunner,
224
- filePath: detection.filePath,
225
- maxAgeMs: detection.maxAgeMs,
226
- message: detection.message,
227
- staleRunnerCheck,
228
- };
229
- }
230
- return {
231
- ok: true,
232
- repo: options.repo.trim().toLowerCase(),
233
- pr: options.pr,
234
- status: detection.status,
235
- activeRun: detection.activeRun,
236
- runId: explicitRunId,
237
- exitSignals: [],
238
- filePath: detection.filePath,
239
- maxAgeMs: detection.maxAgeMs,
240
- staleRunnerCheck,
241
- };
242
- }
243
- async function main() {
244
- try {
245
- const options = parseCliArgs(process.argv.slice(2));
246
- if (options.help) {
247
- console.log(USAGE);
248
- return;
249
- }
250
- const result = await runDetectStaleRunner(options, { env: process.env });
251
- if (!result.ok) {
252
- console.error(JSON.stringify(result));
253
- process.exitCode = 1;
254
- return;
255
- }
256
- console.log(JSON.stringify(result));
257
- } catch (error) {
258
- const payload = formatCliError(error, { usage: USAGE });
259
- console.error(JSON.stringify(payload));
260
- process.exitCode = 1;
261
- }
262
- }
263
- if (isDirectCliRun(import.meta.url)) {
264
- await main();
265
- }