dev-loops 0.3.0 → 0.4.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 (53) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/hooks/_run-context.mjs +9 -16
  4. package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
  5. package/.claude/skills/dev-loop/SKILL.md +6 -6
  6. package/.claude/skills/docs/anti-patterns.md +1 -1
  7. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  8. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  9. package/.claude/skills/docs/merge-preconditions.md +65 -1
  10. package/.claude/skills/docs/stop-conditions.md +1 -0
  11. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  12. package/.claude/skills/local-implementation/SKILL.md +8 -2
  13. package/CHANGELOG.md +26 -0
  14. package/README.md +8 -2
  15. package/cli/index.mjs +21 -2
  16. package/extension/README.md +1 -1
  17. package/package.json +3 -3
  18. package/scripts/README.md +2 -2
  19. package/scripts/github/offer-human-handoff.mjs +147 -0
  20. package/scripts/github/probe-ci-status.mjs +468 -0
  21. package/scripts/github/request-copilot-review.mjs +3 -3
  22. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  23. package/scripts/github/upsert-checkpoint-verdict.mjs +2 -2
  24. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  25. package/scripts/loop/_worktree-path.mjs +27 -0
  26. package/scripts/loop/cleanup-worktree.mjs +175 -0
  27. package/scripts/loop/copilot-pr-handoff.mjs +1 -1
  28. package/scripts/loop/detect-stale-runner.mjs +3 -4
  29. package/scripts/loop/ensure-worktree.mjs +219 -0
  30. package/scripts/loop/outer-loop.mjs +1 -1
  31. package/scripts/loop/pr-runner-coordination.mjs +1 -1
  32. package/scripts/loop/pre-flight-gate.mjs +10 -7
  33. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  34. package/scripts/loop/provision-worktree.mjs +243 -0
  35. package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
  36. package/scripts/loop/run-queue.mjs +25 -1
  37. package/scripts/loop/run-watch-cycle.mjs +75 -22
  38. package/scripts/projects/add-queue-item.mjs +20 -5
  39. package/scripts/projects/archive-done-items.mjs +2 -1
  40. package/scripts/projects/ensure-queue-board.mjs +2 -1
  41. package/scripts/projects/list-queue-items.mjs +2 -1
  42. package/scripts/projects/move-queue-item.mjs +2 -1
  43. package/scripts/projects/reorder-queue-item.mjs +5 -4
  44. package/scripts/projects/sync-item-status.mjs +2 -1
  45. package/skills/copilot-pr-followup/SKILL.md +9 -7
  46. package/skills/dev-loop/SKILL.md +2 -2
  47. package/skills/docs/anti-patterns.md +1 -1
  48. package/skills/docs/copilot-loop-operations.md +1 -1
  49. package/skills/docs/issue-intake-procedure.md +4 -0
  50. package/skills/docs/merge-preconditions.md +65 -1
  51. package/skills/docs/stop-conditions.md +1 -0
  52. package/skills/docs/tracker-first-loop-state.md +6 -6
  53. package/skills/local-implementation/SKILL.md +8 -2
@@ -5,7 +5,10 @@ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helper
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
6
  import { DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION } from "@dev-loops/core/loop/public-dev-loop-routing";
7
7
  import { watchCopilotReview } from "../github/probe-copilot-review.mjs";
8
+ import { watchCiStatus } from "../github/probe-ci-status.mjs";
8
9
  import { runHandoff } from "./copilot-pr-handoff.mjs";
10
+ import { STATE } from "@dev-loops/core/loop/copilot-loop-state";
11
+ import { DEFAULT_POLL_INTERVAL_MS } from "@dev-loops/core/loop/policy-constants";
9
12
  import { detectCopilotSessionActivity } from "./detect-copilot-session-activity.mjs";
10
13
  import { parseArgs } from "node:util";
