dev-loops 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) 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/skills/dev-loop/SKILL.md +16 -6
  5. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  6. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  7. package/.claude/skills/docs/local-planning-flow.md +63 -0
  8. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  9. package/.claude/skills/docs/merge-preconditions.md +35 -0
  10. package/.claude/skills/docs/plan-file-contract.md +37 -0
  11. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  12. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  13. package/CHANGELOG.md +43 -0
  14. package/README.md +1 -0
  15. package/agents/refiner.agent.md +1 -0
  16. package/package.json +5 -3
  17. package/scripts/github/capture-review-threads.mjs +20 -2
  18. package/scripts/github/probe-copilot-review.mjs +69 -3
  19. package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
  20. package/scripts/lib/jq-output.mjs +297 -0
  21. package/scripts/loop/check-retro-tooling.mjs +246 -0
  22. package/scripts/loop/copilot-pr-handoff.mjs +21 -3
  23. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
  24. package/scripts/loop/docs-grill-contract.mjs +70 -0
  25. package/scripts/loop/info.mjs +21 -2
  26. package/scripts/loop/pr-runner-coordination.mjs +20 -4
  27. package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
  28. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  29. package/scripts/loop/run-watch-cycle.mjs +77 -3
  30. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  31. package/scripts/pages/build-site.mjs +136 -0
  32. package/scripts/projects/add-queue-item.mjs +12 -2
  33. package/scripts/projects/list-queue-items.mjs +12 -2
  34. package/scripts/projects/move-queue-item.mjs +12 -2
  35. package/scripts/refine/_refine-helpers.mjs +20 -0
  36. package/scripts/refine/exit-spike.mjs +186 -0
  37. package/scripts/refine/promote-plan.mjs +387 -0
  38. package/scripts/refine/refine-plan-file.mjs +165 -0
  39. package/scripts/refine/validate-plan-file.mjs +64 -0
  40. package/scripts/refine/validate-spike-file.mjs +87 -0
  41. package/skills/dev-loop/SKILL.md +11 -1
  42. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  43. package/skills/docs/artifact-authority-contract.md +86 -31
  44. package/skills/docs/local-planning-flow.md +63 -0
  45. package/skills/docs/local-planning-worked-example.md +139 -0
  46. package/skills/docs/merge-preconditions.md +35 -0
  47. package/skills/docs/plan-file-contract.md +37 -0
  48. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  49. package/skills/docs/spike-mode-contract.md +237 -0
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-retro-tooling
4
+ *
5
+ * Deterministic, dependency-free verifier for the internal-tooling-only rule
6
+ * (issue #982). Given a transcript of the shell commands the agent ran during a
7
+ * dev-loop run, it detects AGENT-LEVEL raw escape-hatch calls that should have
8
+ * gone through internal dev-loops tooling:
9
+ * - `gh ...` (incl. `gh api`, `gh ... --jq`)
10
+ * - `python` / `python3`
11
+ * - `node -e` / `node --eval`
12
+ *
13
+ * These three are the same breach: reaching past the dev-loops tooling to read
14
+ * or parse tool output with a raw call. The verifier returns the list of
15
+ * violations so the retrospective author can record them in
16
+ * `behavioralReview.rawCallViolations` and set `behavioralReview.internalToolingOnly`.
17
+ *
18
+ * Input source (how the harness feeds it):
19
+ * Newline-delimited shell commands — one top-level command per line — as the
20
+ * agent actually invoked them. Pass via a file (`--transcript <path>`) or pipe
21
+ * on stdin. Each line is one Bash-tool invocation's command string.
22
+ *
23
+ * ALLOWED (NOT a violation):
24
+ * - dev-loops subcommands and `node scripts/....mjs` invocations. Those scripts
25
+ * legitimately call gh/GraphQL/etc. internally — that IS the tooling.
26
+ * - A small explicit allowlist of write-ops that have no internal wrapper today:
27
+ * `gh pr merge`, `gh pr ready`, `gh issue create`, `gh issue edit`. These are
28
+ * recorded as `allowedWriteOps` rather than violations so the gate is not
29
+ * blocked forever on an unavoidable gap. Document/close the gap with a wrapper
30
+ * to remove them from the allowlist.
31
+ *
32
+ * VIOLATION (agent-level raw call):
33
+ * - `gh ...` at the start of a command segment (start of line, or after
34
+ * `&&`, `||`, `|`, `;`) that is not in the write-op allowlist.
35
+ * - `python` / `python3` at the start of a command segment.
36
+ * - `node -e` / `node --eval` (inline eval) at the start of a command segment.
37
+ *
38
+ * Head normalization (before classifying a segment, fail-closed):
39
+ * - strips leading `NAME=value ` env-assignment prefixes (`GH_TOKEN=x gh api`)
40
+ * - strips a leading wrapper binary from {sudo, env, xargs, time, nice, command}
41
+ * and re-classifies the remainder (`sudo gh api`, `xargs gh api`, `env gh api`)
42
+ * - reduces a path-prefixed binary to its basename (`./node_modules/.bin/gh`,
43
+ * `/usr/bin/python3`) so the real tool is matched.
44
+ *
45
+ * Known limitations (honest):
46
+ * - Segment splitting is a simple top-level split on `&&`, `||`, `|`, `;` and
47
+ * does NOT fully parse shell quoting/substitution. A `;`/`|` inside a quoted
48
+ * argument can over-report (flags a harmless inner token). It catches the
49
+ * common raw-call forms (incl. the env/wrapper/path-prefixed ones above), but
50
+ * deeply obfuscated calls — command substitution `$(...)`, aliases, `eval` —
51
+ * may evade it. Prefer single-line, single-purpose commands in transcripts.
52
+ */
53
+ import { readFileSync } from "node:fs";
54
+ import process from "node:process";
55
+ import { parseArgs } from "node:util";
56
+ import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
57
+
58
+ const USAGE = `Usage: node scripts/loop/check-retro-tooling.mjs [--transcript <path>] [--json]
59
+
60
+ Reads a newline-delimited transcript of agent shell commands (from --transcript
61
+ or stdin) and reports agent-level raw gh/python/node -e calls (internal-tooling-only
62
+ rule, issue #982).
63
+
64
+ Options:
65
+ --transcript <path> File of newline-delimited commands (default: read stdin)
66
+ --json Emit machine-readable JSON (default: human summary)
67
+
68
+ Exit codes:
69
+ 0 No violations
70
+ 1 One or more violations found
71
+ 2 Argument/runtime error`;
72
+
73
+ /**
74
+ * Write-ops that currently have no internal dev-loops wrapper. Recorded
75
+ * distinctly so the gate is not blocked forever on an unavoidable gap.
76
+ * Keep this set SMALL and explicit; remove an entry once a wrapper exists.
77
+ * @type {ReadonlyArray<RegExp>}
78
+ */
79
+ const ALLOWED_WRITE_OPS = Object.freeze([
80
+ /^gh\s+pr\s+merge\b/,
81
+ /^gh\s+pr\s+ready\b/,
82
+ /^gh\s+issue\s+create\b/,
83
+ /^gh\s+issue\s+edit\b/,
84
+ ]);
85
+
86
+ /** Split a command line into top-level segments on &&, ||, |, ;. */
87
+ function splitSegments(line) {
88
+ return line
89
+ .split(/&&|\|\||\||;/g)
90
+ .map((s) => s.trim())
91
+ .filter((s) => s.length > 0);
92
+ }
93
+
94
+ /** Wrapper binaries whose first argument is the real command to classify. */
95
+ const WRAPPER_BINARIES = new Set(["sudo", "env", "xargs", "time", "nice", "command"]);
96
+
97
+ /**
98
+ * Strip prefixes that hide the real command head, fail-closed (over-report is
99
+ * fine, under-report is the bug):
100
+ * - leading `NAME=value ` env-assignment tokens (any number)
101
+ * - leading wrapper binaries (`sudo`, `env`, `xargs`, ...) — recurse on the rest
102
+ * - a path-prefixed binary (`./x/gh`, `/usr/bin/python3`) → reduce head to its basename
103
+ * Returns the segment with a bare, classifiable command head.
104
+ */
105
+ function normalizeSegmentHead(segment) {
106
+ let s = segment.trim();
107
+ // (a) strip leading env-assignment tokens: NAME=value. The value may be
108
+ // unquoted (`X=1`) or a single/double-quoted string that itself contains
109
+ // spaces (`X="a b"`); match the quoted form first so the space inside the
110
+ // quotes is not mistaken for the token separator (would under-report).
111
+ const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+(?=\S)/;
112
+ while (ENV_ASSIGN.test(s)) {
113
+ s = s.replace(ENV_ASSIGN, "");
114
+ }
115
+ // (c) reduce a path-prefixed head to its basename so `.../gh` → `gh`
116
+ s = s.replace(/^(\S*\/)([^/\s]+)/, "$2");
117
+ // (b) strip a leading wrapper binary, then re-normalize the remainder
118
+ const head = s.split(/\s+/, 1)[0];
119
+ if (WRAPPER_BINARIES.has(head)) {
120
+ const rest = s.slice(head.length).trim();
121
+ if (rest.length > 0) return normalizeSegmentHead(rest);
122
+ }
123
+ return s;
124
+ }
125
+
126
+ /**
127
+ * Classify a single command segment.
128
+ * @returns {{ kind: "violation"|"allowedWriteOp"|"clean", tool?: string }}
129
+ */
130
+ function classifySegment(rawSegment) {
131
+ const segment = normalizeSegmentHead(rawSegment);
132
+ // `node scripts/....mjs` (or any script path) is allowed tooling; only inline
133
+ // eval forms are violations. Check node first so script invocations pass.
134
+ if (/^node\b/.test(segment)) {
135
+ // Inline eval (`-e`/`--eval`) is a violation only before the script path:
136
+ // once a non-flag token (the script) appears, a later `--eval` is just a
137
+ // script argument, not Node's inline-eval mode (avoids false positives).
138
+ const tokens = segment.split(/\s+/).slice(1);
139
+ for (const tok of tokens) {
140
+ if (tok === "-e" || tok === "--eval" || /^--eval=/.test(tok)) {
141
+ return { kind: "violation", tool: "node -e" };
142
+ }
143
+ if (!tok.startsWith("-")) break; // script path reached
144
+ }
145
+ return { kind: "clean" };
146
+ }
147
+ if (/^gh\b/.test(segment)) {
148
+ if (ALLOWED_WRITE_OPS.some((re) => re.test(segment))) {
149
+ return { kind: "allowedWriteOp", tool: "gh" };
150
+ }
151
+ return { kind: "violation", tool: "gh" };
152
+ }
153
+ if (/^python3?\b/.test(segment)) {
154
+ return { kind: "violation", tool: /^python3\b/.test(segment) ? "python3" : "python" };
155
+ }
156
+ return { kind: "clean" };
157
+ }
158
+
159
+ /**
160
+ * Analyze a transcript of newline-delimited shell commands.
161
+ *
162
+ * @param {string} transcript
163
+ * @returns {{ violations: string[], allowedWriteOps: string[], internalToolingOnly: boolean }}
164
+ */
165
+ export function analyzeTranscript(transcript) {
166
+ const violations = [];
167
+ const allowedWriteOps = [];
168
+ const lines = String(transcript ?? "").split(/\r?\n/);
169
+ for (const raw of lines) {
170
+ const line = raw.trim();
171
+ if (line.length === 0 || line.startsWith("#")) continue;
172
+ for (const segment of splitSegments(line)) {
173
+ const result = classifySegment(segment);
174
+ if (result.kind === "violation") {
175
+ violations.push(`${result.tool}: ${segment}`);
176
+ } else if (result.kind === "allowedWriteOp") {
177
+ allowedWriteOps.push(segment);
178
+ }
179
+ }
180
+ }
181
+ return { violations, allowedWriteOps, internalToolingOnly: violations.length === 0 };
182
+ }
183
+
184
+ function parseCliArgs(argv) {
185
+ let values;
186
+ try {
187
+ ({ values } = parseArgs({
188
+ args: argv,
189
+ options: {
190
+ transcript: { type: "string" },
191
+ json: { type: "boolean" },
192
+ help: { type: "boolean", short: "h" },
193
+ },
194
+ strict: true,
195
+ allowPositionals: false,
196
+ }));
197
+ } catch (err) {
198
+ throw Object.assign(new Error(err instanceof Error ? err.message : String(err)), { usage: USAGE });
199
+ }
200
+ return values;
201
+ }
202
+
203
+ function readStdin() {
204
+ try {
205
+ return readFileSync(0, "utf8");
206
+ } catch {
207
+ return "";
208
+ }
209
+ }
210
+
211
+ async function run(argv, { stdout, stderr }) {
212
+ const values = parseCliArgs(argv);
213
+ if (values.help) {
214
+ stdout.write(`${USAGE}\n`);
215
+ return 0;
216
+ }
217
+ const transcript = values.transcript
218
+ ? readFileSync(values.transcript, "utf8")
219
+ : readStdin();
220
+
221
+ const { violations, allowedWriteOps, internalToolingOnly } = analyzeTranscript(transcript);
222
+
223
+ if (values.json) {
224
+ stdout.write(`${JSON.stringify({ ok: internalToolingOnly, internalToolingOnly, rawCallViolations: violations, allowedWriteOps })}\n`);
225
+ } else if (internalToolingOnly) {
226
+ stdout.write(`internalToolingOnly: true — no agent-level raw gh/python/node -e calls found.\n`);
227
+ if (allowedWriteOps.length > 0) {
228
+ stdout.write(`Allowed write-ops (no wrapper yet): ${allowedWriteOps.length}\n`);
229
+ }
230
+ } else {
231
+ stderr.write(`internalToolingOnly: false — ${violations.length} raw-call violation(s):\n`);
232
+ for (const v of violations) stderr.write(` - ${v}\n`);
233
+ }
234
+ return internalToolingOnly ? 0 : 1;
235
+ }
236
+
237
+ if (isDirectCliRun(import.meta.url)) {
238
+ run(process.argv.slice(2), { stdout: process.stdout, stderr: process.stderr }).then(
239
+ (code) => { process.exitCode = typeof code === "number" ? code : 0; },
240
+ (error) => {
241
+ const usage = error instanceof Error && typeof error.usage === "string" ? `\n${error.usage}` : "";
242
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}${usage}\n`);
243
+ process.exitCode = 2;
244
+ },
245
+ );
246
+ }
@@ -17,6 +17,7 @@ import {
17
17
  enforceExternalHealthyWaitTimeout,
18
18
  } from "@dev-loops/core/loop/timeout-policy";
19
19
  import { parseArgs } from "node:util";
20
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
20
21
  import {
21
22
  DEFAULT_POLL_INTERVAL_MS,
22
23
  COPILOT_REVIEW_WAIT_TIMEOUT_MS,
@@ -69,9 +70,11 @@ Error output (stderr, JSON):
69
70
  { "ok": false, "error": "...", "usage": "..." }
70
71
  gh/runtime failures:
71
72
  { "ok": false, "error": "..." }
73
+ ${JQ_OUTPUT_USAGE}
72
74
  Exit codes:
73
75
  0 Success
74
- 1 Argument error or gh failure`.trim();
76
+ 1 Argument error or gh failure
77
+ 2 Invalid --jq filter`.trim();
75
78
  const WATCH_STATES = new Set([
76
79
  STATE.WAITING_FOR_COPILOT_REVIEW,
77
80
  ]);
@@ -134,6 +137,8 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
134
137
  repo: undefined,
135
138
  pr: undefined,
136
139
  watchStatus: undefined,
140
+ jq: undefined,
141
+ silent: false,
137
142
  };
138
143
  const { tokens } = parseArgs({
139
144
  args: [...argv],
@@ -142,6 +147,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
142
147
  repo: { type: "string" },
143
148
  pr: { type: "string" },
144
149
  "watch-status": { type: "string" },
150
+ ...JQ_OUTPUT_PARSE_OPTIONS,
145
151
  },
146
152
  allowPositionals: true,
147
153
  strict: false,
@@ -177,6 +183,14 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
177
183
  options.watchStatus = watchStatus;
178
184
  continue;
179
185
  }
186
+ if (token.name === "jq") {
187
+ options.jq = requireTokenValue(token, parseError);
188
+ continue;
189
+ }
190
+ if (token.name === "silent") {
191
+ options.silent = true;
192
+ continue;
193
+ }
180
194
  throw parseError(`Unknown argument: ${token.rawName}`);
181
195
  }
182
196
  if (options.pr === undefined) {
@@ -519,10 +533,14 @@ export async function runCli(
519
533
  return;
520
534
  }
521
535
  const result = await runHandoff(options, { env, ghCommand });
522
- stdout.write(`${JSON.stringify(result)}\n`);
536
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout });
523
537
  }
524
538
  if (isDirectCliRun(import.meta.url)) {
525
- runCli().catch((error) => {
539
+ runCli().then((code) => {
540
+ if (typeof code === "number") {
541
+ process.exitCode = code;
542
+ }
543
+ }).catch((error) => {
526
544
  process.stderr.write(`${formatCliError(error)}\n`);
527
545
  process.exitCode = 1;
528
546
  });
@@ -163,7 +163,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
163
163
  async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
164
164
  const result = await runChild(
165
165
  ghCommand,
166
- ["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup"],
166
+ ["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeable,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup"],
167
167
  env,
168
168
  );
169
169
  if (result.code !== 0) {
@@ -172,6 +172,32 @@ async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh"
172
172
  }
173
173
  return parseJsonText(result.stdout, { label: "gh pr view" });
174
174
  }
175
+
176
+ // GitHub computes `mergeable` asynchronously, so a freshly-pushed head briefly
177
+ // reads `UNKNOWN`. After the initial fetch, re-poll up to `maxPolls` more times
178
+ // while the value stays UNKNOWN (so at most 1 + maxPolls total fetches) before
179
+ // deciding; never treat a transient UNKNOWN as a pass — the caller fails closed
180
+ // to recheck if it never settles. (issue #980)
181
+ export async function fetchPrFactsWithSettledMergeable(
182
+ options,
183
+ {
184
+ env = process.env,
185
+ ghCommand = "gh",
186
+ maxPolls = 3,
187
+ pollDelayMs = 1500,
188
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
189
+ fetch = fetchPrFacts,
190
+ } = {},
191
+ ) {
192
+ let prData = await fetch(options, { env, ghCommand });
193
+ let polls = 0;
194
+ while (String(prData?.mergeable || "").toUpperCase() === "UNKNOWN" && polls < maxPolls) {
195
+ polls += 1;
196
+ await sleep(pollDelayMs);
197
+ prData = await fetch(options, { env, ghCommand });
198
+ }
199
+ return prData;
200
+ }
175
201
  export function resolveLinkedIssueFromPr(prData) {
176
202
  if (!prData || typeof prData !== "object") return null;
177
203
  const closing = Array.isArray(prData.closingIssuesReferences) ? prData.closingIssuesReferences : [];
@@ -290,7 +316,7 @@ async function loadRetrospectiveCheckpoint(repoRoot) {
290
316
  }
291
317
  }
292
318
  export async function loadPrGateCoordinationContext(options, runtime = {}) {
293
- const prData = await fetchPrFacts(options, runtime);
319
+ const prData = await fetchPrFactsWithSettledMergeable(options, runtime);
294
320
  const currentHeadSha = typeof prData?.headRefOid === "string" && prData.headRefOid.trim().length > 0
295
321
  ? prData.headRefOid.trim()
296
322
  : null;
@@ -349,6 +375,9 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
349
375
  const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
350
376
  ? prData.mergeStateStatus.trim().toUpperCase()
351
377
  : null;
378
+ const mergeable = typeof prData?.mergeable === "string" && prData.mergeable.trim().length > 0
379
+ ? prData.mergeable.trim().toUpperCase()
380
+ : null;
352
381
  const isDraft = Boolean(prData?.isDraft);
353
382
  const isClosed = String(prData?.state || "").toUpperCase() === "CLOSED";
354
383
  const isMerged = String(prData?.state || "").toUpperCase() === "MERGED";
@@ -361,6 +390,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
361
390
  pr: options.pr,
362
391
  currentHeadSha,
363
392
  mergeStateStatus,
393
+ mergeable,
364
394
  conflictFiles,
365
395
  prData,
366
396
  snapshot,
@@ -396,6 +426,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
396
426
  const draftGateConfig = resolveGateConfig(config, "draft");
397
427
  const maxCopilotRounds = resolveRefinementConfig(config, "maxCopilotRounds");
398
428
  const requireRetrospectiveGate = resolveWorkflowConfig(config, "requireRetrospectiveGate");
429
+ const requireRetrospectiveInternalTooling = resolveWorkflowConfig(config, "requireRetrospectiveInternalTooling");
399
430
  const retrospectiveCheckpoint = await loadRetrospectiveCheckpoint(repoRoot);
400
431
  const result = evaluatePrGateCoordination({
401
432
  repo: context.repo,
@@ -406,6 +437,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
406
437
  prMerged: String(context.prData?.state || "").toUpperCase() === "MERGED",
407
438
  prTitle: context.prData?.title,
408
439
  mergeStateStatus: context.mergeStateStatus,
440
+ mergeable: context.mergeable,
409
441
  conflictFiles: context.conflictFiles,
410
442
  lifecycleState: context.interpretation.state,
411
443
  loopDisposition: context.disposition.loopDisposition,
@@ -415,6 +447,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
415
447
  sameHeadCleanConverged: context.interpretation.sameHeadCleanConverged,
416
448
  draftGateRequireCi: draftGateConfig.requireCi,
417
449
  requireRetrospectiveGate,
450
+ requireRetrospectiveInternalTooling,
418
451
  retrospectiveCheckpoint,
419
452
  draftGate: context.gateEvidence.draftGate,
420
453
  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));