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
@@ -14,7 +14,7 @@ import fs from "node:fs";
14
14
  import path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
 
17
- import { transformAgent, transformSkill, stripPiOnlyBlocks } from "@dev-loops/core/claude/asset-generation";
17
+ import { transformAgent, transformSkill, transformCommand, stripPiOnlyBlocks } from "@dev-loops/core/claude/asset-generation";
18
18
 
19
19
  /**
20
20
  * Collect the generated assets as { target, content } pairs (target is repo-relative).
@@ -45,6 +45,19 @@ export function collectGeneratedAssets({ repoRoot = process.cwd() } = {}) {
45
45
  }
46
46
  }
47
47
 
48
+ // Direct slash commands (#972): thin generated wrappers over the public dev-loop contract,
49
+ // one `.claude/commands/<name>.md` per `commands/<name>.command.md` source. No routing logic.
50
+ const commandsDir = path.join(repoRoot, "commands");
51
+ if (fs.existsSync(commandsDir)) {
52
+ for (const entry of fs.readdirSync(commandsDir).sort()) {
53
+ if (!entry.endsWith(".command.md")) continue;
54
+ const source = `commands/${entry}`;
55
+ const raw = fs.readFileSync(path.join(repoRoot, source), "utf8");
56
+ const base = entry.slice(0, -".command.md".length);
57
+ assets.push({ target: `.claude/commands/${base}.md`, content: transformCommand({ source, raw, version }) });
58
+ }
59
+ }
60
+
48
61
  const skillsDir = path.join(repoRoot, "skills");
49
62
  if (fs.existsSync(skillsDir)) {
50
63
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
@@ -171,6 +184,7 @@ function listFilesRecursive(repoRoot, rel) {
171
184
  function listExistingAssetFiles(repoRoot) {
172
185
  const files = [
173
186
  ...listFilesRecursive(repoRoot, ".claude/agents"),
187
+ ...listFilesRecursive(repoRoot, ".claude/commands"),
174
188
  ...listFilesRecursive(repoRoot, ".claude/skills"),
175
189
  ];
176
190
  // `.claude/hooks/` mixes hand-authored scripts (hooks.json, _hook-io.mjs, the three hook
@@ -11,7 +11,7 @@ import {
11
11
  import { parseArgs } from "node:util";
12
12
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
13
13
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
14
- import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
14
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
15
15
  export const REVIEW_THREADS_QUERY = [
16
16
  "query($owner: String!, $name: String!, $pr: Int!) {",
17
17
  " repository(owner: $owner, name: $name) {",
@@ -103,14 +103,7 @@ export function parseCaptureCliArgs(argv) {
103
103
  options.pr = parsePrNumber(requireTokenValue(token));
104
104
  continue;
105
105
  }
106
- if (token.name === "jq") {
107
- options.jq = requireTokenValue(token);
108
- continue;
109
- }
110
- if (token.name === "silent") {
111
- options.silent = true;
112
- continue;
113
- }
106
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t))) continue;
114
107
  throw new Error(`Unknown argument: ${token.rawName}`);
115
108
  }
116
109
  const hasLiveArgs = options.repo !== undefined || options.pr !== undefined;
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
4
+ import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { parseArgs } from "node:util";
7
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
+
9
+ const USAGE = `Usage: comment-issue.mjs --repo <owner/name> --issue <number> (--body <text> | --body-file <path>)
10
+ Post a comment on a GitHub issue. Thin wrapper over \`gh issue comment\` — use this
11
+ instead of an agent-level raw \`gh issue comment\` so the loop's internal-tooling
12
+ record stays clean (#993; the read-side siblings are list-issues.mjs / fetch-ci-logs.mjs).
13
+ Required:
14
+ --repo <owner/name> Repository slug (e.g. owner/repo)
15
+ --issue <number> Issue number to comment on
16
+ --body <text> Comment body as a single argument
17
+ --body-file <path> Read the comment body from a file (preserves
18
+ newlines; alternative to --body; - reads stdin)
19
+ Output (stdout, JSON):
20
+ { "ok": true, "repo": "owner/repo", "issue": 17, "commentUrl": "https://github.com/owner/repo/issues/17#issuecomment-123" }
21
+ Error output (stderr, JSON):
22
+ { "ok": false, "error": "...", "usage"?: "..." }
23
+ ${JQ_OUTPUT_USAGE}
24
+ Exit codes:
25
+ 0 Success
26
+ 1 Argument error or gh failure
27
+ 2 Invalid --jq filter`.trim();
28
+ const parseError = buildParseError(USAGE);
29
+
30
+ export function parseCommentIssueCliArgs(argv) {
31
+ const { tokens } = parseArgs({
32
+ args: [...argv],
33
+ options: {
34
+ help: { type: "boolean", short: "h" },
35
+ repo: { type: "string" },
36
+ issue: { type: "string" },
37
+ body: { type: "string" },
38
+ "body-file": { type: "string" },
39
+ ...JQ_OUTPUT_PARSE_OPTIONS,
40
+ },
41
+ allowPositionals: true,
42
+ strict: false,
43
+ tokens: true,
44
+ });
45
+ const options = {
46
+ help: false,
47
+ repo: undefined,
48
+ issue: undefined,
49
+ body: undefined,
50
+ bodyFile: undefined,
51
+ jq: undefined,
52
+ silent: false,
53
+ };
54
+ for (const token of tokens) {
55
+ if (token.kind === "positional") {
56
+ throw parseError(`Unknown argument: ${token.value}`);
57
+ }
58
+ if (token.kind !== "option") {
59
+ continue;
60
+ }
61
+ if (token.name === "help") {
62
+ options.help = true;
63
+ return options;
64
+ }
65
+ if (token.name === "repo") {
66
+ options.repo = requireTokenValue(token, parseError).trim();
67
+ continue;
68
+ }
69
+ if (token.name === "issue") {
70
+ options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
71
+ continue;
72
+ }
73
+ if (token.name === "body") {
74
+ options.body = requireTokenValue(token, parseError);
75
+ continue;
76
+ }
77
+ if (token.name === "body-file") {
78
+ const rawPath = requireTokenValue(token, parseError).trim();
79
+ if (rawPath.length === 0) {
80
+ throw parseError("--body-file must be a non-empty path");
81
+ }
82
+ options.bodyFile = rawPath;
83
+ continue;
84
+ }
85
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
86
+ throw parseError(`Unknown argument: ${token.rawName}`);
87
+ }
88
+ if (options.repo === undefined || options.issue === undefined) {
89
+ throw parseError("Commenting on an issue requires both --repo <owner/name> and --issue <number>");
90
+ }
91
+ if (options.body === undefined && options.bodyFile === undefined) {
92
+ throw parseError("Commenting on an issue requires --body <text> or --body-file <path>");
93
+ }
94
+ if (options.body !== undefined && options.bodyFile !== undefined) {
95
+ throw parseError("--body and --body-file are mutually exclusive; pass only one");
96
+ }
97
+ try {
98
+ parseRepoSlug(options.repo);
99
+ } catch (error) {
100
+ throw parseError(error instanceof Error ? error.message : String(error));
101
+ }
102
+ return options;
103
+ }
104
+
105
+ async function resolveBody(options) {
106
+ if (options.bodyFile === undefined) {
107
+ if (options.body.trim().length === 0) {
108
+ throw new Error("--body must not be empty");
109
+ }
110
+ return options.body;
111
+ }
112
+ const source = options.bodyFile === "-" ? 0 : options.bodyFile;
113
+ const body = await readFile(source, "utf8");
114
+ if (body.trim().length === 0) {
115
+ throw new Error(`--body-file ${options.bodyFile} is empty`);
116
+ }
117
+ return body;
118
+ }
119
+
120
+ // Post the comment via `gh issue comment`, then read its URL back from
121
+ // `gh issue comment` output. `gh issue comment` prints the new comment URL on
122
+ // success — capture it so callers don't need a follow-up read.
123
+ export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
124
+ const body = await resolveBody(options);
125
+ const result = await run(
126
+ ghCommand,
127
+ ["issue", "comment", String(options.issue), "--repo", options.repo, "--body", body],
128
+ env,
129
+ );
130
+ if (result.code !== 0) {
131
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
132
+ throw new Error(`gh issue comment failed: ${detail}`);
133
+ }
134
+ // `gh issue comment` prints the created comment's URL (the last non-empty line
135
+ // of stdout). Surface it so the caller has the comment URL without a re-read.
136
+ const commentUrl = result.stdout
137
+ .split(/\r?\n/u)
138
+ .map((line) => line.trim())
139
+ .filter((line) => line.length > 0)
140
+ .pop() ?? null;
141
+ if (commentUrl === null || !/^https?:\/\//u.test(commentUrl)) {
142
+ throw new Error(`gh issue comment did not return a comment URL (got: ${result.stdout.trim() || "<empty>"})`);
143
+ }
144
+ return { ok: true, repo: options.repo, issue: options.issue, commentUrl };
145
+ }
146
+
147
+ export async function runCli(
148
+ argv = process.argv.slice(2),
149
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
150
+ ) {
151
+ let options;
152
+ try {
153
+ options = parseCommentIssueCliArgs(argv);
154
+ } catch (error) {
155
+ stderr.write(`${formatCliError(error)}\n`);
156
+ return 1;
157
+ }
158
+ if (options.help) {
159
+ stdout.write(`${USAGE}\n`);
160
+ return 0;
161
+ }
162
+ let result;
163
+ try {
164
+ result = await commentIssue(options, { env, ghCommand, run });
165
+ } catch (error) {
166
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
167
+ return 1;
168
+ }
169
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
170
+ }
171
+
172
+ if (isDirectCliRun(import.meta.url)) {
173
+ runCli().then((code) => { process.exitCode = code; });
174
+ }
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { parseArgs } from "node:util";
4
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
7
+
8
+ const USAGE = `Usage: create-label.mjs --repo <owner/name> --name <label> [--color <hex>] [--description <text>] [--force]
9
+ Create (or idempotently reuse) a GitHub label. Thin wrapper over \`gh label create\`
10
+ — use this instead of an agent-level raw \`gh label create\` so the loop's
11
+ internal-tooling record stays clean (siblings: comment-issue.mjs, edit-pr.mjs).
12
+ Required:
13
+ --repo <owner/name> Repository slug (e.g. owner/repo)
14
+ --name <label> Label name to create
15
+ Optional:
16
+ --color <hex> Hex color without '#' (default: d73a4a)
17
+ --description <text> Label description
18
+ --force Update the label if it already exists
19
+ Output (stdout, JSON):
20
+ { "ok": true, "created": true, "name": "gate:full", "color": "d73a4a", "repo": "owner/repo" }
21
+ { "ok": true, "created": false, "alreadyExists": true, "name": "gate:full" }
22
+ Error output (stderr, JSON):
23
+ { "ok": false, "error": "...", "usage"?: "..." }
24
+ ${JQ_OUTPUT_USAGE}
25
+ Exit codes:
26
+ 0 Success (created or already exists)
27
+ 1 Argument error or gh failure
28
+ 2 Invalid --jq filter`.trim();
29
+ const parseError = buildParseError(USAGE);
30
+
31
+ export function parseCreateLabelCliArgs(argv) {
32
+ const { values } = parseArgs({
33
+ args: [...argv],
34
+ options: {
35
+ help: { type: "boolean", short: "h" },
36
+ repo: { type: "string" },
37
+ name: { type: "string" },
38
+ color: { type: "string" },
39
+ description: { type: "string" },
40
+ force: { type: "boolean" },
41
+ ...JQ_OUTPUT_PARSE_OPTIONS,
42
+ },
43
+ allowPositionals: false,
44
+ strict: false,
45
+ });
46
+ if (values.help) {
47
+ return { help: true };
48
+ }
49
+ const repo = typeof values.repo === "string" ? values.repo.trim() : undefined;
50
+ const name = typeof values.name === "string" ? values.name.trim() : undefined;
51
+ if (!repo || !name) {
52
+ throw parseError("Creating a label requires both --repo <owner/name> and --name <label>");
53
+ }
54
+ try {
55
+ parseRepoSlug(repo);
56
+ } catch (error) {
57
+ throw parseError(error instanceof Error ? error.message : String(error));
58
+ }
59
+ const color = typeof values.color === "string" && values.color.trim().length > 0 ? values.color.trim() : "d73a4a";
60
+ const description =
61
+ typeof values.description === "string" && values.description.length > 0 ? values.description : undefined;
62
+ return {
63
+ help: false,
64
+ repo,
65
+ name,
66
+ color,
67
+ description,
68
+ force: values.force === true,
69
+ jq: values.jq,
70
+ silent: values.silent === true,
71
+ };
72
+ }
73
+
74
+ // Pure: assemble the `gh label create` argv. Exported so the arg shape can be
75
+ // tested without spawning gh.
76
+ export function buildLabelArgs({ repo, name, color, description, force }) {
77
+ const args = ["label", "create", name, "--repo", repo, "--color", color];
78
+ if (description !== undefined) {
79
+ args.push("--description", description);
80
+ }
81
+ if (force) {
82
+ args.push("--force");
83
+ }
84
+ return args;
85
+ }
86
+
87
+ export function createLabel(options, { ghCommand = "gh", exec = execFileSync } = {}) {
88
+ const args = buildLabelArgs(options);
89
+ try {
90
+ exec(ghCommand, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
91
+ } catch (error) {
92
+ const stderr = typeof error?.stderr === "string" ? error.stderr : String(error?.stderr ?? "");
93
+ // Without --force, gh errors if the label exists. Treat that as idempotent
94
+ // success so re-runs are safe; any other failure propagates.
95
+ if (!options.force && /already exists/i.test(stderr)) {
96
+ return { ok: true, created: false, alreadyExists: true, name: options.name };
97
+ }
98
+ const detail = stderr.trim() || (error instanceof Error ? error.message : String(error));
99
+ throw new Error(`gh label create failed: ${detail}`);
100
+ }
101
+ return { ok: true, created: true, name: options.name, color: options.color, repo: options.repo };
102
+ }
103
+
104
+ export function run(
105
+ argv = process.argv.slice(2),
106
+ { stdout = process.stdout, stderr = process.stderr, ghCommand = "gh", exec = execFileSync } = {},
107
+ ) {
108
+ let options;
109
+ try {
110
+ options = parseCreateLabelCliArgs(argv);
111
+ } catch (error) {
112
+ stderr.write(`${formatCliError(error)}\n`);
113
+ return 1;
114
+ }
115
+ if (options.help) {
116
+ stdout.write(`${USAGE}\n`);
117
+ return 0;
118
+ }
119
+ let result;
120
+ try {
121
+ result = createLabel(options, { ghCommand, exec });
122
+ } catch (error) {
123
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
124
+ return 1;
125
+ }
126
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
127
+ }
128
+
129
+ export const main = run;
130
+
131
+ if (isDirectCliRun(import.meta.url)) {
132
+ process.exitCode = run();
133
+ }
@@ -9,15 +9,19 @@ import {
9
9
  summarizeGateReviewCommentMarkers,
10
10
  summarizeGateReviewComments,
11
11
  } from "../_core-helpers.mjs";
12
- import { access } from "node:fs/promises";
12
+ import { access, readFile } from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
15
15
  import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
16
16
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
- import { loadDevLoopConfig, resolveGateConfig, resolveRequireFanoutEvidence } from "@dev-loops/core/config";
17
+ import { FANOUT_PROVENANCE_MIN_REVIEWERS, GATE_FULL_LABEL, loadDevLoopConfig, resolveGateConfig, resolveLightMode, resolveRequireFanoutEvidence, resolveRequireFanoutProvenance } from "@dev-loops/core/config";
18
+ import { FANOUT_UNAVAILABLE_MESSAGE, provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
19
+ import { detectMergeBaseScope, isEligibleForLightMode } from "../loop/detect-change-scope.mjs";
18
20
  import { buildLogPath } from "./write-gate-findings-log.mjs";
19
21
  import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
20
22
  import { detectStaleRunner } from "../loop/_stale-runner-detection.mjs";
23
+ import { resolveLedgerCheckouts, resolveRepoRoot } from "../loop/_repo-root-resolver.mjs";
24
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
21
25
  const USAGE = `Usage: detect-checkpoint-evidence.mjs --repo <owner/name> --pr <number>
22
26
  Fetch the live PR head SHA and visible PR issue comments, then summarize the
23
27
  latest valid draft-gate and pre-approval checkpoint verdict comments. Always fail
@@ -84,9 +88,11 @@ Output (stdout, JSON; always includes preMergeGateCheck):
84
88
  Error output (stderr, JSON):
85
89
  { "ok": false, "error": "...", "usage": "..." }
86
90
  { "ok": false, "error": "..." }
91
+ ${JQ_OUTPUT_USAGE}
87
92
  Exit codes:
88
93
  0 Success (gate evidence is valid)
89
- 1 Argument error, gh failure, malformed gh JSON, or missing required pre-merge gate evidence.`.trim();
94
+ 1 Argument error, gh failure, malformed gh JSON, or missing required pre-merge gate evidence.
95
+ 2 Invalid --jq filter.`.trim();
90
96
  const parseError = buildParseError(USAGE);
91
97
  export function parseDetectCheckpointEvidenceCliArgs(argv) {
92
98
  const { tokens } = parseArgs({
@@ -96,6 +102,7 @@ export function parseDetectCheckpointEvidenceCliArgs(argv) {
96
102
  repo: { type: "string" },
97
103
  pr: { type: "string" },
98
104
  "require-before-merge": { type: "boolean" },
105
+ ...JQ_OUTPUT_PARSE_OPTIONS,
99
106
  },
100
107
  allowPositionals: true,
101
108
  strict: false,
@@ -125,6 +132,7 @@ export function parseDetectCheckpointEvidenceCliArgs(argv) {
125
132
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
126
133
  continue;
127
134
  }
135
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
128
136
  if (token.name === "require-before-merge") {
129
137
  throw parseError(`--require-before-merge has been removed: gate evidence enforcement is now always-on by default. Omit the flag.`);
130
138
  }
@@ -249,7 +257,24 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
249
257
  // { required: false, gates: [] } so the `.required` guard skips this block.
250
258
  if (fanoutEnforcement && fanoutEnforcement.required) {
251
259
  for (const gate of fanoutEnforcement.gates) {
252
- if (gate.executionMode !== "fanout_fanin") {
260
+ // Light-mode acceptance (#1174): a genuinely under-threshold micro-PR
261
+ // collapses the gate fan-out to a single inline check (#1043). Accept that
262
+ // inline verdict ONLY when ALL hold, fail CLOSED otherwise:
263
+ // - lightMode is enabled in config, AND
264
+ // - the reviewed head's merge-base scope was RE-DERIVED under threshold
265
+ // (scopeUnderThreshold; false whenever scope could not be derived), AND
266
+ // - the PR carries no gate:full label (which always forces fan-out), AND
267
+ // - the verdict records a non-empty inline reason.
268
+ // Any non-light inline verdict (over threshold / label / lightMode off /
269
+ // scope underivable) falls through to the byte-identical rejection below.
270
+ const lightAccepted =
271
+ gate.executionMode === "inline_single_agent"
272
+ && fanoutEnforcement.lightMode === true
273
+ && fanoutEnforcement.hasFullLabel !== true
274
+ && gate.scopeUnderThreshold === true
275
+ && typeof gate.inlineReason === "string"
276
+ && gate.inlineReason.trim().length > 0;
277
+ if (gate.executionMode !== "fanout_fanin" && !lightAccepted) {
253
278
  failures.push(
254
279
  `${gate.name}: requireFanoutEvidence is enabled but executionMode is "${gate.executionMode ?? "unset"}" (expected "fanout_fanin"); inline gate verdicts are not accepted`,
255
280
  );
@@ -259,6 +284,31 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
259
284
  failures.push(
260
285
  `${gate.name}: requireFanoutEvidence is enabled but no findings-log ledger exists for the reviewed head (${gate.ledgerPath})`,
261
286
  );
287
+ continue;
288
+ }
289
+ // Opt-in provenance enforcement (gates.requireFanoutProvenance), layered on
290
+ // top of fan-out evidence. When off (default) requireProvenance is falsy so
291
+ // NO new failure is added — behavior is byte-identical to today. When on, a
292
+ // fanout_fanin ledger must record INTERNALLY-CONSISTENT provenance (checked
293
+ // the same way the write path validates it — a hand-edited or shadow ledger
294
+ // is re-validated here, not trusted) with distinctReviewers >= the floor.
295
+ // Provenance is enforced ONLY for fanout_fanin verdicts (#1174): a
296
+ // light-accepted inline verdict is already scope-bounded and has no
297
+ // multi-reviewer provenance to record, so requiring it would make the
298
+ // light path unmergeable — the inverse of this issue's fix.
299
+ if (fanoutEnforcement.requireProvenance && gate.executionMode === "fanout_fanin") {
300
+ const prov = gate.provenance;
301
+ const consistencyErr = provenanceConsistencyError(prov);
302
+ const reviewers = prov && Number.isInteger(prov.distinctReviewers) ? prov.distinctReviewers : null;
303
+ if (consistencyErr) {
304
+ failures.push(
305
+ `${gate.name}: requireFanoutProvenance is enabled but the findings-log ledger lacks valid fan-out provenance (${consistencyErr}); ${FANOUT_UNAVAILABLE_MESSAGE}`,
306
+ );
307
+ } else if (reviewers === null || reviewers < FANOUT_PROVENANCE_MIN_REVIEWERS) {
308
+ failures.push(
309
+ `${gate.name}: requireFanoutProvenance is enabled but the findings-log ledger lacks valid fan-out provenance (need provenance.distinctReviewers >= ${FANOUT_PROVENANCE_MIN_REVIEWERS}, got ${reviewers === null ? "none" : reviewers}); ${FANOUT_UNAVAILABLE_MESSAGE}`,
310
+ );
311
+ }
262
312
  }
263
313
  }
264
314
  }
@@ -287,24 +337,88 @@ async function ledgerExists(fullPath) {
287
337
  return false;
288
338
  }
289
339
  }
340
+ /**
341
+ * True if the ledger (relative path) exists under ANY enumerated checkout
342
+ * (main + every worktree). Fixes #1050: a ledger written in the PR worktree is
343
+ * found even when the check runs from a different checkout's git-toplevel.
344
+ */
345
+ async function ledgerExistsInAny(checkouts, ledgerPath) {
346
+ for (const root of checkouts) {
347
+ if (await ledgerExists(path.resolve(root, ledgerPath))) {
348
+ return true;
349
+ }
350
+ }
351
+ return false;
352
+ }
353
+ /**
354
+ * Read the recorded fan-out `provenance` object from a ledger across the
355
+ * enumerated checkouts. Mirrors ledgerExistsInAny's "ANY checkout satisfies"
356
+ * semantics: prefers the FIRST checkout whose ledger provenance actually
357
+ * SATISFIES enforcement (internally consistent AND distinctReviewers >= floor),
358
+ * so a below-floor or provenance-less ledger in an earlier-enumerated checkout
359
+ * cannot SHADOW a valid one in the PR worktree (which would falsely fail closed).
360
+ * Falls back to the first non-null provenance (for a useful diagnostic message)
361
+ * only when NO checkout satisfies, and null only when none is present. Only
362
+ * called when requireFanoutProvenance is enabled so the default path pays no I/O.
363
+ */
364
+ async function readLedgerProvenanceInAny(checkouts, ledgerPath) {
365
+ let firstNonNull = null;
366
+ for (const root of checkouts) {
367
+ const full = path.resolve(root, ledgerPath);
368
+ try {
369
+ const parsed = JSON.parse(await readFile(full, "utf8"));
370
+ const prov = parsed && typeof parsed === "object" ? parsed.provenance : null;
371
+ if (prov == null) continue; // ledger present but no provenance — keep scanning.
372
+ if (provenanceConsistencyError(prov) === null && prov.distinctReviewers >= FANOUT_PROVENANCE_MIN_REVIEWERS) {
373
+ return prov; // satisfying ledger — prefer it over any earlier below-floor one.
374
+ }
375
+ if (firstNonNull === null) firstNonNull = prov; // remember for diagnostics.
376
+ } catch {
377
+ // Missing/unreadable/malformed ledger in this checkout — try the next.
378
+ }
379
+ }
380
+ return firstNonNull;
381
+ }
290
382
  /**
291
383
  * Build the fan-out evidence enforcement descriptor.
292
384
  *
293
385
  * Enforcement is ON by default (opt-out via gates.requireFanoutEvidence: false).
294
386
  * Returns { required: false } when enforcement is disabled OR when config is
295
387
  * unavailable (config == null — null or undefined — after a failed load) — config-unavailable must
296
- * fail open and never enable enforcement. When enabled, records per-required-gate
297
- * executionMode and whether the deterministic findings-log ledger exists for the
298
- * reviewed head SHA so the pre-merge check can fail closed on inline verdicts or
299
- * missing ledgers.
388
+ * fail open and never enable enforcement. When enabled, returns
389
+ * { required: true, requireProvenance, lightMode, hasFullLabel, gates } where
390
+ * each per-required-gate entry records executionMode, inlineReason,
391
+ * scopeUnderThreshold, and whether the deterministic findings-log ledger exists
392
+ * for the reviewed head SHA, so the pre-merge check can fail closed on inline
393
+ * verdicts or missing ledgers.
394
+ *
395
+ * Light mode (#1174): when gates.lightMode is configured, `hasFullLabel`
396
+ * (gate:full PR label) and `baseRef` feed a fail-closed merge-base scope
397
+ * re-derivation for inline verdicts. A gate's scopeUnderThreshold is true only
398
+ * when light mode is on, the PR has no gate:full label, a base ref is known,
399
+ * and the reviewed head's merge-base diff is genuinely under threshold — which
400
+ * lets the pre-merge check accept an inline single-agent verdict for a
401
+ * small-scope PR instead of always rejecting inline evidence.
300
402
  */
