dev-loops 0.1.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 (156) hide show
  1. package/.pi/dev-loop/defaults.yaml +477 -0
  2. package/AGENTS.md +25 -0
  3. package/CHANGELOG.md +18 -0
  4. package/LICENSE +21 -0
  5. package/README.md +178 -0
  6. package/agents/dev-loop.agent.md +82 -0
  7. package/agents/developer.agent.md +37 -0
  8. package/agents/docs.agent.md +33 -0
  9. package/agents/fixer.agent.md +53 -0
  10. package/agents/quality.agent.md +28 -0
  11. package/agents/refiner.agent.md +87 -0
  12. package/agents/review.agent.md +64 -0
  13. package/cli/index.mjs +424 -0
  14. package/extension/README.md +233 -0
  15. package/extension/checks.ts +94 -0
  16. package/extension/index.ts +131 -0
  17. package/extension/post-merge-update.ts +512 -0
  18. package/extension/presentation.ts +107 -0
  19. package/lib/dev-loops-core.mjs +284 -0
  20. package/package.json +103 -0
  21. package/scripts/README.md +1007 -0
  22. package/scripts/_cli-primitives.mjs +10 -0
  23. package/scripts/_core-helpers.mjs +30 -0
  24. package/scripts/docs/validate-links.mjs +567 -0
  25. package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
  26. package/scripts/github/_review-thread-mutations.mjs +214 -0
  27. package/scripts/github/capture-review-threads.mjs +180 -0
  28. package/scripts/github/create-draft-pr.mjs +108 -0
  29. package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
  30. package/scripts/github/detect-linked-issue-pr.mjs +331 -0
  31. package/scripts/github/manage-sub-issues.mjs +394 -0
  32. package/scripts/github/probe-copilot-review.mjs +323 -0
  33. package/scripts/github/ready-for-review.mjs +93 -0
  34. package/scripts/github/reconcile-draft-gate.mjs +328 -0
  35. package/scripts/github/reply-resolve-review-thread.mjs +42 -0
  36. package/scripts/github/reply-resolve-review-threads.mjs +329 -0
  37. package/scripts/github/request-copilot-review.mjs +551 -0
  38. package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
  39. package/scripts/github/stage-reviewer-draft.mjs +191 -0
  40. package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
  41. package/scripts/github/verify-fresh-review-context.mjs +125 -0
  42. package/scripts/github/write-gate-findings-log.mjs +212 -0
  43. package/scripts/loop/_checkpoint-io.mjs +55 -0
  44. package/scripts/loop/_checkpoint-paths.mjs +28 -0
  45. package/scripts/loop/_handoff-contract.mjs +230 -0
  46. package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
  47. package/scripts/loop/_loop-evidence.mjs +32 -0
  48. package/scripts/loop/_pr-runner-coordination.mjs +611 -0
  49. package/scripts/loop/_stale-runner-detection.mjs +145 -0
  50. package/scripts/loop/_steering-state-file.mjs +134 -0
  51. package/scripts/loop/build-handoff-envelope.mjs +181 -0
  52. package/scripts/loop/checkpoint-contract.mjs +49 -0
  53. package/scripts/loop/conductor-monitor.mjs +1850 -0
  54. package/scripts/loop/conductor.mjs +214 -0
  55. package/scripts/loop/copilot-pr-handoff.mjs +493 -0
  56. package/scripts/loop/debt-remediate.mjs +304 -0
  57. package/scripts/loop/detect-change-scope.mjs +102 -0
  58. package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
  59. package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
  60. package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
  61. package/scripts/loop/detect-internal-only-pr.mjs +270 -0
  62. package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
  63. package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
  64. package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
  65. package/scripts/loop/detect-stale-runner.mjs +250 -0
  66. package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
  67. package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
  68. package/scripts/loop/info.mjs +267 -0
  69. package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
  70. package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
  71. package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
  72. package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
  73. package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
  74. package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
  75. package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
  76. package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
  77. package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
  78. package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
  79. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
  80. package/scripts/loop/inspect-run-viewer.mjs +82 -0
  81. package/scripts/loop/inspect-run.mjs +382 -0
  82. package/scripts/loop/outer-loop.mjs +419 -0
  83. package/scripts/loop/pr-runner-coordination.mjs +143 -0
  84. package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
  85. package/scripts/loop/pre-flight-gate.mjs +236 -0
  86. package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
  87. package/scripts/loop/pre-push-main-guard.mjs +103 -0
  88. package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
  89. package/scripts/loop/print-gates.mjs +42 -0
  90. package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
  91. package/scripts/loop/run-conductor-cycle.mjs +322 -0
  92. package/scripts/loop/run-queue.mjs +124 -0
  93. package/scripts/loop/run-refinement-audit.mjs +513 -0
  94. package/scripts/loop/run-watch-cycle.mjs +358 -0
  95. package/scripts/loop/steer-loop.mjs +841 -0
  96. package/scripts/loop/ui-designer-review-contract.mjs +76 -0
  97. package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
  98. package/scripts/projects/add-queue-item.mjs +528 -0
  99. package/scripts/projects/ensure-queue-board.mjs +837 -0
  100. package/scripts/projects/list-queue-items.mjs +489 -0
  101. package/scripts/projects/move-queue-item.mjs +549 -0
  102. package/scripts/projects/reorder-queue-item.mjs +518 -0
  103. package/scripts/refine/_refine-helpers.mjs +258 -0
  104. package/scripts/refine/prose-linkage-detector.mjs +92 -0
  105. package/scripts/refine/refinement-completeness-checker.mjs +88 -0
  106. package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
  107. package/scripts/refine/tree-integrity-validator.mjs +211 -0
  108. package/scripts/refine/verify.mjs +178 -0
  109. package/scripts/repo-wiki-local.mjs +156 -0
  110. package/scripts/repo-wiki.mjs +119 -0
  111. package/skills/copilot-pr-followup/SKILL.md +380 -0
  112. package/skills/dev-loop/SKILL.md +141 -0
  113. package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
  114. package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
  115. package/skills/dev-loop/scripts/init-phase.mjs +71 -0
  116. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
  117. package/skills/dev-loop/scripts/phase-files.mjs +29 -0
  118. package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
  119. package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
  120. package/skills/dev-loop/scripts/render-template.mjs +82 -0
  121. package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
  122. package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
  123. package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
  124. package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
  125. package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
  126. package/skills/dev-loop/templates/dev-mode-review.md +17 -0
  127. package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
  128. package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
  129. package/skills/dev-loop/templates/phase-doc.md +27 -0
  130. package/skills/dev-loop/templates/phase-summary.md +13 -0
  131. package/skills/dev-loop/templates/phase-variant.md +15 -0
  132. package/skills/dev-loop/templates/retrospective.md +11 -0
  133. package/skills/dev-loop/templates/review.md +32 -0
  134. package/skills/dev-loop/templates/ui-vision-review.md +55 -0
  135. package/skills/docs/acceptance-criteria-verification.md +21 -0
  136. package/skills/docs/anti-patterns.md +21 -0
  137. package/skills/docs/artifact-authority-contract.md +119 -0
  138. package/skills/docs/confirmation-rules.md +28 -0
  139. package/skills/docs/copilot-ci-status-contract.md +52 -0
  140. package/skills/docs/copilot-loop-operations.md +233 -0
  141. package/skills/docs/debt-remediation-contract.md +107 -0
  142. package/skills/docs/entrypoint-strategies.md +115 -0
  143. package/skills/docs/epic-tree-refinement-procedure.md +234 -0
  144. package/skills/docs/issue-intake-procedure.md +235 -0
  145. package/skills/docs/main-agent-contract.md +72 -0
  146. package/skills/docs/merge-preconditions.md +29 -0
  147. package/skills/docs/pr-lifecycle-contract.md +209 -0
  148. package/skills/docs/public-dev-loop-contract.md +497 -0
  149. package/skills/docs/retrospective-checkpoint-contract.md +159 -0
  150. package/skills/docs/stop-conditions.md +29 -0
  151. package/skills/docs/structural-quality.md +42 -0
  152. package/skills/docs/tracker-first-loop-state.md +281 -0
  153. package/skills/docs/validation-policy.md +27 -0
  154. package/skills/docs/workflow-handoff-contract.md +135 -0
  155. package/skills/final-approval/SKILL.md +19 -0
  156. package/skills/local-implementation/SKILL.md +640 -0