11
14
  import {
@@ -124,16 +127,33 @@ function buildWatchCycleContractTrace({
124
127
  cycleDisposition,
125
128
  sessionActivity = null,
126
129
  workflowRunWatch = null,
130
+ ciWatchArgs = null,
131
+ ciWatchStatus = null,
127
132
  }) {
128
- const boundaryClassification = handoff.action !== "watch"
129
- ? (handoff.loopDisposition === "blocked"
133
+ // The CI-watch branch (waiting_for_ci) routes through probe-ci-status.mjs even
134
+ // though handoff.action !== "watch". It is an observational wait, so it mirrors
135
+ // the Copilot watch classification: a quiet timeout is a HEALTHY_WAIT, while
136
+ // success/failure/changed route a follow-up. Without this the boundary-action
137
+ // path below would misclassify a CI timeout as routed_followup.
138
+ const isCiWatch = ciWatchArgs !== null;
139
+ const isWatchBoundary = handoff.action === "watch" || isCiWatch;
140
+ const observedStatus = isCiWatch ? ciWatchStatus : watchStatus;
141
+ const boundaryClassification = isWatchBoundary
142
+ ? (observedStatus === "timeout" || observedStatus === "idle"
143
+ ? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.HEALTHY_WAIT
144
+ : DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP)
145
+ : handoff.loopDisposition === "blocked"
130
146
  ? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.BLOCKED
131
147
  : handoff.terminal
132
148
  ? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.TERMINAL
133
- : DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP)
134
- : watchStatus === "changed"
135
- ? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP
136
- : DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.HEALTHY_WAIT;
149
+ : DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP;
150
+ const healthyWait = boundaryClassification === DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.HEALTHY_WAIT;
151
+ const helper = handoff.action === "watch"
152
+ ? "scripts/github/probe-copilot-review.mjs"
153
+ : isCiWatch
154
+ ? "scripts/github/probe-ci-status.mjs"
155
+ : null;
156
+ const effectiveArgs = isCiWatch ? ciWatchArgs : watchArgs;
137
157
  return {
138
158
  handoff: {
139
159
  action: handoff.action,
@@ -142,38 +162,37 @@ function buildWatchCycleContractTrace({
142
162
  terminal: Boolean(handoff.terminal),
143
163
  },
144
164
  waitStrategy: {
145
- helper: handoff.action === "watch" ? "scripts/github/probe-copilot-review.mjs" : null,
146
- mode: handoff.action === "watch"
147
- ? "persistent_watch"
148
- : "not_applicable",
149
- effectiveTimeoutMs: watchArgs?.timeoutMs ?? null,
150
- effectivePollIntervalMs: watchArgs?.pollIntervalMs ?? null,
165
+ helper,
166
+ mode: isWatchBoundary ? "persistent_watch" : "not_applicable",
167
+ effectiveTimeoutMs: effectiveArgs?.timeoutMs ?? null,
168
+ effectivePollIntervalMs: effectiveArgs?.pollIntervalMs ?? null,
151
169
  timeoutPolicyClassification: watchTimeoutPolicy?.classification ?? null,
152
170
  },
153
171
  orchestration: {
154
172
  emittedWatchArgs: handoff.watchArgs ?? null,
155
- effectiveWatchArgs: watchArgs,
173
+ effectiveWatchArgs: effectiveArgs,
174
+ ciWatchArgs,
156
175
  sessionActivity,
157
176
  workflowRunWatch,
158
177
  },
159
- stateRefresh: handoff.action === "watch"
178
+ stateRefresh: isWatchBoundary
160
179
  ? {
161
180
  boundaryKind: "post_watch_or_probe",
162
- observedStatus: watchStatus,
181
+ observedStatus,
163
182
  refreshRequired: true,
164
- refreshReason: watchStatus === "changed"
165
- ? "Watch boundaries with fresh activity require an authoritative state refresh before routing the follow-up path."
166
- : "Healthy watch boundaries are observational only; refresh authoritative state before treating timeout/idle as stop or completion.",
183
+ refreshReason: healthyWait
184
+ ? "Healthy watch boundaries are observational only; refresh authoritative state before treating timeout/idle as stop or completion."
185
+ : "Watch boundaries with fresh activity require an authoritative state refresh before routing the follow-up path.",
167
186
  }
168
187
  : null,
169
188
  stopReason: {
170
189
  classification: boundaryClassification,
171
190
  terminal: Boolean(handoff.terminal),
172
191
  cycleDisposition,
173
- reason: handoff.action === "watch"
174
- ? (watchStatus === "changed"
175
- ? "Fresh watcher activity requires follow-up instead of staying in a healthy wait boundary."
176
- : "Quiet watcher boundaries remain healthy waits and must not be treated as terminal completion by themselves.")
192
+ reason: isWatchBoundary
193
+ ? (healthyWait
194
+ ? "Quiet watcher boundaries remain healthy waits and must not be treated as terminal completion by themselves."
195
+ : "Fresh watcher activity requires follow-up instead of staying in a healthy wait boundary.")
177
196
  : handoff.nextAction,
178
197
  },
179
198
  };
@@ -236,6 +255,7 @@ export async function runWatchCycle(
236
255
  ghCommand = "gh",
237
256
  runHandoffImpl = runHandoff,
238
257
  watchCopilotReviewImpl = watchCopilotReview,
258
+ watchCiStatusImpl = watchCiStatus,
239
259
  detectCopilotSessionActivityImpl = detectCopilotSessionActivity,
240
260
  fetchPrHeadBranchImpl = fetchPrHeadBranch,
241
261
  watchWorkflowRunImpl = watchWorkflowRun,
@@ -267,6 +287,39 @@ export async function runWatchCycle(
267
287
  if (handoff.watchTimeoutPolicy !== undefined) {
268
288
  result.watchTimeoutPolicy = handoff.watchTimeoutPolicy;
269
289
  }
290
+ // Provider-agnostic CI wait (#917): a waiting_for_ci boundary would otherwise
291
+ // dead-end at action:"stop". Route it to the helper-owned CI watcher
292
+ // (CircleCI / GH Actions / external commit-status), not gh run watch.
293
+ if (handoff.action !== "watch" && handoff.state === STATE.WAITING_FOR_CI) {
294
+ // Mirror determineWatchTimeout but with a CI-specific context label so
295
+ // timeout diagnostics read "CI wait" instead of "Copilot review wait".
296
+ const ciTimeoutMs = enforceExternalHealthyWaitTimeout({
297
+ timeoutMs: EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY.defaultTimeoutMs,
298
+ contextLabel: "CI wait",
299
+ });
300
+ const ciWatchArgs = {
301
+ repo: options.repo,
302
+ pr: options.pr,
303
+ pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
304
+ timeoutMs: ciTimeoutMs,
305
+ };
306
+ const ciWatch = await watchCiStatusImpl(ciWatchArgs, { env, ghCommand });
307
+ result.ciWatchArgs = ciWatchArgs;
308
+ result.ciWatch = ciWatch;
309
+ result.watchStatus = ciWatch.status;
310
+ // success/failure/changed all need authoritative re-detection (follow-up);
311
+ // a quiet timeout stays a healthy pending wait.
312
+ result.cycleDisposition = ciWatch.status === "timeout" ? "pending" : "needs_followup";
313
+ result.terminal = false;
314
+ result.contractTrace = buildWatchCycleContractTrace({
315
+ handoff,
316
+ watchTimeoutPolicy: result.watchTimeoutPolicy ?? null,
317
+ cycleDisposition: result.cycleDisposition,
318
+ ciWatchArgs,
319
+ ciWatchStatus: ciWatch.status,
320
+ });
321
+ return result;
322
+ }
270
323
  if (handoff.action !== "watch") {
271
324
  result.contractTrace = buildWatchCycleContractTrace({
272
325
  handoff,
@@ -3,7 +3,8 @@ import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.
3
3
  import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { parseArgs } from "node:util";
5
5
 
6
- const USAGE = `Usage: dev-loops project add --repo <owner/name> --project <number|id> --item <number>
6
+ const USAGE = `Usage: dev-loops queue add --repo <owner/name> --project <number|id> --item <number>
7
+ dev-loops project add … (back-compat alias for "queue add")
7
8
 
8
9
  Add an existing issue or PR to a GitHub Projects V2 board.
9
10
 
@@ -11,7 +12,8 @@ Options:
11
12
  --repo <owner/name> Required. Repository containing the issue/PR.
12
13
  --project <number|id> Required. Project number (integer) or node ID.
13
14
  --item <number> Required. Issue or PR number to add.
14
- --status <name> Initial Status column (default: "Backlog").
15
+ --column <name> Initial Status column (default: "Backlog").
16
+ --status <name> Back-compat alias for --column.
15
17
  --help, -h Show this help.
16
18
 
17
19
  Output (stdout):
@@ -41,6 +43,7 @@ function parseCliArgs(argv) {
41
43
  repo: { type: "string" },
42
44
  project: { type: "string" },
43
45
  item: { type: "string" },
46
+ column: { type: "string" },
44
47
  status: { type: "string" },
45
48
  help: { type: "boolean", short: "h" },
46
49
  },
@@ -81,7 +84,13 @@ function parseCliArgs(argv) {
81
84
  args.item = val;
82
85
  break;
83
86
  }
87
+ case "column":
88
+ args.column = requireValue(token, "--column requires a value");
89
+ break;
84
90
  case "status":
91
+ // Back-compat alias for --column (issue #912). Kept separate so a
92
+ // conflicting `--column X --status Y` is rejected rather than silently
93
+ // resolved by argv order.
85
94
  args.status = requireValue(token, "--status requires a value");
86
95
  break;
87
96
  default:
@@ -375,9 +384,15 @@ async function main(args, { env = process.env, runChild } = {}) {
375
384
  if (!Number.isInteger(itemNumber) || itemNumber < 1) {
376
385
  throw Object.assign(new Error("--item is required and must be a positive integer"), { code: "INVALID_ITEM" });
377
386
  }
378
- const targetStatus = (args.status ?? "Backlog").trim();
387
+ if (args.column != null && args.status != null && args.column.trim() !== args.status.trim()) {
388
+ throw Object.assign(
389
+ new Error(`Conflicting --column ("${args.column}") and --status ("${args.status}") — pass only one (prefer --column).`),
390
+ { code: "INVALID_ARGS", usage: USAGE },
391
+ );
392
+ }
393
+ const targetStatus = (args.column ?? args.status ?? "Backlog").trim();
379
394
  if (!targetStatus) {
380
- throw Object.assign(new Error("--status must not be empty"), { code: "INVALID_STATUS" });
395
+ throw Object.assign(new Error("--column must not be empty"), { code: "INVALID_STATUS" });
381
396
  }
382
397
 
383
398
  // 1. Resolve owner
@@ -542,4 +557,4 @@ if (isDirectCliRun(import.meta.url)) {
542
557
  });
543
558
  }
544
559
 
545
- export { main };
560
+ export { main, parseCliArgs };
@@ -6,7 +6,8 @@ import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.
6
6
  import { runChild as _runChild } from "../_cli-primitives.mjs";
7
7
  import { parseArgs } from "node:util";
8
8
 
9
- const USAGE = `Usage: dev-loops project archive-done --repo <owner/name> [--project <number|id>] [--older-than <duration>] [--dry-run]
9
+ const USAGE = `Usage: dev-loops queue archive-done --repo <owner/name> [--project <number|id>] [--older-than <duration>] [--dry-run]
10
+ (dev-loops project archive-done … is a back-compat alias)
10
11
 
11
12
  Archive GitHub Projects V2 items whose issue/PR has been closed for at least the
12
13
  given duration. Operator-triggered (no webhooks). Uses archiveProjectV2Item.
@@ -6,7 +6,8 @@ import { parse as parseYaml } from "yaml";
6
6
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
7
7
  import { runChild as _runChild } from "../_cli-primitives.mjs";
8
8
 
9
- const USAGE = `Usage: dev-loops project ensure --repo <owner/name> [--project <number>] [--title <title>] [--link-repo <owner/name>] [--repair-rename]
9
+ const USAGE = `Usage: dev-loops queue ensure --repo <owner/name> [--project <number>] [--title <title>] [--link-repo <owner/name>] [--repair-rename]
10
+ (dev-loops project ensure … is a back-compat alias)
10
11
 
11
12
  --repair-rename Rename semantically equivalent Status columns to the standard names
12
13
  (e.g. "Ready" -> "Next Up"). Without this flag the helper only
@@ -3,7 +3,8 @@ import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.
3
3
  import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { parseArgs } from "node:util";
5
5
 
6
- const USAGE = `Usage: dev-loops project list --repo <owner/name> --project <number|id> [--column <name>] [--limit <n>]
6
+ const USAGE = `Usage: dev-loops queue list --repo <owner/name> --project <number|id> [--column <name>] [--limit <n>]
7
+ (dev-loops project list … is a back-compat alias)
7
8
 
8
9
  List GitHub Projects V2 items filtered by Status column, ordered by position
9
10
  ascending. Returns machine-readable JSON.
@@ -3,7 +3,8 @@ import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.
3
3
  import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { parseArgs } from "node:util";
5
5
 
6
- const USAGE = `Usage: dev-loops project move --repo <owner/name> --project <number|id> --item <number|node-id> --to-column <name>
6
+ const USAGE = `Usage: dev-loops queue move --repo <owner/name> --project <number|id> --item <number|node-id> --to-column <name>
7
+ (dev-loops project move … is a back-compat alias)
7
8
 
8
9
  Move a GitHub Projects V2 item between Status columns.
9
10
 
@@ -4,10 +4,11 @@ import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { parseArgs } from "node:util";
5
5
 
6
6
  const USAGE = `Usage:
7
- dev-loops project reorder --repo <owner/name> --project <number|id> --item <number|node-id> [--after <number|node-id>]
8
- dev-loops project reorder move-to-top <ref> --repo <owner/name> --project <number|id>
9
- dev-loops project reorder move-after <ref> <after-ref> --repo <owner/name> --project <number|id>
10
- dev-loops project reorder order <ref1> <ref2> ... --repo <owner/name> --project <number|id>
7
+ dev-loops queue reorder --repo <owner/name> --project <number|id> --item <number|node-id> [--after <number|node-id>]
8
+ dev-loops queue reorder move-to-top <ref> --repo <owner/name> --project <number|id>
9
+ dev-loops queue reorder move-after <ref> <after-ref> --repo <owner/name> --project <number|id>
10
+ dev-loops queue reorder order <ref1> <ref2> ... --repo <owner/name> --project <number|id>
11
+ (dev-loops project reorder … is a back-compat alias)
11
12
 
12
13
  Reorder GitHub Projects V2 items by board position via updateProjectV2ItemPosition.
13
14
 
@@ -4,7 +4,8 @@ import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { syncBoardStatus } from "@dev-loops/core/loop/queue-board-sync";
5
5
  import { parseArgs } from "node:util";
6
6
 
7
- const USAGE = `Usage: dev-loops project sync-status --repo <owner/name> --item <number> --to-column <name>
7
+ const USAGE = `Usage: dev-loops queue sync-status --repo <owner/name> --item <number> --to-column <name>
8
+ (dev-loops project sync-status … is a back-compat alias)
8
9
 
9
10
  Sync a queued issue/PR's board Status column on a dev-loop transition (e.g.
10
11
  PR opened → "In Progress", merged → "Done"). Resolves the board from .devloops
@@ -166,9 +166,9 @@ The outer-loop checkpoint is the canonical re-attachment artifact.
166
166
 
167
167
  Start every wait seam with a detector refresh: `detect-copilot-loop-state.mjs --repo <owner/name> --pr <number>`.
168
168
 
169
- Allowed wait tools: `detect-copilot-loop-state.mjs` (one-shot), `dev-loops loop watch-cycle` (persistent), `copilot-pr-handoff.mjs --watch-status` (refresh after timeout/idle), `gh run watch <run-id> --repo <owner/name>` (CI with known run id). Otherwise exit and resume later from fresh detector call.
169
+ Allowed wait tools: `detect-copilot-loop-state.mjs` (one-shot), `dev-loops loop watch-cycle` (persistent), `copilot-pr-handoff.mjs --watch-status` (refresh after timeout/idle), `dev-loops loop watch-ci --repo <owner/name> --pr <number>` (provider-agnostic CI: CircleCI / Actions / external commit-status), `gh run watch <run-id> --repo <owner/name>` (Actions-only fallback when the run id is known). Otherwise exit and resume later from fresh detector call.
170
170
 
171
- Practical rules: do not poll manually. `waiting_for_copilot_review` → `run-watch-cycle.mjs` or report-and-resume. `waiting_for_ci` with pending/none CI → `gh run watch <run-id> --repo <owner/name>` (known run id) or report-and-resume. Bounded CI exception: zero current-head suites + previous-head green + local `npm run verify` passed → rerun detector with `--local-validation-head-sha` for `crediblyGreen` promotion. `ciStatus=failure` → stop/fix, never wait.
171
+ Practical rules: do not poll manually. `waiting_for_copilot_review` → `run-watch-cycle.mjs` or report-and-resume. `waiting_for_ci` with pending/none CI → `dev-loops loop watch-ci --repo <owner/name> --pr <number>` (provider-agnostic; covers CircleCI / Actions / external commit-status) or report-and-resume; `gh run watch <run-id>` is an Actions-only fallback. `dev-loops loop watch-cycle` also auto-routes a `waiting_for_ci` boundary to this CI watcher. Bounded CI exception: zero current-head suites + previous-head green + local `npm run verify` passed → rerun detector with `--local-validation-head-sha` for `crediblyGreen` promotion. `ciStatus=failure` → stop/fix, never wait.
172
172
 
173
173
  Preferred approach:
174
174
  - route decisions through `copilot-pr-handoff.mjs` output; enter watcher only on `action: "watch"` with `watchEntryConfirmed=true`; prefer `dev-loops loop watch-cycle` for deterministic handoff → watch
@@ -251,7 +251,7 @@ When unresolved feedback exists, use a narrow follow-up loop:
251
251
  - if that local validation is still known red, continue remediation instead of re-requesting Copilot
252
252
  - after a fix push advances the PR head SHA, re-run `detect-copilot-loop-state.mjs` for the new head and apply the [Copilot CI Status Contract](../docs/copilot-ci-status-contract.md). Previous-head CI is stale; only current-head results unblock CI-dependent steps. if GitHub CI/checks for the updated head are known red for a fixable issue, continue remediation instead of re-requesting Copilot. only once the updated head is green or credibly green, explicitly re-request Copilot review for the new head. Always use `request-copilot-review.mjs` — never `gh api POST repos/.../requested_reviewers` directly.
253
253
  - only enter a wait/watch loop if the request result is confirmed as `requested` or `already-requested`
254
- - for `requested` / `already-requested`, immediately re-baseline with `detect-copilot-loop-state.mjs`; if the returned state is `waiting_for_copilot_review`, use `dev-loops loop watch-cycle` or stop/resume later, and if the returned state is `waiting_for_ci`, use `gh run watch` for a known run id or stop/resume later after that single detector refresh
254
+ - for `requested` / `already-requested`, immediately re-baseline with `detect-copilot-loop-state.mjs`; if the returned state is `waiting_for_copilot_review`, use `dev-loops loop watch-cycle` or stop/resume later, and if the returned state is `waiting_for_ci`, use `dev-loops loop watch-ci` (provider-agnostic CI wait; `gh run watch` is an Actions-only fallback) or stop/resume later after that single detector refresh
255
255
  - if the request result is `unavailable`, report that limitation and stop unless the user explicitly wants passive waiting anyway
256
256
  - if the request command fails unexpectedly, stop and report the error rather than sleeping and hoping for a new review
257
257
  13. after a confirmed re-requested Copilot pass, refresh PR thread state again before reporting completion; if fresh Copilot threads exist, return to this follow-up loop rather than stopping at `review requested`
@@ -322,7 +322,7 @@ The canonical checkpoint verdict comment contract is [Gate Review Comment Contra
322
322
  - **CI prerequisite:** resolve the draft gate config first (`resolveGateConfig(config, "draft")`). When `requireCi=true` (default), wait for green current-head CI before entering `draft_gate`. When `requireCi=false`, the draft gate may proceed without green CI. This draft-only override does **not** relax `pre_approval_gate`; final approval and merge readiness still require green current-head CI.
323
323
  - **Pass criteria:** all configured draft gate angles pass; all findings at severities in `blockCleanOnFindingSeverities` are addressed; validation passes; no unrelated files are included.
324
324
  - **Next step after passing:** mark the PR ready for review.
325
- - **Board status sync (best-effort, after ready-for-review):** once the PR is created/marked ready for review, sync the linked issue's board item to "In Progress" so the queue board reflects active work without a manual `add-queue-item --status`:
325
+ - **Board status sync (best-effort, after ready-for-review):** once the PR is created/marked ready for review, sync the linked issue's board item to "In Progress" so the queue board reflects active work without a manual `dev-loops queue sync-status --to-column <col>`:
326
326
  ```sh
327
327
  node <resolved-skill-scripts>/projects/sync-item-status.mjs --repo <owner/name> --item <linked-issue> --to-column "In Progress"
328
328
  ```
@@ -370,6 +370,8 @@ See [Merge Preconditions](../docs/merge-preconditions.md). Verify: zero unresolv
370
370
  After merge-ready preconditions pass, verify [Merge Preconditions](../docs/merge-preconditions.md) authoritatively before reporting merge-ready. Stop at the human approval checkpoint by default. Cross-check via `dev-loops gate capture-threads` (not prose assertion).
371
371
  Follow [Merge Preconditions](../docs/merge-preconditions.md): stop at `waiting_for_merge_authorization` after approval unless merge explicitly authorized. Run pre-merge gate evidence check before any `gh pr merge`.
372
372
 
373
+ When `approval.humanHandoff.enabled` is set, don't just park silently at this stop: run `dev-loops gate offer-human-handoff --repo <owner/name> --pr <number>` to surface candidate reviewers/assignees, then **offer** them to the operator. Only on operator confirmation, route the PR with `--assign <login>` / `--request-review <login>`. This is OFFER-only — never auto-assign. See the `approval.humanHandoff` section in [Merge Preconditions](../docs/merge-preconditions.md); it pairs with `autonomy.humanMergeOnly`.
374
+
373
375
  ### Mechanical pre-merge gate evidence check
374
376
 
375
377
  Immediately before any `gh pr merge`, run:
@@ -400,15 +402,15 @@ Do not report completion or advance to the next PR queue item until `.pi/dev-loo
400
402
 
401
403
  ### Post-merge board archive (best-effort)
402
404
 
403
- After the retrospective checkpoint write, optionally tidy the queue board locally:
405
+ After the retrospective checkpoint write, run the post-merge board archive as a standard step of the post-merge hook (see [Merge Preconditions](../docs/merge-preconditions.md) "Post-merge"):
404
406
 
405
407
  ```sh
406
- node <resolved-skill-scripts>/projects/archive-done-items.mjs --repo <owner/name>
408
+ node <resolved-skill-scripts>/projects/archive-done-items.mjs --repo <owner/name> || true
407
409
  ```
408
410
 
409
411
  Board and threshold resolve from `.devloops` (`queue.projectNumber`/`queue.boardTitle`, `queue.archiveOlderThanDays`, default 7d), using local `gh` auth — no CI, cron, or PAT. This step is best-effort and NON-FATAL: ignore any failure and never let it block the merge or the retrospective.
410
412
 
411
- Alongside the archive step, sync the linked issue's board item to "Done" so the queue board reflects the merge without a manual `add-queue-item --status`:
413
+ Alongside the archive step, sync the linked issue's board item to "Done" so the queue board reflects the merge without a manual `dev-loops queue sync-status --to-column <col>`:
412
414
 
413
415
  ```sh
414
416
  node <resolved-skill-scripts>/projects/sync-item-status.mjs --repo <owner/name> --item <linked-issue> --to-column "Done"
@@ -27,7 +27,7 @@ Required installed runtime contract docs are shared bundled copies under `../doc
27
27
 
28
28
  The main agent must **always** dispatch the `dev-loop` async subagent for any dev-loop work.
29
29
  Do not run `dev-loops loop startup` or any startup resolver in the main agent.
30
- For async-required routes (config `workflow.asyncStartMode`, default `required`) the resolver needs an async run-id marker (`DEVLOOPS_RUN_ID`, or the `PI_SUBAGENT_RUN_ID` alias) that the Pi harness injects when it dispatches the async subagent; under the Claude Code harness the requirement is relaxed automatically (no marker needed). The startup resolver also runs without a marker for non-async routes. Regardless, only the `dev-loop` subagent runs it — never the main agent.
30
+ For async-required routes (config `workflow.asyncStartMode`, default `required`) the resolver needs an async run-id marker (`DEVLOOPS_RUN_ID`) that the harness injects when it dispatches the async subagent; under the Claude Code harness the requirement is relaxed automatically (no marker needed). The startup resolver also runs without a marker for non-async routes. Regardless, only the `dev-loop` subagent runs it — never the main agent.
31
31
  <!-- /pi-only -->
32
32
 
33
33
  ### Resolve authoritative state
@@ -94,7 +94,7 @@ Do not preload route packs before the resolver selects the strategy.
94
94
 
95
95
  ## Async dispatch
96
96
 
97
- **Async dispatch rule (enforced):** the resolver fails closed for GitHub-first strategies when `canonicalStateSummary.requiresAsyncDispatch` is `true` (default `required` mode) — inline invocation without an async run-id marker (`DEVLOOPS_RUN_ID`, or the `PI_SUBAGENT_RUN_ID` alias) is rejected for those routes. Under the Claude Code harness this requirement is relaxed automatically. See [Startup procedure](#startup-procedure).
97
+ **Async dispatch rule (enforced):** the resolver fails closed for GitHub-first strategies when `canonicalStateSummary.requiresAsyncDispatch` is `true` (default `required` mode) — inline invocation without an async run-id marker (`DEVLOOPS_RUN_ID`) is rejected for those routes. Under the Claude Code harness this requirement is relaxed automatically. See [Startup procedure](#startup-procedure).
98
98
 
99
99
 
100
100
  ## Fallback gate-comment poster
@@ -11,7 +11,7 @@ Canonical owner for anti-pattern guidance across all workflow families.
11
11
  5. **Duplicate worktree paths**: Check `git worktree list` before creating new worktrees; reuse existing matching worktrees.
12
12
  6. **Main-checkout mutation**: Reserve main checkout for inspection/control; use `tmp/worktrees/` paths for mutation work.
13
13
  7. **Spelunking tooling internals instead of using the public surface**: Do not read installed package internals, scan tooling source, or run ad-hoc scripts to understand a tool's behavior. Use the CLI, its `--help` subcommands, and `skills/docs/`. Read tool source only when the task is to inspect or change that tool, or when a concrete failure path is inside it and no public CLI/docs path exists. Once a failure is concrete, search changed files for the exact pattern — don't run duplicate broad searches.
14
- 8. **Hand-editing the queue file when a board is configured**: When a GitHub Projects board is configured (`queue.projectNumber`/`queue.boardTitle` in `.devloops`), the board is the authoritative queue MEMBERSHIP and ordering source — not just status. Add work via the board (`dev-loops project add ... --status "Next Up"`), not by hand-editing `.pi/dev-loop-queue.json`; the queue runner reconciles board `Next Up` items into queue entries before each run. See the operator guides under `docs/` (queue board usage/setup) for the workflow.
14
+ 8. **Hand-editing the queue file when a board is configured**: When a GitHub Projects board is configured (`queue.projectNumber`/`queue.boardTitle` in `.devloops`), the board is the authoritative queue MEMBERSHIP and ordering source — not just status. Add work via the board (`dev-loops queue add ... --column "Next Up"`), not by hand-editing `.pi/dev-loop-queue.json`; the queue runner reconciles board `Next Up` items into queue entries before each run. See the operator guides under `docs/` (queue board usage/setup) for the workflow.
15
15
 
16
16
  ## Light mode exception
17
17
 
@@ -51,7 +51,7 @@ For tracker-first MVP `story -> PR -> tracker sync` work, the source-repo refere
51
51
 
52
52
  4. Branch on the detector output instead of inventing a polling loop:
53
53
  - `state=waiting_for_copilot_review` with `snapshot.copilotReviewOnCurrentHead=false`: do **not** poll manually; either run `node <resolved-skill-scripts>/loop/run-watch-cycle.mjs --repo <owner/name> --pr <number>` for persistent async waiting or report the wait state and resume later after the single detector call
54
- - `state=waiting_for_ci` with `snapshot.ciStatus` in `{ "pending", "none" }`: do **not** poll manually by default; use `gh run watch <run-id> --repo <owner/name>` when the current-head run id is already known, otherwise report pending CI and resume later after the single detector refresh. Bounded exception: if GitHub created zero current-head check suites/statuses, the previous head rollup was green, and local `npm run verify` already passed for the same current head, rerun `detect-copilot-loop-state.mjs` with `--local-validation-head-sha <current-head-sha>` so the detector can promote that exact zero-suite case to `snapshot.ciStatus="crediblyGreen"` instead of waiting forever on raw `none`.
54
+ - `state=waiting_for_ci` with `snapshot.ciStatus` in `{ "pending", "none" }`: do **not** poll manually by default; use `dev-loops loop watch-ci --repo <owner/name> --pr <number>` to block-wait on the combined check/status state (provider-agnostic — CircleCI, GitHub Actions, and any external commit-status / check-run). `gh run watch <run-id>` is an Actions-only fallback when the current-head run id is already known and you know the gating checks are GitHub Actions. Either way, on `timeout`/`changed` report pending CI and resume later after the single detector refresh. Bounded exception: if GitHub created zero current-head check suites/statuses, the previous head rollup was green, and local `npm run verify` already passed for the same current head, rerun `detect-copilot-loop-state.mjs` with `--local-validation-head-sha <current-head-sha>` so the detector can promote that exact zero-suite case to `snapshot.ciStatus="crediblyGreen"` instead of waiting forever on raw `none`.
55
55
  - `snapshot.ciStatus="failure"` remains a stop/fix state, never a wait loop
56
56
 
57
57
  5. For reviewer-side draft-review work, run `node <resolved-skill-scripts>/loop/detect-reviewer-loop-state.mjs --repo <owner/name> --pr <number> [--reviewer-login <login>] [--local-state <path>]`.
@@ -224,6 +224,10 @@ gh pr edit <pr-number> --repo <resolved-repo> --title "..." --body-file <body-fi
224
224
  gh pr ready <pr-number> --repo <resolved-repo>
225
225
  gh pr review <pr-number> --repo <resolved-repo> --approve --body "..."
226
226
  node <resolved-skill-scripts>/github/detect-checkpoint-evidence.mjs --repo <resolved-repo> --pr <pr-number>
227
+ ```
228
+
229
+ The merge itself is gated, not implied by the lines above. Before any `gh pr merge`, follow [Merge Preconditions](./merge-preconditions.md): all preconditions (green CI, clean `draft_gate` + current-head `pre_approval_gate`, zero unresolved threads, explicit merge authorization) must hold. Critically, when `autonomy.humanMergeOnly: true` is set, merge is a fixed human-only action — `resolveEffectiveMergeAuthorized` fails closed, the agent **never** runs `gh pr merge`, and instead reports merge-ready + gate evidence and hands off to a human. Only when **not** `humanMergeOnly` and merge is explicitly authorized may the agent run:
230
+ ```sh
227
231
  gh pr merge <pr-number> --repo <resolved-repo> --squash --delete-branch
228
232
  ```
229
233
 
@@ -32,9 +32,73 @@ A marker is allowed only while the PR is still in draft; it must be removed befo
32
32
  - `"Merge authorized if gates green"` is valid explicit authorization
33
33
  - Implied approval from prior turns is not sufficient
34
34
 
35
+ ### `autonomy.humanMergeOnly` — fixed human-only merge (non-overridable)
36
+
37
+ When a repo sets `autonomy.humanMergeOnly: true` in `.devloops`, merge is a fixed
38
+ human action and this authorization step is **non-overridable**:
39
+
40
+ - `resolveAutonomyStopAt` always includes `merge`, even if `stopAt` is set to `[]`.
41
+ - The effective merge authorization fails closed: `resolveEffectiveMergeAuthorized`
42
+ returns `false` regardless of the `mergeAuthorized` envelope flag or an explicit
43
+ "merge" instruction. The lifecycle resolver therefore never advances to the merge
44
+ state and parks at the `pre_approval_gate` human-merge handoff.
45
+ - The agent still runs the full mechanical pre-merge evidence check and reports
46
+ merge-ready + gate evidence, then hands off to a human to perform `gh pr merge`.
47
+ The agent **never** runs `gh pr merge` itself.
48
+
49
+ This makes human-gated merge an enforced repo invariant, not a per-run default an
50
+ explicit instruction can unlock.
51
+
52
+ ### `approval.humanHandoff` — offer to assign a human at the handoff (opt-in)
53
+
54
+ When a repo sets `approval.humanHandoff.enabled: true` in `.devloops`, the loop
55
+ does not just park silently at the human-merge stop — at the
56
+ `pre_approval_gate` / `waiting_for_merge_authorization` boundary it **offers**
57
+ to route the PR to a named human (pairs with `autonomy.humanMergeOnly`: when
58
+ human-merge is enforced, the handoff names who should take it). Disabled by
59
+ default; no candidate sourcing when disabled.
60
+
61
+ ```yaml
62
+ approval:
63
+ humanHandoff:
64
+ enabled: true
65
+ candidatesFrom: [codeowners, recent-committers]
66
+ assignees: [alice, bob]
67
+ ```
68
+
69
+ At the handoff boundary, resolve and surface candidates:
70
+
71
+ ```sh
72
+ dev-loops gate offer-human-handoff --repo <owner/name> --pr <number>
73
+ ```
74
+
75
+ This prints the deduped, ordered candidate list (priority:
76
+ `assignees` > `codeowners` for the touched paths (last-match-wins) >
77
+ `recent-committers` to those paths, PR author/bots excluded). It assigns no one.
78
+
79
+ **OFFER-only — the operator confirms the assignee** (auto-assigning without
80
+ confirmation is a non-goal). On confirmation, perform the action:
81
+
82
+ ```sh
83
+ dev-loops gate offer-human-handoff --repo <owner/name> --pr <number> \
84
+ --assign <login> --request-review <login>
85
+ ```
86
+
87
+ which runs `gh pr edit --add-assignee` / `--add-reviewer` for the confirmed
88
+ human(s).
89
+
35
90
  ## Post-merge
36
91
 
37
- - Remove merged worktree: `git worktree remove --force <path> && git worktree prune`
92
+ - Remove merged worktree (canonical): `node scripts/loop/cleanup-worktree.mjs --repo-root <main> (--issue <n> | --pr <n>)`.
93
+ It resolves the namespaced path, runs `git worktree remove --force <path> && git worktree prune` (the underlying
94
+ mechanism) from the main checkout, and refuses any path not under `tmp/worktrees/dev-loops/`. See
95
+ [worktree guidance](../../docs/worktree-guidance.md).
96
+ - Archive long-done queue items (operator-induced, NOT a cron): `node scripts/projects/archive-done-items.mjs --repo <owner/name> || true`.
97
+ Runs as part of this post-merge hook. It applies the configured `queue.archiveOlderThanDays` (default `7d`) and archives
98
+ board items whose issue/PR has been closed at least that long. Best-effort: run it as a standard post-merge step but ignore
99
+ any non-zero exit (a successful run that finds nothing to archive exits 0; a board/config-resolution/API error exits
100
+ non-zero, which the `|| true` masks) — a failure here must never block merge completion. See
101
+ [projects queue usage](../../docs/projects-queue-usage.md).
38
102
  - Clean up stale branches
39
103
 
40
104
  ## Cross-references
@@ -10,6 +10,7 @@ Canonical owner for agent stop / wait / block conditions across all workflow fam
10
10
  | `done` lifecycle state | all | Terminal stop |
11
11
  | `approval_ready` without explicit merge auth | `final_approval` | Stop at approval gate |
12
12
  | `merge_ready` without explicit merge auth | all | Stop at `waiting_for_merge_authorization` |
13
+ | `autonomy.humanMergeOnly: true` | all | Always stop at merge for human action, even with explicit merge auth — agent never runs `gh pr merge` (see [Merge preconditions](merge-preconditions.md)) |
13
14
  | Ambiguous / contradictory state | all | Fail closed to `needs_reconcile` |
14
15
  | Missing authoritative startup inputs | `dev-loop` | Fail closed |
15
16
 
@@ -1,7 +1,7 @@
1
1
  # Tracker-First Story-to-PR Contract
2
2
 
3
3
  This document defines the adapter-agnostic MVP contract for the tracker-first
4
- story-to-PR workflow in `pi-dev-loops`.
4
+ story-to-PR workflow in `dev-loops`.
5
5
 
6
6
  **MVP invariant: one tracker work item → one GitHub PR.**
7
7
 
@@ -43,9 +43,9 @@ are out of scope for this slice.
43
43
  | PR lifecycle facts | GitHub | Draft / ready-for-review, open / merged / closed, branch, head SHA |
44
44
  | PR review and CI facts | GitHub | Reviewer assignments, review states, check-run results, merge status |
45
45
  | Decision content | ADR / RFC artifacts (when linked) | Architecture decisions and RFCs referenced from PR body |
46
- | PR projection and reverse-sync logic | `pi-dev-loops` | Title/body/label generation rules, state mapping, sync triggers |
46
+ | PR projection and reverse-sync logic | `dev-loops` | Title/body/label generation rules, state mapping, sync triggers |
47
47
 
48
- `pi-dev-loops` **does not** become the canonical owner of any business fields.
48
+ `dev-loops` **does not** become the canonical owner of any business fields.
49
49
  It provides projection and sync logic only.
50
50
 
51
51
  ## 3. PR Projection Contract
@@ -234,9 +234,9 @@ This contract is intentionally narrower than the parent epics:
234
234
 
235
235
  ## 7. Related
236
236
 
237
- - Parent workflow-family epic: [mfittko/pi-dev-loops#17](https://github.com/mfittko/pi-dev-loops/issues/17)
238
- - Umbrella artifact model epic: [mfittko/pi-dev-loops#19](https://github.com/mfittko/pi-dev-loops/issues/19)
239
- - This contract (first implementable slice): [mfittko/pi-dev-loops#21](https://github.com/mfittko/pi-dev-loops/issues/21)
237
+ - Parent workflow-family epic: [mfittko/dev-loops#17](https://github.com/mfittko/dev-loops/issues/17)
238
+ - Umbrella artifact model epic: [mfittko/dev-loops#19](https://github.com/mfittko/dev-loops/issues/19)
239
+ - This contract (first implementable slice): [mfittko/dev-loops#21](https://github.com/mfittko/dev-loops/issues/21)
240
240
  - Copilot loop state graph: [Copilot Loop State Graph](../../docs/copilot-loop-state-graph.md)
241
241
  - Reviewer loop state graph: [Reviewer Loop State Graph](../../docs/reviewer-loop-state-graph.md)
242
242
 
@@ -72,7 +72,13 @@ For the `local_implementation` strategy, before any planning or implementation m
72
72
  node scripts/loop/pre-flight-gate.mjs --expected-branch <working-branch> --check-subagents
73
73
  ```
74
74
 
75
- Before creating or reusing a worktree for local implementation, always run `git fetch origin` first. Always create worktrees from a freshly fetched `origin/main`. The fetched `origin/main` is the authoritative base for all worktree creation.
75
+ Before creating or reusing a worktree for local implementation, use the canonical lifecycle entrypoint, which fetches the base remote, then creates-or-reuses the worktree at its namespaced path and provisions it in one step:
76
+
77
+ ```sh
78
+ node scripts/loop/ensure-worktree.mjs --repo-root <main> --issue <n>
79
+ ```
80
+
81
+ This creates (or reuses) the worktree at `tmp/worktrees/dev-loops/<kind>-<n>` from a freshly fetched `origin/main` (the authoritative base for all worktree creation) and provisions its gitignored runtime files. Raw `git worktree add -b <branch> tmp/worktrees/dev-loops/<kind>-<n> origin/main` is the underlying mechanism `ensure-worktree.mjs` runs for you. See [worktree guidance](../../docs/worktree-guidance.md).
76
82
 
77
83
  This validates:
78
84
  - Worktree isolation (current directory is under `tmp/worktrees/`)
@@ -85,7 +91,7 @@ Note: `--check-subagents` is advisory — it reports availability but does not b
85
91
 
86
92
  This gate does **not** apply to other routed strategies (`copilot_pr_followup`, `external_pr_followup`, `reviewer_fixer`, `wait_watch`, `final_approval`, `issue_intake`). Those strategies have their own execution rules and may edit code from any checkout as needed.
87
93
 
88
- Development-only bypass (`PI_PREFLIGHT_BYPASS=1`) exists for testing the gate itself but must not be used in production workflow runs. The bypass variable is a testing convenience, not an operational escape hatch.
94
+ Development-only bypass (`DEVLOOPS_PREFLIGHT_BYPASS=1`) exists for testing the gate itself but must not be used in production workflow runs. The bypass variable is a testing convenience, not an operational escape hatch.
89
95
 
90
96
  ## Narrow failure-triage fast path
91
97