dev-loops 0.6.0 → 0.7.2

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 (203) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +8 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +4 -3
  6. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  7. package/.claude/commands/loop-continue.md +15 -0
  8. package/.claude/commands/loop-enqueue.md +22 -0
  9. package/.claude/commands/loop-grill.md +17 -0
  10. package/.claude/commands/{info.md → loop-info.md} +2 -2
  11. package/.claude/commands/loop-queue-status.md +24 -0
  12. package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
  13. package/.claude/commands/{start.md → loop-start.md} +1 -1
  14. package/.claude/commands/{status.md → loop-status.md} +1 -1
  15. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  16. package/.claude/hooks/_hook-decisions.mjs +138 -15
  17. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  18. package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
  19. package/.claude/skills/dev-loop/SKILL.md +16 -12
  20. package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
  21. package/.claude/skills/docs/anti-patterns.md +5 -3
  22. package/.claude/skills/docs/artifact-authority-contract.md +30 -11
  23. package/.claude/skills/docs/confirmation-rules.md +1 -0
  24. package/.claude/skills/docs/contract-style-guide.md +37 -0
  25. package/.claude/skills/docs/copilot-loop-operations.md +19 -15
  26. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  27. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  28. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  29. package/.claude/skills/docs/issue-intake-procedure.md +26 -19
  30. package/.claude/skills/docs/local-planning-flow.md +1 -1
  31. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  32. package/.claude/skills/docs/merge-preconditions.md +30 -12
  33. package/.claude/skills/docs/plan-file-contract.md +1 -1
  34. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  35. package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
  36. package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
  37. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  38. package/.claude/skills/docs/stop-conditions.md +30 -15
  39. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  40. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  41. package/.claude/skills/docs/validation-policy.md +4 -5
  42. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  43. package/.claude/skills/local-implementation/SKILL.md +62 -145
  44. package/.claude/skills/loop-grill/SKILL.md +163 -0
  45. package/AGENTS.md +1 -0
  46. package/CHANGELOG.md +101 -0
  47. package/README.md +9 -9
  48. package/agents/dev-loop.agent.md +11 -10
  49. package/agents/developer.agent.md +1 -1
  50. package/agents/docs.agent.md +1 -1
  51. package/agents/fixer.agent.md +2 -1
  52. package/agents/quality.agent.md +1 -1
  53. package/agents/refiner.agent.md +3 -3
  54. package/agents/review.agent.md +5 -4
  55. package/cli/index.mjs +35 -42
  56. package/extension/README.md +4 -4
  57. package/extension/checks.ts +1 -5
  58. package/extension/harness-types.ts +1 -0
  59. package/extension/index.ts +6 -0
  60. package/extension/pi-extension-adapter.ts +2 -0
  61. package/extension/presentation.ts +7 -19
  62. package/package.json +12 -12
  63. package/scripts/_core-helpers.mjs +2 -0
  64. package/scripts/claude/headless-dev-loop.mjs +1 -1
  65. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  66. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  67. package/scripts/github/_review-thread-mutations.mjs +5 -2
  68. package/scripts/github/capture-review-threads.mjs +2 -9
  69. package/scripts/github/comment-issue.mjs +2 -9
  70. package/scripts/github/create-label.mjs +133 -0
  71. package/scripts/github/create-pr.mjs +119 -10
  72. package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
  73. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  74. package/scripts/github/edit-pr.mjs +259 -0
  75. package/scripts/github/fetch-ci-logs.mjs +2 -9
  76. package/scripts/github/list-issues.mjs +2 -9
  77. package/scripts/github/list-review-threads.mjs +277 -0
  78. package/scripts/github/manage-sub-issues.mjs +46 -10
  79. package/scripts/github/offer-human-handoff.mjs +8 -5
  80. package/scripts/github/post-gate-findings.mjs +20 -5
  81. package/scripts/github/probe-ci-status.mjs +8 -2
  82. package/scripts/github/probe-copilot-review.mjs +21 -14
  83. package/scripts/github/ready-for-review.mjs +26 -8
  84. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  85. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  86. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  87. package/scripts/github/request-copilot-review.mjs +98 -26
  88. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  89. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  90. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  91. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  92. package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
  93. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  94. package/scripts/github/verify-fresh-review-context.mjs +226 -32
  95. package/scripts/github/view-pr.mjs +150 -0
  96. package/scripts/github/wait-pr-checks.mjs +171 -0
  97. package/scripts/github/write-gate-context.mjs +458 -65
  98. package/scripts/github/write-gate-findings-log.mjs +128 -2
  99. package/scripts/lib/jq-output.mjs +18 -0
  100. package/scripts/loop/_post-convergence-change.mjs +211 -0
  101. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  102. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  103. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  104. package/scripts/loop/check-retro-tooling.mjs +14 -3
  105. package/scripts/loop/checkpoint-contract.mjs +7 -4
  106. package/scripts/loop/cleanup-worktree.mjs +12 -3
  107. package/scripts/loop/conductor-monitor.mjs +12 -18
  108. package/scripts/loop/copilot-pr-handoff.mjs +162 -14
  109. package/scripts/loop/debt-remediate.mjs +24 -12
  110. package/scripts/loop/detect-change-scope.mjs +38 -9
  111. package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
  112. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  113. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  114. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  115. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  116. package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
  117. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  118. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  119. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  120. package/scripts/loop/ensure-worktree.mjs +8 -3
  121. package/scripts/loop/info.mjs +12 -4
  122. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  123. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  124. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  125. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  126. package/scripts/loop/inspect-run.mjs +8 -2
  127. package/scripts/loop/outer-loop.mjs +8 -4
  128. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  129. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  130. package/scripts/loop/pre-flight-gate.mjs +9 -9
  131. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  132. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  133. package/scripts/loop/provision-worktree.mjs +74 -3
  134. package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
  135. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  136. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  137. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  138. package/scripts/loop/run-queue.mjs +18 -9
  139. package/scripts/loop/run-refinement-audit.mjs +8 -9
  140. package/scripts/loop/run-watch-cycle.mjs +2 -9
  141. package/scripts/loop/sanctioned-commands.mjs +106 -0
  142. package/scripts/loop/steer-loop.mjs +29 -16
  143. package/scripts/loop/validate-pr-body-spec.mjs +223 -0
  144. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  145. package/scripts/pages/build-site.mjs +59 -8
  146. package/scripts/pages/build-state-atlas.mjs +443 -0
  147. package/scripts/projects/_resolve-project.mjs +1 -0
  148. package/scripts/projects/add-queue-item.mjs +60 -54
  149. package/scripts/projects/archive-done-items.mjs +49 -84
  150. package/scripts/projects/ensure-queue-board.mjs +10 -36
  151. package/scripts/projects/list-queue-items.mjs +59 -382
  152. package/scripts/projects/move-queue-item.mjs +21 -449
  153. package/scripts/projects/reconcile-queue.mjs +253 -0
  154. package/scripts/projects/reorder-queue-item.mjs +43 -68
  155. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  156. package/scripts/projects/sync-item-status.mjs +15 -10
  157. package/scripts/refine/_refine-helpers.mjs +21 -5
  158. package/scripts/refine/exit-spike.mjs +18 -8
  159. package/scripts/refine/promote-plan.mjs +22 -16
  160. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  161. package/scripts/refine/refine-plan-file.mjs +13 -4
  162. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  163. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  164. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  165. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  166. package/scripts/refine/validate-plan-file.mjs +1 -1
  167. package/scripts/refine/validate-spike-file.mjs +1 -1
  168. package/scripts/refine/verify.mjs +11 -6
  169. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  170. package/scripts/release/extract-changelog-section.mjs +16 -2
  171. package/skills/copilot-pr-followup/SKILL.md +60 -34
  172. package/skills/dev-loop/SKILL.md +11 -7
  173. package/skills/docs/acceptance-criteria-verification.md +8 -3
  174. package/skills/docs/anti-patterns.md +5 -3
  175. package/skills/docs/artifact-authority-contract.md +30 -11
  176. package/skills/docs/confirmation-rules.md +1 -0
  177. package/skills/docs/contract-style-guide.md +37 -0
  178. package/skills/docs/copilot-loop-operations.md +19 -15
  179. package/skills/docs/cross-harness-regression-contract.md +60 -0
  180. package/skills/docs/debt-remediation-contract.md +1 -1
  181. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  182. package/skills/docs/issue-intake-procedure.md +26 -19
  183. package/skills/docs/local-planning-flow.md +1 -1
  184. package/skills/docs/local-planning-worked-example.md +1 -1
  185. package/skills/docs/merge-preconditions.md +30 -12
  186. package/skills/docs/plan-file-contract.md +1 -1
  187. package/skills/docs/pr-lifecycle-contract.md +44 -35
  188. package/skills/docs/public-dev-loop-contract.md +60 -44
  189. package/skills/docs/required-rules.json +149 -0
  190. package/skills/docs/retrospective-checkpoint-contract.md +106 -79
  191. package/skills/docs/spike-mode-contract.md +14 -7
  192. package/skills/docs/stop-conditions.md +30 -15
  193. package/skills/docs/tracker-first-loop-state.md +13 -8
  194. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  195. package/skills/docs/validation-policy.md +4 -5
  196. package/skills/docs/workflow-handoff-contract.md +39 -0
  197. package/skills/local-implementation/SKILL.md +62 -145
  198. package/skills/loop-grill/SKILL.md +165 -0
  199. package/.claude/commands/continue.md +0 -15
  200. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
  201. package/scripts/loop/conductor.mjs +0 -233
  202. package/scripts/loop/detect-stale-runner.mjs +0 -265
  203. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { mkdir, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { execFileSync } from "node:child_process";
4
+ import { createHash } from "node:crypto";
3
5
  import path from "node:path";
4
6
  import { buildParseError, isDirectCliRun, formatCliError } from "../_core-helpers.mjs";
5
- const USAGE = `Usage: verify-fresh-review-context.mjs [--help] [--scope <name>]
7
+ import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
8
+ const USAGE = `Usage: verify-fresh-review-context.mjs [--help] [--scope <name>] [--context-path <path>]
9
+ [--prefix-hash <sha256>|--prefix-file <path>]
6
10
  Verify that the current scoped-reviewer session has fresh context.
7
11
 
8
12
  "Fresh" means the reviewer's context is the neutral gate-context builder
@@ -10,30 +14,76 @@ artifact (the build-once diff + adjacent-code bundle) plus its single review
10
14
  angle, and explicitly NOT the main (orchestrating) agent's conversation/state
11
15
  or a prior reviewer session's state. The injected neutral bundle is the
12
16
  INTENDED seed and is NOT contamination; this guard detects main-agent /
13
- cross-session state bleed by way of a per-(cwd, scope) sentinel: a first run in
14
- a fresh session creates the sentinel and passes, a re-entry that finds an
15
- existing sentinel fails closed. Seeding a reviewer with the neutral bundle (a
16
- path/prompt, not a sentinel) never creates a sentinel, so it never
17
- false-positives as contaminated.
17
+ cross-session state bleed by way of a per-(cwd, scope, round) sentinel: a first
18
+ run in a fresh session creates the sentinel and passes, a re-entry that finds an
19
+ existing sentinel for the same round fails closed. Seeding a reviewer with the
20
+ neutral bundle (a path/prompt, not a sentinel) never creates a sentinel, so it
21
+ never false-positives as contaminated.
22
+
23
+ Sentinels are per review ROUND, keyed by the current head SHA (\`git rev-parse
24
+ HEAD\`). A retry at a new head naturally gets a fresh sentinel (no manual clear
25
+ step), while a same-scope + same-head re-entry still fails closed. When git is
26
+ unavailable the sentinel is keyed by scope only (legacy behavior).
18
27
  Options:
19
28
  --scope <name> Unique reviewer scope (e.g. "draft-gate-coverage").
20
29
  Must be non-empty, containing only alphanumeric
21
30
  characters and hyphens. When provided, the sentinel
22
31
  is scoped so parallel reviewers in the same working
23
32
  directory do not trigger false contamination.
33
+ --context-path <path> Path to the seeded gate-context artifact this
34
+ reviewer must be reading from (the build-once bundle
35
+ written by write-gate-context.mjs, e.g.
36
+ tmp/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.json).
37
+ Must resolve to a path within the reviewer's working
38
+ directory; a path that resolves OUTSIDE cwd (an absolute
39
+ or ..-escaping path pointing at another worktree's bundle,
40
+ which would defeat the worktree-locality guard) fails
41
+ closed (exit 1).
42
+ When provided, also fails closed (exit 1) if the artifact
43
+ is missing (ENOENT) from the reviewer's cwd. Per-angle
44
+ gate reviewers must run in the PR's actual worktree/head
45
+ (never an isolated worktree) so this gitignored,
46
+ worktree-local artifact is present; a missing artifact
47
+ means either a stale/isolated checkout or a skipped
48
+ preamble, and the reviewer must refuse to proceed
49
+ rather than silently reviewing without seeded context.
50
+ --prefix-hash <sha256> A 64-character hex SHA-256 digest (case-insensitive,
51
+ normalized to lowercase) of the
52
+ invariant briefing block this reviewer was seeded with
53
+ (GATE-EXEC-BRIEFING-PREFIX in
54
+ docs/gate-review-sub-loop-contract.md). Recorded on the
55
+ reviewer's sentinel so \`verify-briefing-prefixes.mjs\` can
56
+ fail closed when reviewers of the same gate pass were
57
+ seeded with different invariant blocks. Mutually exclusive
58
+ with --prefix-file.
59
+ --prefix-file <path> Path to the invariant briefing block text; the tool
60
+ hashes its raw bytes (sha256) and records the digest same
61
+ as --prefix-hash. Fails closed (exit 1) if the file is
62
+ missing. Mutually exclusive with --prefix-hash.
24
63
  Output (stdout, JSON):
25
- { "ok": true, "fresh": true, "sentinelCreated": true }
26
- { "ok": true, "fresh": false, "sentinelCreated": false, "reason": "..." }
64
+ { "ok": true, "fresh": true, "sentinelCreated": true, "round": "<headSha|null>" }
65
+ { "ok": true, "fresh": true, "sentinelCreated": true, "round": "...", "gateContextPath": "...", "gateContextPresent": true }
66
+ { "ok": true, "fresh": false, "sentinelCreated": false, "round": "...", "reason": "..." }
67
+ { "ok": true, "fresh": false, "sentinelCreated": false, "round": "...", "gateContextPath": "...", "gateContextPresent": false, "reason": "..." }
27
68
  On error (stderr, JSON):
28
69
  { "ok": false, "error": "...", "usage": "..." }
70
+ ${JQ_OUTPUT_USAGE}
29
71
  Exit codes:
30
72
  0 Clean (first run)
31
- 1 Contaminated (prior session detected)
32
- 2 Usage or internal error`.trim();
73
+ 1 Refuse to review: contaminated (prior session detected), OR (with
74
+ --context-path) the seeded gate-context artifact is missing or resolves
75
+ outside the reviewer's working directory, OR (with --prefix-file) the
76
+ prefix file is missing
77
+ 2 Usage or internal error, invalid --jq filter, or invalid/conflicting
78
+ --prefix-hash/--prefix-file`.trim();
33
79
  const VALID_SCOPE_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
80
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
34
81
  const parseError = buildParseError(USAGE);
35
- function resolveScope(argv) {
36
- const idx = argv.indexOf("--scope");
82
+ // Resolve a `--flag <value>` argument. Returns null when the flag is absent,
83
+ // "" when it is present but the value is missing/empty/flag-like (a following
84
+ // `-`-prefixed token, which must not be silently consumed), else the value.
85
+ function resolveFlagValue(argv, flag) {
86
+ const idx = argv.indexOf(flag);
37
87
  if (idx === -1) return null;
38
88
  const val = argv[idx + 1];
39
89
  if (val === undefined || val === "" || (val.length > 0 && val[0] === "-")) {
@@ -41,6 +91,9 @@ function resolveScope(argv) {
41
91
  }
42
92
  return val;
43
93
  }
94
+ function resolveScope(argv) {
95
+ return resolveFlagValue(argv, "--scope");
96
+ }
44
97
  function resolveValidatedScope(argv) {
45
98
  const raw = resolveScope(argv);
46
99
  if (raw === null) return null;
@@ -52,22 +105,53 @@ function resolveValidatedScope(argv) {
52
105
  }
53
106
  return raw;
54
107
  }
55
- function sentinelRelative(scope) {
56
- const suffix = scope ? `-${scope}` : "";
57
- return path.join("tmp", `checkpoint-context-sentinel${suffix}.json`);
108
+ function resolveContextPath(argv) {
109
+ return resolveFlagValue(argv, "--context-path");
110
+ }
111
+ function resolvePrefixHash(argv) {
112
+ return resolveFlagValue(argv, "--prefix-hash");
113
+ }
114
+ function resolvePrefixFile(argv) {
115
+ return resolveFlagValue(argv, "--prefix-file");
116
+ }
117
+ // Round = the current head SHA, so a retry on a new head gets a fresh key while
118
+ // a same-head re-entry collides and fails closed. `git rev-parse HEAD` yields the
119
+ // same full SHA on every invocation for a given head, so the key is deterministic
120
+ // with no user input to spell it inconsistently. Returns null when git is
121
+ // unavailable (falls back to the legacy scope-only key).
122
+ function resolveHeadRound(cwd = process.cwd()) {
123
+ try {
124
+ const sha = execFileSync("git", ["rev-parse", "HEAD"], {
125
+ cwd,
126
+ encoding: "utf8",
127
+ stdio: ["ignore", "pipe", "ignore"],
128
+ }).trim();
129
+ return VALID_SCOPE_RE.test(sha) ? sha : null;
130
+ } catch {
131
+ return null; // not a git repo / git unavailable
132
+ }
133
+ }
134
+ function sentinelRelative(scope, round) {
135
+ const scopeSuffix = scope ? `-${scope}` : "";
136
+ const roundSuffix = round ? `-${round}` : "";
137
+ return path.join("tmp", `checkpoint-context-sentinel${scopeSuffix}${roundSuffix}.json`);
58
138
  }
59
139
  function legacySentinelRelative(scope) {
60
140
  const suffix = scope ? `-${scope}` : "";
61
141
  return path.join("tmp", `gate-review-context-sentinel${suffix}.json`);
62
142
  }
63
- async function checkSentinelExists(scope, cwd = process.cwd()) {
64
- const sentinelPath = path.resolve(cwd, sentinelRelative(scope));
143
+ async function checkSentinelExists(scope, round, cwd = process.cwd()) {
144
+ const sentinelPath = path.resolve(cwd, sentinelRelative(scope, round));
65
145
  try { await stat(sentinelPath); return { exists: true, path: sentinelPath, legacy: false }; } catch (err) {
66
146
  if (err.code !== "ENOENT") throw err;
67
147
  }
68
- const legacyPath = path.resolve(cwd, legacySentinelRelative(scope));
69
- try { await stat(legacyPath); return { exists: true, path: legacyPath, legacy: true }; } catch (err) {
70
- if (err.code !== "ENOENT") throw err;
148
+ // Legacy names predate head-keyed rounds; only consult them for the
149
+ // scope-only key so a stale pre-round sentinel never blocks a new round.
150
+ if (!round) {
151
+ const legacyPath = path.resolve(cwd, legacySentinelRelative(scope));
152
+ try { await stat(legacyPath); return { exists: true, path: legacyPath, legacy: true }; } catch (err) {
153
+ if (err.code !== "ENOENT") throw err;
154
+ }
71
155
  }
72
156
  return { exists: false, path: sentinelPath, legacy: false };
73
157
  }
@@ -78,27 +162,135 @@ async function main(argv = process.argv.slice(2)) {
78
162
  }
79
163
  const scope = resolveValidatedScope(argv);
80
164
  if (scope === undefined) return 2;
81
- const sentinelPath = path.resolve(process.cwd(), sentinelRelative(scope));
165
+ const contextPathArg = resolveContextPath(argv);
166
+ if (contextPathArg === "") {
167
+ process.stderr.write(`${formatCliError(
168
+ parseError("Invalid --context-path value: must be non-empty.")
169
+ )}\n`);
170
+ return 2;
171
+ }
172
+ const prefixHashArg = resolvePrefixHash(argv);
173
+ if (prefixHashArg === "") {
174
+ process.stderr.write(`${formatCliError(
175
+ parseError("Invalid --prefix-hash value: must be non-empty.")
176
+ )}\n`);
177
+ return 2;
178
+ }
179
+ const prefixFileArg = resolvePrefixFile(argv);
180
+ if (prefixFileArg === "") {
181
+ process.stderr.write(`${formatCliError(
182
+ parseError("Invalid --prefix-file value: must be non-empty.")
183
+ )}\n`);
184
+ return 2;
185
+ }
186
+ if (prefixHashArg !== null && prefixFileArg !== null) {
187
+ process.stderr.write(`${formatCliError(
188
+ parseError("--prefix-hash and --prefix-file are mutually exclusive — pass at most one.")
189
+ )}\n`);
190
+ return 2;
191
+ }
192
+ if (prefixHashArg !== null && !SHA256_HEX_RE.test(prefixHashArg.trim().toLowerCase())) {
193
+ process.stderr.write(`${formatCliError(
194
+ parseError(`Invalid --prefix-hash value "${prefixHashArg}": must be a 64-character hex SHA-256 digest (case-insensitive).`)
195
+ )}\n`);
196
+ return 2;
197
+ }
198
+ const jqArg = resolveFlagValue(argv, "--jq");
199
+ if (jqArg === "") {
200
+ process.stderr.write(`${formatCliError(
201
+ parseError("Invalid --jq value: must be non-empty.")
202
+ )}\n`);
203
+ return 2;
204
+ }
205
+ const jq = jqArg === null ? undefined : jqArg;
206
+ const silent = argv.includes("--silent") || argv.includes("-s");
207
+ // Every branch below reports ok:true (the tool ran successfully); the
208
+ // fresh/contaminated verdict maps to the exit code via `ok` here so the
209
+ // shared --jq/--silent contract composes with the existing 0=fresh/1=refuse
210
+ // signal instead of always reading ok:true as success.
211
+ const finish = (payload, freshLike) => emitResult(payload, { jq, silent, ok: freshLike });
212
+ const round = resolveHeadRound();
213
+ if (contextPathArg !== null) {
214
+ const cwd = process.cwd();
215
+ const resolvedContextPath = path.resolve(cwd, contextPathArg);
216
+ // The guard proves the reviewer is in the working tree where the gitignored
217
+ // tmp/gate-context bundle was written. An absolute or ..-escaping path could
218
+ // stat the real bundle in ANOTHER (stale/isolated) worktree and pass — so a
219
+ // path resolving outside cwd fails closed, defeating that bypass.
220
+ const withinCwd =
221
+ resolvedContextPath === cwd || resolvedContextPath.startsWith(cwd + path.sep);
222
+ if (!withinCwd) {
223
+ return finish({
224
+ ok: true,
225
+ fresh: false,
226
+ sentinelCreated: false,
227
+ round: round ?? null,
228
+ gateContextPath: contextPathArg,
229
+ gateContextPresent: false,
230
+ reason: `Seeded gate-context artifact path "${contextPathArg}" resolves outside the reviewer's working directory — refusing. --context-path must be a cwd-relative path to the worktree-local bundle; an absolute or ..-escaping path could point at another (stale/isolated) worktree's bundle and defeat the worktree-locality guard.`,
231
+ }, false);
232
+ }
233
+ try {
234
+ await stat(resolvedContextPath);
235
+ } catch (err) {
236
+ if (err.code !== "ENOENT") throw err;
237
+ return finish({
238
+ ok: true,
239
+ fresh: false,
240
+ sentinelCreated: false,
241
+ round: round ?? null,
242
+ gateContextPath: contextPathArg,
243
+ gateContextPresent: false,
244
+ reason: `Seeded gate-context artifact missing at "${contextPathArg}" — refusing to review without the build-once neutral context bundle. Per-angle gate reviewers must run in the PR's actual worktree/head (never an isolated worktree checked out from stale main), which is where the context-builder preamble wrote this gitignored artifact.`,
245
+ }, false);
246
+ }
247
+ }
248
+ // Resolve the invariant-briefing prefix hash (GATE-EXEC-BRIEFING-PREFIX) before
249
+ // sentinel creation, same ordering rationale as --context-path above: a failure
250
+ // here must not burn the scope sentinel.
251
+ let prefixHash = prefixHashArg !== null ? prefixHashArg.trim().toLowerCase() : null;
252
+ if (prefixHash === null && prefixFileArg !== null) {
253
+ let prefixFileBytes;
254
+ try {
255
+ prefixFileBytes = await readFile(path.resolve(process.cwd(), prefixFileArg));
256
+ } catch (err) {
257
+ // ANY read failure (ENOENT, EACCES, EPERM, ...) refuses the review in the
258
+ // normal fail-closed shape — an unreadable prefix file means the
259
+ // invariant-briefing proof cannot be established, which is an enforcement
260
+ // refusal, not a tool crash.
261
+ return finish({
262
+ ok: true,
263
+ fresh: false,
264
+ sentinelCreated: false,
265
+ round: round ?? null,
266
+ reason: `--prefix-file "${prefixFileArg}" unreadable (${err.code ?? "error"}) — cannot compute the invariant-briefing prefix hash (GATE-EXEC-BRIEFING-PREFIX).`,
267
+ }, false);
268
+ }
269
+ prefixHash = createHash("sha256").update(prefixFileBytes).digest("hex");
270
+ }
271
+ const sentinelPath = path.resolve(process.cwd(), sentinelRelative(scope, round));
82
272
  try {
83
273
  await mkdir(path.dirname(sentinelPath), { recursive: true });
84
274
  } catch (err) {
85
275
  process.stderr.write(`${formatCliError(err)}\n`);
86
276
  return 2;
87
277
  }