@@ -0,0 +1,732 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import test from "node:test";
7
+
8
+ import {
9
+ buildParseError,
10
+ parsePostGateVerdictFallbackCliArgs,
11
+ renderFallbackGateReviewCommentBody,
12
+ runCli,
13
+ } from "./post-gate-verdict-fallback.mjs";
14
+
15
+ const scriptPath = path.resolve("skills/dev-loop/scripts/post-gate-verdict-fallback.mjs");
16
+
17
+ function runNode(args, env) {
18
+ return new Promise((resolve, reject) => {
19
+ const child = spawn(process.execPath, [scriptPath, ...args], {
20
+ env,
21
+ stdio: ["pipe", "pipe", "pipe"],
22
+ });
23
+ let stdout = "";
24
+ let stderr = "";
25
+ child.stdout.on("data", (chunk) => {
26
+ stdout += String(chunk);
27
+ });
28
+ child.stderr.on("data", (chunk) => {
29
+ stderr += String(chunk);
30
+ });
31
+ child.on("error", reject);
32
+ child.on("close", (code) => {
33
+ resolve({ code, stdout, stderr });
34
+ });
35
+ });
36
+ }
37
+
38
+ function buildGhStubScript() {
39
+ return [
40
+ "#!/usr/bin/env node",
41
+ 'const { readFileSync } = require("node:fs");',
42
+ 'const sequencePath = process.env.GH_SEQUENCE_PATH;',
43
+ 'const counterPath = process.env.GH_COUNTER_PATH;',
44
+ 'const defaultStdout = process.env.GH_DEFAULT_STDOUT ?? "{\\"id\\":1,\\"html_url\\":\\"https://example/comment\\"}\\n";',
45
+ 'const exitCode = Number(process.env.GH_EXIT_CODE ?? 0);',
46
+ 'const entries = sequencePath ? JSON.parse(readFileSync(sequencePath, "utf8")) : [];',
47
+ 'const actual = process.argv.slice(2);',
48
+ 'const current = counterPath ? Number(readFileSync(counterPath, "utf8").trim() || "0") : 0;',
49
+ 'const entry = entries.length === 0 ? null : (entries[Math.min(current, entries.length - 1)] ?? null);',
50
+ 'if (counterPath) require("node:fs").writeFileSync(counterPath, String(current + 1));',
51
+ 'let stdin = "";',
52
+ 'process.stdin.setEncoding("utf8");',
53
+ 'process.stdin.on("data", (chunk) => { stdin += chunk; });',
54
+ 'process.stdin.on("end", () => {',
55
+ ' if (entry && entry.assertArgIncludes) {',
56
+ ' for (const expected of entry.assertArgIncludes) {',
57
+ ' if (!actual.some((a) => String(a).includes(expected))) {',
58
+ ' process.stderr.write(`missing expected arg substring: ${expected}\\nactual: ${actual.join(" ")}\\n`);',
59
+ ' process.exit(94);',
60
+ ' }',
61
+ ' }',
62
+ ' }',
63
+ ' if (entry && entry.assertStdinIncludes) {',
64
+ ' for (const expected of entry.assertStdinIncludes) {',
65
+ ' if (!stdin.includes(expected)) {',
66
+ ' process.stderr.write(`missing expected stdin text: ${expected}\\n`);',
67
+ ' process.exit(96);',
68
+ ' }',
69
+ ' }',
70
+ ' }',
71
+ ' if (entry && entry.stderr) process.stderr.write(entry.stderr);',
72
+ ' if (entry && entry.stdout) {',
73
+ ' process.stdout.write(entry.stdout);',
74
+ ' } else {',
75
+ ' process.stdout.write(defaultStdout);',
76
+ ' }',
77
+ ' process.exit(entry && Number.isInteger(entry.exitCode) ? entry.exitCode : exitCode);',
78
+ '});',
79
+ "",
80
+ ].join("\n");
81
+ }
82
+
83
+ async function writeGhStub(tempDir, entries = [], { defaultStdout, exitCode = 0 } = {}) {
84
+ const sequencePath = path.join(tempDir, "gh-sequence.json");
85
+ const counterPath = path.join(tempDir, "gh-counter.txt");
86
+ const ghPath = path.join(tempDir, "gh");
87
+ await writeFile(sequencePath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
88
+ await writeFile(counterPath, "0\n", "utf8");
89
+ await writeFile(ghPath, buildGhStubScript(), "utf8");
90
+ await chmod(ghPath, 0o755);
91
+ return {
92
+ env: {
93
+ ...process.env,
94
+ PATH: [tempDir, process.env.PATH ?? ""].filter(Boolean).join(path.delimiter),
95
+ GH_SEQUENCE_PATH: sequencePath,
96
+ GH_COUNTER_PATH: counterPath,
97
+ ...(defaultStdout === undefined ? {} : { GH_DEFAULT_STDOUT: defaultStdout }),
98
+ GH_EXIT_CODE: String(exitCode),
99
+ },
100
+ ghPath,
101
+ counterPath,
102
+ };
103
+ }
104
+
105
+ test("renderFallbackGateReviewCommentBody matches the full helper's visible format", () => {
106
+ const body = renderFallbackGateReviewCommentBody({
107
+ gate: "draft_gate",
108
+ headSha: "abc1234",
109
+ verdict: "clean",
110
+ findingsSummary: "no issues found",
111
+ nextAction: "mark ready for review",
112
+ });
113
+ assert.match(body, /### Gate review: `draft_gate`/);
114
+ assert.match(body, /\*\*Reviewed head SHA:\*\* `abc1234`/);
115
+ assert.match(body, /\*\*Verdict:\*\* clean/);
116
+ assert.match(body, /\*\*Findings summary:\*\* no issues found/);
117
+ assert.match(body, /\*\*Next action:\*\* mark ready for review/);
118
+ });
119
+
120
+ test("renderFallbackGateReviewCommentBody includes blocking severities for findings_present", () => {
121
+ const body = renderFallbackGateReviewCommentBody({
122
+ gate: "pre_approval_gate",
123
+ headSha: "deadbeef",
124
+ verdict: "findings_present",
125
+ findingsSummary: "two must-fix items",
126
+ nextAction: "stay draft and fix",
127
+ blockCleanOnFindingSeverities: ["must-fix", "worth-fixing-now"],
128
+ });
129
+ assert.match(body, /\*\*Blocking severities:\*\* must-fix, worth-fixing-now/);
130
+ assert.match(body, /\*\*Next action:\*\* stay draft and fix/);
131
+ });
132
+
133
+ test("renderFallbackGateReviewCommentBody omits blocking severities line when verdict is clean and no blocking list provided", () => {
134
+ const body = renderFallbackGateReviewCommentBody({
135
+ gate: "draft_gate",
136
+ headSha: "abc1234",
137
+ verdict: "clean",
138
+ findingsSummary: "no issues found",
139
+ nextAction: "mark ready for review",
140
+ });
141
+ assert.doesNotMatch(body, /\*\*Blocking severities:\*\*/);
142
+ });
143
+ test("renderFallbackGateReviewCommentBody preserves full template structure when findingsSummary is large", () => {
144
+ const huge = "x".repeat(5000);
145
+ const body = renderFallbackGateReviewCommentBody({
146
+ gate: "draft_gate",
147
+ headSha: "abc1234",
148
+ verdict: "clean",
149
+ findingsSummary: huge,
150
+ nextAction: "mark ready for review",
151
+ });
152
+ // Template structure stays intact so parseGateReviewCommentBody() never loses required fields.
153
+ assert.match(body, /### Gate review: `draft_gate`/);
154
+ assert.match(body, /\*\*Reviewed head SHA:\*\* `abc1234`/);
155
+ assert.match(body, /\*\*Verdict:\*\* clean/);
156
+ assert.match(body, /\*\*Next action:\*\* mark ready for review/);
157
+ // Findings summary is truncated per-field, not the whole body.
158
+ const summaryLine = body.split("\n").find((line) => line.startsWith("**Findings summary:**"));
159
+ assert.ok(summaryLine, "findings summary line present");
160
+ assert.ok(summaryLine.length < huge.length + 200, "summary line is truncated per-field");
161
+ assert.match(summaryLine, /\[truncated \d+ chars\]/);
162
+ });
163
+
164
+ test("renderFallbackGateReviewCommentBody preserves leading content in findingsSummary", () => {
165
+ // Caller-controlled summaries should not have leading whitespace stripped.
166
+ const body = renderFallbackGateReviewCommentBody({
167
+ gate: "draft_gate",
168
+ headSha: "abc1234",
169
+ verdict: "clean",
170
+ findingsSummary: " leading-space summary\n second line",
171
+ nextAction: "mark ready for review",
172
+ });
173
+ assert.match(body, /\*\*Findings summary:\*\* leading-space summary/);
174
+ });
175
+
176
+
177
+ test("parsePostGateVerdictFallbackCliArgs reports a clear error when a flag is followed by another flag", () => {
178
+ const parseError = buildParseError("Usage: ...");
179
+ assert.throws(
180
+ () =>
181
+ parsePostGateVerdictFallbackCliArgs(
182
+ [
183
+ "--repo",
184
+ "--pr",
185
+ "17",
186
+ "--head-sha",
187
+ "abc1234",
188
+ "--verdict",
189
+ "clean",
190
+ "--findings-summary",
191
+ "ok",
192
+ "--next-action",
193
+ "go",
194
+ ],
195
+ { parseError },
196
+ ),
197
+ /--repo requires a non-empty value \(got "--pr"\)/,
198
+ );
199
+ });test("parsePostGateVerdictFallbackCliArgs rejects missing required flags", () => {
200
+ const parseError = buildParseError("Usage: ...");
201
+ assert.throws(
202
+ () => parsePostGateVerdictFallbackCliArgs([], { parseError }),
203
+ /requires --repo, --pr, --head-sha, --verdict, --next-action, and either --findings-summary/,
204
+ );
205
+ });
206
+
207
+ test("parsePostGateVerdictFallbackCliArgs rejects malformed gate", () => {
208
+ const parseError = buildParseError("Usage: ...");
209
+ assert.throws(
210
+ () =>
211
+ parsePostGateVerdictFallbackCliArgs(
212
+ [
213
+ "--repo",
214
+ "owner/repo",
215
+ "--pr",
216
+ "17",
217
+ "--head-sha",
218
+ "abc1234",
219
+ "--verdict",
220
+ "clean",
221
+ "--findings-summary",
222
+ "ok",
223
+ "--next-action",
224
+ "go",
225
+ "--gate",
226
+ "wrong",
227
+ ],
228
+ { parseError },
229
+ ),
230
+ /--gate must be one of: draft_gate, pre_approval_gate/,
231
+ );
232
+ });
233
+
234
+ test("parsePostGateVerdictFallbackCliArgs rejects malformed verdict", () => {
235
+ const parseError = buildParseError("Usage: ...");
236
+ assert.throws(
237
+ () =>
238
+ parsePostGateVerdictFallbackCliArgs(
239
+ [
240
+ "--repo",
241
+ "owner/repo",
242
+ "--pr",
243
+ "17",
244
+ "--head-sha",
245
+ "abc1234",
246
+ "--verdict",
247
+ "maybe",
248
+ "--findings-summary",
249
+ "ok",
250
+ "--next-action",
251
+ "go",
252
+ ],
253
+ { parseError },
254
+ ),
255
+ /--verdict must be one of: clean, findings_present, blocked/,
256
+ );
257
+ });
258
+
259
+ test("parsePostGateVerdictFallbackCliArgs rejects malformed head SHA", () => {
260
+ const parseError = buildParseError("Usage: ...");
261
+ assert.throws(
262
+ () =>
263
+ parsePostGateVerdictFallbackCliArgs(
264
+ [
265
+ "--repo",
266
+ "owner/repo",
267
+ "--pr",
268
+ "17",
269
+ "--head-sha",
270
+ "XYZ",
271
+ "--verdict",
272
+ "clean",
273
+ "--findings-summary",
274
+ "ok",
275
+ "--next-action",
276
+ "go",
277
+ ],
278
+ { parseError },
279
+ ),
280
+ /--head-sha must be a 7-64 character hexadecimal SHA/,
281
+ );
282
+ });
283
+
284
+ test("parsePostGateVerdictFallbackCliArgs rejects malformed repo slug", () => {
285
+ const parseError = buildParseError("Usage: ...");
286
+ assert.throws(
287
+ () =>
288
+ parsePostGateVerdictFallbackCliArgs(
289
+ [
290
+ "--repo",
291
+ "no-slash",
292
+ "--pr",
293
+ "17",
294
+ "--head-sha",
295
+ "abc1234",
296
+ "--verdict",
297
+ "clean",
298
+ "--findings-summary",
299
+ "ok",
300
+ "--next-action",
301
+ "go",
302
+ ],
303
+ { parseError },
304
+ ),
305
+ /--repo must be of the form owner\/name/,
306
+ );
307
+ });
308
+ test("parsePostGateVerdictFallbackCliArgs rejects repo slug with whitespace segments", () => {
309
+ const parseError = buildParseError("Usage: ...");
310
+ assert.throws(
311
+ () =>
312
+ parsePostGateVerdictFallbackCliArgs(
313
+ [
314
+ "--repo",
315
+ "own er/repo",
316
+ "--pr",
317
+ "17",
318
+ "--head-sha",
319
+ "abc1234",
320
+ "--verdict",
321
+ "clean",
322
+ "--findings-summary",
323
+ "ok",
324
+ "--next-action",
325
+ "go",
326
+ ],
327
+ { parseError },
328
+ ),
329
+ /--repo must be of the form owner\/name/,
330
+ );
331
+ });
332
+
333
+ test("parsePostGateVerdictFallbackCliArgs rejects repo slug with dot-dot segments", () => {
334
+ const parseError = buildParseError("Usage: ...");
335
+ assert.throws(
336
+ () =>
337
+ parsePostGateVerdictFallbackCliArgs(
338
+ [
339
+ "--repo",
340
+ "..\//repo",
341
+ "--pr",
342
+ "17",
343
+ "--head-sha",
344
+ "abc1234",
345
+ "--verdict",
346
+ "clean",
347
+ "--findings-summary",
348
+ "ok",
349
+ "--next-action",
350
+ "go",
351
+ ],
352
+ { parseError },
353
+ ),
354
+ /--repo must be of the form owner\/name/,
355
+ );
356
+ });
357
+
358
+ test("parsePostGateVerdictFallbackCliArgs rejects repo slug with more than one slash", () => {
359
+ const parseError = buildParseError("Usage: ...");
360
+ assert.throws(
361
+ () =>
362
+ parsePostGateVerdictFallbackCliArgs(
363
+ [
364
+ "--repo",
365
+ "owner/repo/extra",
366
+ "--pr",
367
+ "17",
368
+ "--head-sha",
369
+ "abc1234",
370
+ "--verdict",
371
+ "clean",
372
+ "--findings-summary",
373
+ "ok",
374
+ "--next-action",
375
+ "go",
376
+ ],
377
+ { parseError },
378
+ ),
379
+ /--repo must be of the form owner\/name/,
380
+ );
381
+ });
382
+
383
+ test("parsePostGateVerdictFallbackCliArgs accepts well-formed arguments", () => {
384
+ const parseError = buildParseError("Usage: ...");
385
+ const parsed = parsePostGateVerdictFallbackCliArgs(
386
+ [
387
+ "--repo",
388
+ "owner/repo",
389
+ "--pr",
390
+ "17",
391
+ "--head-sha",
392
+ "abc1234",
393
+ "--verdict",
394
+ "clean",
395
+ "--findings-summary",
396
+ "ok",
397
+ "--next-action",
398
+ "go",
399
+ ],
400
+ { parseError },
401
+ );
402
+ assert.equal(parsed.repo, "owner/repo");
403
+ assert.equal(parsed.pr, 17);
404
+ assert.equal(parsed.headSha, "abc1234");
405
+ assert.equal(parsed.verdict, "clean");
406
+ assert.equal(parsed.findingsSummary, "ok");
407
+ assert.equal(parsed.nextAction, "go");
408
+ });
409
+
410
+ test("runCli posts via gh and emits a degraded-mode warning", async () => {
411
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dev-loop-gate-fallback-"));
412
+ try {
413
+ const stub = await writeGhStub(
414
+ tempDir,
415
+ [
416
+ {
417
+ assertArgIncludes: ["api", "repos/owner/repo/issues/17/comments"],
418
+ assertStdinIncludes: ["### Gate review: `draft_gate`"],
419
+ stdout: '{"id":101,"html_url":"https://github.com/owner/repo/pull/17#issuecomment-101"}\n',
420
+ },
421
+ ],
422
+ {},
423
+ );
424
+ const stdout = [];
425
+ const stderr = [];
426
+ const exitCode = await runCli(
427
+ [
428
+ "--repo",
429
+ "owner/repo",
430
+ "--pr",
431
+ "17",
432
+ "--head-sha",
433
+ "abc1234",
434
+ "--verdict",
435
+ "clean",
436
+ "--findings-summary",
437
+ "no issues found",
438
+ "--next-action",
439
+ "mark ready for review",
440
+ ],
441
+ {
442
+ env: stub.env,
443
+ spawn: spawn,
444
+ ghCommand: "gh",
445
+ stdoutSink: stdout,
446
+ stderrSink: stderr,
447
+ },
448
+ );
449
+ assert.equal(exitCode, 0);
450
+ const result = JSON.parse(stdout.join(""));
451
+ assert.equal(result.ok, true);
452
+ assert.equal(result.action, "created");
453
+ assert.equal(result.commentId, 101);
454
+ assert.equal(result.commentUrl, "https://github.com/owner/repo/pull/17#issuecomment-101");
455
+ assert.equal(result.gate, "draft_gate");
456
+ assert.equal(result.fallback, true);
457
+ assert.match(stderr.join(""), /fallback mode active/i);
458
+ assert.match(stderr.join(""), /audit trail is degraded/i);
459
+ const counterText = await readFile(stub.counterPath, "utf8");
460
+ assert.equal(counterText.trim(), "1");
461
+ } finally {
462
+ await rm(tempDir, { recursive: true, force: true });
463
+ }
464
+ });
465
+
466
+ test("runCli fails closed (non-zero exit) when gh posting fails", async () => {
467
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dev-loop-gate-fallback-"));
468
+ try {
469
+ const stub = await writeGhStub(
470
+ tempDir,
471
+ [
472
+ {
473
+ assertArgIncludes: ["api", "repos/owner/repo/issues/17/comments"],
474
+ stderr: "gh: POST failed: 403 (Resource not accessible by integration)\n",
475
+ exitCode: 1,
476
+ },
477
+ ],
478
+ {},
479
+ );
480
+ let caught = null;
481
+ try {
482
+ await runCli(
483
+ [
484
+ "--repo",
485
+ "owner/repo",
486
+ "--pr",
487
+ "17",
488
+ "--head-sha",
489
+ "abc1234",
490
+ "--verdict",
491
+ "clean",
492
+ "--findings-summary",
493
+ "no issues found",
494
+ "--next-action",
495
+ "mark ready for review",
496
+ ],
497
+ {
498
+ env: stub.env,
499
+ spawn: spawn,
500
+ ghCommand: "gh",
501
+ stdoutSink: [],
502
+ stderrSink: [],
503
+ },
504
+ );
505
+ } catch (err) {
506
+ caught = err;
507
+ }
508
+ assert.ok(caught instanceof Error, "expected runCli to throw on posting failure");
509
+ assert.match(String(caught.message), /gh api failed to post gate verdict comment/i);
510
+ } finally {
511
+ await rm(tempDir, { recursive: true, force: true });
512
+ }
513
+ });
514
+
515
+ test("runCli reads --findings-file when provided instead of inline summary", async () => {
516
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dev-loop-gate-fallback-"));
517
+ try {
518
+ const findingsPath = path.join(tempDir, "findings.md");
519
+ await writeFile(findingsPath, "no issues found\n", "utf8");
520
+ const stub = await writeGhStub(
521
+ tempDir,
522
+ [
523
+ {
524
+ assertArgIncludes: ["api", "repos/owner/repo/issues/17/comments"],
525
+ assertStdinIncludes: ["**Findings summary:** no issues found"],
526
+ stdout: '{"id":102,"html_url":"https://github.com/owner/repo/pull/17#issuecomment-102"}\n',
527
+ },
528
+ ],
529
+ {},
530
+ );
531
+ const stdout = [];
532
+ await runCli(
533
+ [
534
+ "--repo",
535
+ "owner/repo",
536
+ "--pr",
537
+ "17",
538
+ "--head-sha",
539
+ "abc1234",
540
+ "--verdict",
541
+ "clean",
542
+ "--findings-file",
543
+ findingsPath,
544
+ "--next-action",
545
+ "mark ready for review",
546
+ ],
547
+ {
548
+ env: stub.env,
549
+ spawn: spawn,
550
+ ghCommand: "gh",
551
+ stdoutSink: stdout,
552
+ stderrSink: [],
553
+ },
554
+ );
555
+ const result = JSON.parse(stdout.join(""));
556
+ assert.equal(result.ok, true);
557
+ assert.equal(result.commentId, 102);
558
+ } finally {
559
+ await rm(tempDir, { recursive: true, force: true });
560
+ }
561
+ });
562
+
563
+
564
+ test("runCli preserves internal newlines and leading content from --findings-file", async () => {
565
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dev-loop-gate-fallback-wn-2-"));
566
+ try {
567
+ const findingsPath = path.join(tempDir, "findings.md");
568
+ await writeFile(findingsPath, " first line\n second line\n", "utf8");
569
+ const stub = await writeGhStub(
570
+ tempDir,
571
+ [
572
+ {
573
+ assertArgIncludes: ["api", "repos/owner/repo/issues/17/comments"],
574
+ assertStdinIncludes: ["**Findings summary:** first line\\n second line"],
575
+ stdout: '{"id":104,"html_url":"https://github.com/owner/repo/pull/17#issuecomment-104"}\n',
576
+ },
577
+ ],
578
+ {},
579
+ );
580
+ const stdout = [];
581
+ await runCli(
582
+ [
583
+ "--repo",
584
+ "owner/repo",
585
+ "--pr",
586
+ "17",
587
+ "--head-sha",
588
+ "abc1234",
589
+ "--verdict",
590
+ "clean",
591
+ "--findings-file",
592
+ findingsPath,
593
+ "--next-action",
594
+ "mark ready for review",
595
+ ],
596
+ {
597
+ env: stub.env,
598
+ spawn: spawn,
599
+ ghCommand: "gh",
600
+ stdoutSink: stdout,
601
+ stderrSink: [],
602
+ },
603
+ );
604
+ const result = JSON.parse(stdout.join(""));
605
+ assert.equal(result.ok, true);
606
+ assert.equal(result.commentId, 104);
607
+ } finally {
608
+ await rm(tempDir, { recursive: true, force: true });
609
+ }
610
+ });
611
+
612
+ test("runCli rejects --findings-file that contains only whitespace", async () => {
613
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dev-loop-gate-fallback-wn-1"));
614
+ try {
615
+ const findingsPath = path.join(tempDir, "findings.md");
616
+ await writeFile(findingsPath, " \n\t\n \n", "utf8");
617
+ const stub = await writeGhStub(tempDir, [], {});
618
+ const stdout = [];
619
+ const stderr = [];
620
+ await assert.rejects(
621
+ runCli(
622
+ [
623
+ "--repo",
624
+ "owner/repo",
625
+ "--pr",
626
+ "17",
627
+ "--head-sha",
628
+ "abc1234",
629
+ "--verdict",
630
+ "clean",
631
+ "--findings-file",
632
+ findingsPath,
633
+ "--next-action",
634
+ "mark ready for review",
635
+ ],
636
+ {
637
+ env: stub.env,
638
+ spawn: spawn,
639
+ ghCommand: "gh",
640
+ stdoutSink: stdout,
641
+ stderrSink: stderr,
642
+ },
643
+ ),
644
+ /empty or contains only whitespace/,
645
+ );
646
+ } finally {
647
+ await rm(tempDir, { recursive: true, force: true });
648
+ }
649
+ });
650
+
651
+ test("CLI integration: posts via the real node CLI when gh is stubbed", async () => {
652
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dev-loop-gate-fallback-cli-"));
653
+ try {
654
+ const stub = await writeGhStub(
655
+ tempDir,
656
+ [
657
+ {
658
+ assertArgIncludes: ["api", "repos/owner/repo/issues/17/comments"],
659
+ assertStdinIncludes: ["### Gate review: `pre_approval_gate`"],
660
+ stdout: '{"id":303,"html_url":"https://github.com/owner/repo/pull/17#issuecomment-303"}\n',
661
+ },
662
+ ],
663
+ {},
664
+ );
665
+ const result = await runNode(
666
+ [
667
+ "--repo",
668
+ "owner/repo",
669
+ "--pr",
670
+ "17",
671
+ "--head-sha",
672
+ "abc1234",
673
+ "--verdict",
674
+ "clean",
675
+ "--findings-summary",
676
+ "ok",
677
+ "--next-action",
678
+ "await final human approval",
679
+ "--gate",
680
+ "pre_approval_gate",
681
+ ],
682
+ stub.env,
683
+ );
684
+ assert.equal(result.code, 0);
685
+ const parsed = JSON.parse(result.stdout);
686
+ assert.equal(parsed.ok, true);
687
+ assert.equal(parsed.action, "created");
688
+ assert.equal(parsed.gate, "pre_approval_gate");
689
+ assert.equal(parsed.fallback, true);
690
+ assert.match(result.stderr, /fallback mode active/i);
691
+ } finally {
692
+ await rm(tempDir, { recursive: true, force: true });
693
+ }
694
+ });
695
+
696
+ test("CLI integration: fails closed with non-zero exit when gh posting fails", async () => {
697
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dev-loop-gate-fallback-cli-"));
698
+ try {
699
+ const stub = await writeGhStub(
700
+ tempDir,
701
+ [
702
+ {
703
+ assertArgIncludes: ["api", "repos/owner/repo/issues/17/comments"],
704
+ stderr: "gh: POST failed: 500 (internal)\n",
705
+ exitCode: 1,
706
+ },
707
+ ],
708
+ {},
709
+ );
710
+ const result = await runNode(
711
+ [
712
+ "--repo",
713
+ "owner/repo",
714
+ "--pr",
715
+ "17",
716
+ "--head-sha",
717
+ "abc1234",
718
+ "--verdict",
719
+ "clean",
720
+ "--findings-summary",
721
+ "ok",
722
+ "--next-action",
723
+ "go",
724
+ ],
725
+ stub.env,
726
+ );
727
+ assert.notEqual(result.code, 0);
728
+ assert.match(result.stderr, /gh api failed to post gate verdict comment/i);
729
+ } finally {
730
+ await rm(tempDir, { recursive: true, force: true });
731
+ }
732
+ });