301
- async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarker, preApprovalGateMarker, config, cwd }) {
403
+ export async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarker, preApprovalGateMarker, config, cwd, hasFullLabel = false, baseRef = null }) {
302
404
  // Fail open when config could not be loaded/validated. `== null` covers both
303
405
  // null and undefined; the loader only ever yields null on failure, but the
304
406
  // loose check defensively treats an absent config as unavailable.
305
407
  if (config == null || !resolveRequireFanoutEvidence(config)) {
408
+ // Disabled/unavailable return is intentionally byte-identical to before
409
+ // (no requireProvenance key): buildPreMergeGateCheck only reads it inside
410
+ // the `required` block, so this preserves the exact existing shape.
306
411
  return { required: false, gates: [] };
307
412
  }
413
+ // Provenance enforcement is opt-in and layered ON TOP of fan-out evidence: it
414
+ // only takes effect while evidence enforcement (above) is active.
415
+ const requireProvenance = resolveRequireFanoutProvenance(config);
416
+ // Light-mode facts (#1174): the threshold that a re-derived merge-base scope
417
+ // must fall under for an inline verdict to be accepted. null when lightMode is
418
+ // disabled → no inline verdict can ever be accepted (scopeUnderThreshold stays
419
+ // false), preserving today's rejection.
420
+ const lightThreshold = resolveLightMode(config);
421
+ const lightMode = lightThreshold != null;
308
422
  const draftRequired = resolveGateConfig(config, "draft").required;
309
423
  const preApprovalRequired = resolveGateConfig(config, "preApproval").required;
310
424
  const gateSpecs = [
@@ -312,18 +426,35 @@ async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarke
312
426
  { name: "pre_approval_gate", marker: preApprovalGateMarker, required: preApprovalRequired },
313
427
  ].filter((spec) => spec.required && spec.marker.visible);
314
428
  const gates = [];
429
+ const checkouts = resolveLedgerCheckouts(cwd);
430
+ // checkouts[0] is always resolveRepoRoot(cwd) (resolveLedgerCheckouts adds it
431
+ // first, unconditionally, and never throws — it falls back to cwd on git
432
+ // failure) — reuse it instead of a second `git rev-parse --show-toplevel`.
433
+ const repoRoot = checkouts[0];
315
434
  for (const spec of gateSpecs) {
316
435
  const headSha = spec.marker.headSha ?? currentHeadSha;
317
436
  const ledgerPath = buildLogPath({ repo, pr, gate: spec.name, headSha, tmpRoot: "tmp" });
318
- const fullPath = path.resolve(cwd, ledgerPath);
437
+ // Re-derive scope FAIL-CLOSED for inline verdicts only (the fan-out default
438
+ // path pays no git I/O). scopeUnderThreshold is true ONLY when lightMode is
439
+ // on, the PR has no gate:full label, a base ref is known, and the merge-base
440
+ // diff for the reviewed head is genuinely under threshold. Any git/scope
441
+ // failure leaves it false, so the inline verdict is rejected exactly as today.
442
+ let scopeUnderThreshold = false;
443
+ if (lightMode && !hasFullLabel && baseRef && spec.marker.executionMode === "inline_single_agent") {
444
+ const scope = detectMergeBaseScope({ base: baseRef, head: headSha, cwd: repoRoot });
445
+ scopeUnderThreshold = scope.ok === true && isEligibleForLightMode(scope, lightThreshold);
446
+ }
319
447
  gates.push({
320
448
  name: spec.name,
321
449
  executionMode: spec.marker.executionMode ?? null,
450
+ inlineReason: spec.marker.inlineReason ?? null,
451
+ scopeUnderThreshold,
322
452
  ledgerPath,
323
- ledgerExists: await ledgerExists(fullPath),
453
+ ledgerExists: await ledgerExistsInAny(checkouts, ledgerPath),
454
+ provenance: requireProvenance ? await readLedgerProvenanceInAny(checkouts, ledgerPath) : null,
324
455
  });
325
456
  }
326
- return { required: true, gates };
457
+ return { required: true, requireProvenance, lightMode, hasFullLabel, gates };
327
458
  }