88
- const existing = await checkSentinelExists(scope);
278
+ const existing = await checkSentinelExists(scope, round);
89
279
  if (existing.exists) {
90
- process.stdout.write(JSON.stringify({
280
+ return finish({
91
281
  ok: true,
92
282
  fresh: false,
93
283
  sentinelCreated: false,
284
+ round: round ?? null,
94
285
  reason: `Checkpoint context sentinel already exists${existing.legacy ? " (legacy name)" : ""} — inherited session context detected. Restart the subagent with fresh context (subagent({context:\"fresh\"})).`,
95
- }) + "\n");
96
- return 1;
286
+ }, false);
97
287
  }
98
288
  const sentinel = {
99
289
  createdAt: new Date().toISOString(),
100
290
  pid: process.pid,
101
291
  ...(scope ? { scope } : {}),
292
+ ...(round ? { round } : {}),
293
+ ...(prefixHash ? { prefixHash } : {}),
102
294
  };
103
295
  try {
104
296
  await writeFile(sentinelPath, JSON.stringify(sentinel, null, 2) + "\n", {
@@ -107,23 +299,25 @@ async function main(argv = process.argv.slice(2)) {
107
299
  });
108
300
  } catch (err) {
109
301
  if (err.code === "EEXIST") {
110
- process.stdout.write(JSON.stringify({
302
+ return finish({
111
303
  ok: true,
112
304
  fresh: false,
113
305
  sentinelCreated: false,
306
+ round: round ?? null,
114
307
  reason: "Checkpoint context sentinel already exists (detected on atomic create) — inherited session context detected. Restart the subagent with fresh context (subagent({context:\"fresh\"})).",
115
- }) + "\n");
116
- return 1;
308
+ }, false);
117
309
  }
118
310
  process.stderr.write(`${formatCliError(err)}\n`);
119
311
  return 2;
120
312
  }
121
- process.stdout.write(JSON.stringify({
313
+ return finish({
122
314
  ok: true,
123
315
  fresh: true,
124
316
  sentinelCreated: true,
125
- }) + "\n");
126
- return 0;
317
+ round: round ?? null,
318
+ ...(contextPathArg !== null ? { gateContextPath: contextPathArg, gateContextPresent: true } : {}),
319
+ ...(prefixHash ? { prefixHash } : {}),
320
+ }, true);
127
321
  }
128
322
  if (isDirectCliRun(import.meta.url)) {
129
323
  try {
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
+ import { parseArgs } from "node:util";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
+
8
+ // Default PR facts a follow-up run reads: the fields loop info surfaces plus the
9
+ // head SHA. Callers needing a different set pass --json <fields> (a comma list,
10
+ // same vocabulary as `gh pr view --json`).
11
+ const DEFAULT_FIELDS =
12
+ "number,title,body,state,isDraft,headRefName,baseRefName,headRefOid,author,mergedAt,mergeable,mergeStateStatus,url";
13
+
14
+ const USAGE = `Usage: view-pr.mjs --repo <owner/name> --pr <number> [--json <fields>]
15
+ Read PR facts (branch/state/mergeStateStatus/head SHA/etc.). Thin wrapper over
16
+ \`gh pr view --json …\` — use this instead of an agent-level raw \`gh pr view\` so the
17
+ loop's internal-tooling record stays clean (siblings: list-issues.mjs,
18
+ comment-issue.mjs, fetch-ci-logs.mjs; #1057). For composite loop routing/CI facts
19
+ prefer \`dev-loops loop info --pr\`; this is the thin field-read counterpart.
20
+ Required:
21
+ --repo <owner/name> Repository slug (e.g. owner/repo)
22
+ --pr <number> Pull request number
23
+ Optional:
24
+ --json <fields> Comma-separated gh pr view field list
25
+ (default: ${DEFAULT_FIELDS})
26
+ Output (stdout, JSON):
27
+ { "ok": true, "pr": { <requested fields> } }
28
+ Error output (stderr, JSON):
29
+ { "ok": false, "error": "...", "usage"?: "..." }
30
+ ${JQ_OUTPUT_USAGE}
31
+ Exit codes:
32
+ 0 Success
33
+ 1 Argument error or gh failure
34
+ 2 Invalid --jq filter`.trim();
35
+ const parseError = buildParseError(USAGE);
36
+
37
+ export function parseViewPrCliArgs(argv) {
38
+ const { tokens } = parseArgs({
39
+ args: [...argv],
40
+ options: {
41
+ help: { type: "boolean", short: "h" },
42
+ repo: { type: "string" },
43
+ pr: { type: "string" },
44
+ json: { type: "string" },
45
+ ...JQ_OUTPUT_PARSE_OPTIONS,
46
+ },
47
+ allowPositionals: true,
48
+ strict: false,
49
+ tokens: true,
50
+ });
51
+ const options = {
52
+ help: false,
53
+ repo: undefined,
54
+ pr: undefined,
55
+ fields: DEFAULT_FIELDS,
56
+ jq: undefined,
57
+ silent: false,
58
+ };
59
+ for (const token of tokens) {
60
+ if (token.kind === "positional") {
61
+ throw parseError(`Unknown argument: ${token.value}`);
62
+ }
63
+ if (token.kind !== "option") {
64
+ continue;
65
+ }
66
+ if (token.name === "help") {
67
+ options.help = true;
68
+ return options;
69
+ }
70
+ if (token.name === "repo") {
71
+ options.repo = requireTokenValue(token, parseError).trim();
72
+ continue;
73
+ }
74
+ if (token.name === "pr") {
75
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
76
+ continue;
77
+ }
78
+ if (token.name === "json") {
79
+ const fields = requireTokenValue(token, parseError)
80
+ .split(",")
81
+ .map((f) => f.trim())
82
+ .filter((f) => f.length > 0);
83
+ if (fields.length === 0) {
84
+ throw parseError("--json must list at least one field");
85
+ }
86
+ if (fields.some((f) => !/^[A-Za-z][A-Za-z0-9]*$/.test(f))) {
87
+ throw parseError("--json fields must be gh pr view field names (letters/digits)");
88
+ }
89
+ options.fields = fields.join(",");
90
+ continue;
91
+ }
92
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
93
+ throw parseError(`Unknown argument: ${token.rawName}`);
94
+ }
95
+ if (options.repo === undefined || options.pr === undefined) {
96
+ throw parseError("Viewing a PR requires both --repo <owner/name> and --pr <number>");
97
+ }
98
+ try {
99
+ parseRepoSlug(options.repo);
100
+ } catch (error) {
101
+ throw parseError(error instanceof Error ? error.message : String(error));
102
+ }
103
+ return options;
104
+ }
105
+
106
+ export async function viewPr(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
107
+ const result = await run(
108
+ ghCommand,
109
+ ["pr", "view", String(options.pr), "--repo", options.repo, "--json", options.fields],
110
+ env,
111
+ );
112
+ if (result.code !== 0) {
113
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
114
+ throw new Error(`gh pr view failed: ${detail}`);
115
+ }
116
+ const pr = parseJsonText(result.stdout, { label: "gh pr view" });
117
+ if (pr === null || typeof pr !== "object" || Array.isArray(pr)) {
118
+ throw new Error("gh pr view did not return a JSON object");
119
+ }
120
+ return { ok: true, pr };
121
+ }
122
+
123
+ export async function runCli(
124
+ argv = process.argv.slice(2),
125
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
126
+ ) {
127
+ let options;
128
+ try {
129
+ options = parseViewPrCliArgs(argv);
130
+ } catch (error) {
131
+ stderr.write(`${formatCliError(error)}\n`);
132
+ return 1;
133
+ }
134
+ if (options.help) {
135
+ stdout.write(`${USAGE}\n`);
136
+ return 0;
137
+ }
138
+ let result;
139
+ try {
140
+ result = await viewPr(options, { env, ghCommand, run });
141
+ } catch (error) {
142
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
143
+ return 1;
144
+ }
145
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
146
+ }
147
+
148
+ if (isDirectCliRun(import.meta.url)) {
149
+ runCli().then((code) => { process.exitCode = code; });
150
+ }
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
4
+ import { parseNonNegativeInteger, parsePositiveInteger, parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
+ import {
8
+ DEFAULT_POLL_INTERVAL_MS,
9
+ COPILOT_REVIEW_WAIT_TIMEOUT_MS,
10
+ } from "@dev-loops/core/loop/policy-constants";
11
+ // Reuse probe-ci-status.mjs's watch engine verbatim (polling, heartbeats, the
12
+ // not-yet-registered-check grace/race guard, head-change detection) instead of
13
+ // re-implementing it. This wrapper only adds seconds-based flags and a direct
14
+ // process-exit-code contract for shell/scripted callers.
15
+ import { watchCiStatus } from "./probe-ci-status.mjs";
16
+
17
+ const DEFAULT_TIMEOUT_SECONDS = Math.floor(COPILOT_REVIEW_WAIT_TIMEOUT_MS / 1000);
18
+ const DEFAULT_POLL_SECONDS = Math.floor(DEFAULT_POLL_INTERVAL_MS / 1000);
19
+
20
+ const USAGE = `Usage: wait-pr-checks.mjs --repo <owner/name> --pr <number> [--timeout <seconds>] [--poll <seconds>]
21
+ Block until the current head SHA's CI checks/statuses settle, or the wait
22
+ budget elapses. Replaces hand-rolled \`until [ "$(gh pr checks | grep -c pending)" = 0 ]\`
23
+ shell loops with a single deterministic call.
24
+ Required:
25
+ --repo <owner/name> Repository slug (e.g. owner/repo)
26
+ --pr <number> Pull request number
27
+ Optional:
28
+ --timeout <seconds> Total wait budget (default ${DEFAULT_TIMEOUT_SECONDS}; 0 = single
29
+ immediate check, no wait)
30
+ --poll <seconds> Delay between polls (default ${DEFAULT_POLL_SECONDS})
31
+ Not-yet-registered-check race guard:
32
+ A freshly pushed head with zero registered checks does NOT settle green on
33
+ the first poll — a provider may post its first check a beat after the push.
34
+ The watcher requires either an observed check/status, or a stable empty
35
+ result across two consecutive polls, before treating the head as genuinely
36
+ check-less. A repo with no CI at all still settles after that grace instead
37
+ of hanging to timeout.
38
+ Output (stdout, JSON, the final per-check summary):
39
+ { "ok": true, "status": "success"|"failure"|"pending"|"timeout"|"changed",
40
+ "settled": bool, "ciStatus": "success"|"failure"|"pending"|"none",
41
+ "failedChecks": [{ "name": "...", "conclusion"?: "..." }], "headSha": "...", "attempts": N }
42
+ "changed" means the head SHA advanced during the wait; re-baseline and re-run.
43
+ Diagnostic output (stderr):
44
+ { "ok": true, "type": "watch_heartbeat", "elapsedMs": N, "totalBudgetMs": N, "poll": N, "maxPolls": N }
45
+ { "ok": false, "error": "...", "usage"?: "..." }
46
+ ${JQ_OUTPUT_USAGE}
47
+ Exit codes (default output, no --jq/--silent):
48
+ 0 Green (status "success")
49
+ 1 Red (status "failure"), or an argument/gh/runtime error
50
+ 2 Not settled (status "timeout"/"changed"/"pending")
51
+ With --jq/--silent, the standard jq-output contract above applies instead
52
+ (0/1 by result truthiness; 2 reserved for an invalid --jq filter).`.trim();
53
+
54
+ const parseError = buildParseError(USAGE);
55
+
56
+ export function parseWaitPrChecksCliArgs(argv) {
57
+ const { tokens } = parseArgs({
58
+ args: [...argv],
59
+ options: {
60
+ help: { type: "boolean", short: "h" },
61
+ repo: { type: "string" },
62
+ pr: { type: "string" },
63
+ timeout: { type: "string" },
64
+ poll: { type: "string" },
65
+ ...JQ_OUTPUT_PARSE_OPTIONS,
66
+ },
67
+ allowPositionals: true,
68
+ strict: false,
69
+ tokens: true,
70
+ });
71
+ const options = {
72
+ help: false,
73
+ repo: undefined,
74
+ pr: undefined,
75
+ timeoutMs: DEFAULT_TIMEOUT_SECONDS * 1000,
76
+ pollIntervalMs: DEFAULT_POLL_SECONDS * 1000,
77
+ };
78
+ for (const token of tokens) {
79
+ if (token.kind === "positional") {
80
+ throw parseError(`Unknown argument: ${token.value}`);
81
+ }
82
+ if (token.kind !== "option") {
83
+ continue;
84
+ }
85
+ if (token.name === "help") {
86
+ options.help = true;
87
+ return options;
88
+ }
89
+ if (token.name === "repo") {
90
+ options.repo = requireTokenValue(token, parseError).trim();
91
+ continue;
92
+ }
93
+ if (token.name === "pr") {
94
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
95
+ continue;
96
+ }
97
+ if (token.name === "timeout") {
98
+ // Canonical-digits validation (shared /^\d+$/ primitives): rejects
99
+ // JS-coercible spellings like 1e3, 0x10, "1.0", or " 5 ".
100
+ options.timeoutMs = parseNonNegativeInteger(requireTokenValue(token, parseError), "--timeout", parseError) * 1000;
101
+ continue;
102
+ }
103
+ if (token.name === "poll") {
104
+ options.pollIntervalMs = parsePositiveInteger(requireTokenValue(token, parseError), "--poll", parseError) * 1000;
105
+ continue;
106
+ }
107
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
108
+ throw parseError(`Unknown argument: ${token.rawName}`);
109
+ }
110
+ if (options.repo === undefined || options.pr === undefined) {
111
+ throw parseError("Waiting on PR checks requires both --repo <owner/name> and --pr <number>");
112
+ }
113
+ try {
114
+ parseRepoSlug(options.repo);
115
+ } catch (error) {
116
+ throw parseError(error instanceof Error ? error.message : String(error));
117
+ }
118
+ return options;
119
+ }
120
+
121
+ // Direct process-exit-code contract for shell/scripted callers — this is the
122
+ // tool's reason to exist alongside `dev-loops loop watch-ci`, whose CLI always
123
+ // exits 0 and expects callers to branch on the JSON `status` field instead.
124
+ export function exitCodeForWaitResult(result) {
125
+ if (result.status === "success") return 0;
126
+ if (result.status === "failure") return 1;
127
+ return 2;
128
+ }
129
+
130
+ export async function runCli(
131
+ argv = process.argv.slice(2),
132
+ {
133
+ stdout = process.stdout,
134
+ stderr = process.stderr,
135
+ env = process.env,
136
+ ghCommand = "gh",
137
+ delayImpl = undefined,
138
+ now = undefined,
139
+ } = {},
140
+ ) {
141
+ const options = parseWaitPrChecksCliArgs(argv);
142
+ if (options.help) {
143
+ stdout.write(`${USAGE}\n`);
144
+ return 0;
145
+ }
146
+ const result = await watchCiStatus(
147
+ { repo: options.repo, pr: options.pr, timeoutMs: options.timeoutMs, pollIntervalMs: options.pollIntervalMs },
148
+ {
149
+ env,
150
+ ghCommand,
151
+ ...(delayImpl ? { delayImpl } : {}),
152
+ ...(now ? { now } : {}),
153
+ },
154
+ );
155
+ if (options.jq !== undefined || options.silent) {
156
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr, ok: result.status === "success" });
157
+ }
158
+ stdout.write(`${JSON.stringify(result)}\n`);
159
+ return exitCodeForWaitResult(result);
160
+ }
161
+
162
+ if (isDirectCliRun(import.meta.url)) {
163
+ runCli().then((code) => {
164
+ if (typeof code === "number") {
165
+ process.exitCode = code;
166
+ }
167
+ }).catch((error) => {
168
+ process.stderr.write(`${formatCliError(error)}\n`);
169
+ process.exitCode = 1;
170
+ });
171
+ }