328
459
  export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", cwd = process.cwd() } = {}) {
329
460
  const runnerOwnership = await ensureAsyncRunnerOwnership({
@@ -378,8 +509,31 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
378
509
  // treat it as config-unavailable and leave fan-out enforcement disabled
379
510
  // (preserves default behavior). Other gate checks remain unaffected.
380
511
  let config = null;
381
- const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: cwd });
512
+ const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: resolveRepoRoot(cwd) });
382
513
  config = Array.isArray(configErrors) && configErrors.length > 0 ? null : loadedConfig;
514
+ // Light-mode pre-merge facts (#1174): the base commit for the merge-base scope
515
+ // re-derivation and whether the PR forces full fan-out via the gate:full label.
516
+ // Fetched LAZILY — only when fan-out enforcement is active AND lightMode is on
517
+ // AND a gate actually recorded an inline verdict — so the common fan-out path
518
+ // (and every existing caller/test) makes NO extra gh call and stays unchanged.
519
+ let baseRef = null;
520
+ let hasFullLabel = false;
521
+ const anyInlineVerdict = [draftGateMarker, preApprovalGateMarker].some(
522
+ (marker) => marker.visible && marker.executionMode === "inline_single_agent",
523
+ );
524
+ if (config != null && resolveRequireFanoutEvidence(config) && resolveLightMode(config) != null && anyInlineVerdict) {
525
+ try {
526
+ const lightFacts = await runGhJson(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "baseRefOid,labels"], { env, ghCommand });
527
+ baseRef = typeof lightFacts?.baseRefOid === "string" && lightFacts.baseRefOid.trim().length > 0
528
+ ? lightFacts.baseRefOid.trim()
529
+ : null;
530
+ hasFullLabel = Array.isArray(lightFacts?.labels)
531
+ && lightFacts.labels.some((label) => (typeof label === "string" ? label : label?.name) === GATE_FULL_LABEL);
532
+ } catch {
533
+ // Fail CLOSED: without the label/base facts we cannot safely accept an
534
+ // inline verdict, so leave baseRef null (scope underivable → rejected).
535
+ }
536
+ }
383
537
  const fanoutEnforcement = await buildFanoutEnforcement({
384
538
  repo: options.repo,
385
539
  pr: options.pr,
@@ -388,6 +542,8 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
388
542
  preApprovalGateMarker,
389
543
  config,
390
544
  cwd,
545
+ hasFullLabel,
546
+ baseRef,
391
547
  });
392
548
  return {
393
549
  ok: true,
@@ -458,7 +614,7 @@ async function main() {
458
614
  process.exitCode = 1;
459
615
  return;
460
616
  }
461
- process.stdout.write(`${JSON.stringify(output)}\n`);
617
+ process.exitCode = emitResult(output, { jq: options.jq, silent: options.silent });
462
618
  } catch (error) {
463
619
  if (error && typeof error === "object" && "staleRunner" in error && error.staleRunner) {
464
620
  const staleRunnerCheck = {