dev-loops 0.1.3 → 0.2.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 (75) hide show
  1. package/.claude/.claude-plugin/plugin.json +9 -0
  2. package/.claude/agents/dev-loop.md +69 -0
  3. package/.claude/agents/developer.md +35 -0
  4. package/.claude/agents/docs.md +31 -0
  5. package/.claude/agents/fixer.md +51 -0
  6. package/.claude/agents/quality.md +26 -0
  7. package/.claude/agents/refiner.md +85 -0
  8. package/.claude/agents/review.md +61 -0
  9. package/.claude/hooks/_hook-io.mjs +38 -0
  10. package/.claude/hooks/hooks.json +35 -0
  11. package/.claude/hooks/post-tool-use-merge.mjs +23 -0
  12. package/.claude/hooks/pre-tool-use-bash-gate.mjs +73 -0
  13. package/.claude/hooks/pre-tool-use-write-guard.mjs +55 -0
  14. package/.claude/skills/copilot-pr-followup/SKILL.md +376 -0
  15. package/.claude/skills/dev-loop/SKILL.md +140 -0
  16. package/.claude/skills/dev-loop/templates/bootstrap-agents.md +26 -0
  17. package/.claude/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
  18. package/.claude/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
  19. package/.claude/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
  20. package/.claude/skills/dev-loop/templates/dev-mode-review.md +17 -0
  21. package/.claude/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
  22. package/.claude/skills/dev-loop/templates/merged-phase-plan.md +19 -0
  23. package/.claude/skills/dev-loop/templates/phase-doc.md +27 -0
  24. package/.claude/skills/dev-loop/templates/phase-summary.md +13 -0
  25. package/.claude/skills/dev-loop/templates/phase-variant.md +15 -0
  26. package/.claude/skills/dev-loop/templates/retrospective.md +11 -0
  27. package/.claude/skills/dev-loop/templates/review.md +32 -0
  28. package/.claude/skills/dev-loop/templates/ui-vision-review.md +55 -0
  29. package/.claude/skills/docs/acceptance-criteria-verification.md +21 -0
  30. package/.claude/skills/docs/anti-patterns.md +21 -0
  31. package/.claude/skills/docs/artifact-authority-contract.md +119 -0
  32. package/.claude/skills/docs/confirmation-rules.md +28 -0
  33. package/.claude/skills/docs/copilot-ci-status-contract.md +52 -0
  34. package/.claude/skills/docs/copilot-loop-operations.md +233 -0
  35. package/.claude/skills/docs/debt-remediation-contract.md +107 -0
  36. package/.claude/skills/docs/entrypoint-strategies.md +115 -0
  37. package/.claude/skills/docs/epic-tree-refinement-procedure.md +234 -0
  38. package/.claude/skills/docs/issue-intake-procedure.md +235 -0
  39. package/.claude/skills/docs/main-agent-contract.md +85 -0
  40. package/.claude/skills/docs/merge-preconditions.md +29 -0
  41. package/.claude/skills/docs/pr-lifecycle-contract.md +209 -0
  42. package/.claude/skills/docs/public-dev-loop-contract.md +497 -0
  43. package/.claude/skills/docs/retrospective-checkpoint-contract.md +159 -0
  44. package/.claude/skills/docs/stop-conditions.md +29 -0
  45. package/.claude/skills/docs/structural-quality.md +42 -0
  46. package/.claude/skills/docs/tracker-first-loop-state.md +281 -0
  47. package/.claude/skills/docs/validation-policy.md +27 -0
  48. package/.claude/skills/docs/workflow-handoff-contract.md +135 -0
  49. package/.claude/skills/final-approval/SKILL.md +18 -0
  50. package/.claude/skills/local-implementation/SKILL.md +636 -0
  51. package/CHANGELOG.md +51 -0
  52. package/README.md +16 -0
  53. package/agents/dev-loop.agent.md +4 -0
  54. package/cli/index.mjs +5 -5
  55. package/extension/checks.ts +18 -29
  56. package/extension/harness-types.ts +47 -0
  57. package/extension/index.ts +10 -8
  58. package/extension/pi-extension-adapter.ts +64 -0
  59. package/extension/post-merge-update.ts +69 -190
  60. package/package.json +16 -9
  61. package/scripts/README.md +6 -3
  62. package/scripts/claude/generate-claude-assets.mjs +175 -0
  63. package/scripts/claude/headless-dev-loop.mjs +89 -0
  64. package/scripts/claude/headless-info-smoke.mjs +90 -0
  65. package/scripts/github/_review-thread-mutations.mjs +3 -0
  66. package/scripts/github/reply-resolve-review-threads.mjs +3 -3
  67. package/scripts/github/upsert-checkpoint-verdict.mjs +4 -3
  68. package/scripts/loop/_pr-runner-coordination.mjs +2 -1
  69. package/scripts/loop/copilot-pr-handoff.mjs +3 -2
  70. package/scripts/loop/detect-stale-runner.mjs +5 -7
  71. package/scripts/loop/info.mjs +3 -2
  72. package/scripts/loop/inspect-run-viewer/server.mjs +2 -1
  73. package/scripts/loop/outer-loop.mjs +2 -2
  74. package/scripts/loop/pr-runner-coordination.mjs +4 -4
  75. package/skills/docs/main-agent-contract.md +19 -6
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Headless dev-loop entry for Claude Code (#775).
4
+ *
5
+ * Runs the dev-loop non-interactively via `claude -p` (the Claude Agent SDK headless path),
6
+ * with the repo's `.claude/settings.json` hooks (gate + read-only guard, #773) active. It mints
7
+ * a neutral DEVLOOPS_RUN_ID (CA2) and propagates it into the spawned `claude` env so the headless
8
+ * session is recognized as the dev-loop subagent context by the write-guard.
9
+ *
10
+ * Usage:
11
+ * node scripts/claude/headless-dev-loop.mjs [--issue <n> | --pr <n>] [--prompt <text>]
12
+ * [--claude-bin <path>] [--dry-run]
13
+ *
14
+ * --issue/--pr select the target; with neither, the prompt targets the current state.
15
+ * --prompt overrides the generated dev-loop prompt entirely.
16
+ * --claude-bin overrides the `claude` binary path.
17
+ * --dry-run prints the resolved command + the DEVLOOPS_RUN_ID without spawning `claude`
18
+ * (CI-safe; no API key / `claude` binary required).
19
+ */
20
+ import { spawnSync } from "node:child_process";
21
+ import path from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ import { ensureRunId } from "@dev-loops/core/loop/run-context";
25
+ import {
26
+ buildDevLoopPrompt,
27
+ buildHeadlessClaudeInvocation,
28
+ DEFAULT_CLAUDE_BIN,
29
+ } from "@dev-loops/core/claude/headless-entry";
30
+
31
+ function parseArgs(argv) {
32
+ const opts = { dryRun: false, claudeBin: DEFAULT_CLAUDE_BIN };
33
+ const requireValue = (name, i) => {
34
+ const v = argv[i + 1];
35
+ if (v === undefined || v.startsWith("--")) throw new Error(`${name} requires a value`);
36
+ return v;
37
+ };
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i];
40
+ if (a === "--dry-run") opts.dryRun = true;
41
+ else if (a === "--issue") { opts.issue = requireValue(a, i); i++; }
42
+ else if (a === "--pr") { opts.pr = requireValue(a, i); i++; }
43
+ else if (a === "--claude-bin") { opts.claudeBin = requireValue(a, i); i++; }
44
+ else if (a === "--prompt") { opts.prompt = requireValue(a, i); i++; }
45
+ else throw new Error(`unknown argument: ${a}`);
46
+ }
47
+ if (opts.issue != null && opts.pr != null) {
48
+ throw new Error("--issue and --pr are mutually exclusive");
49
+ }
50
+ return opts;
51
+ }
52
+
53
+ function main(argv) {
54
+ let opts;
55
+ try {
56
+ opts = parseArgs(argv);
57
+ } catch (error) {
58
+ process.stderr.write(JSON.stringify({ ok: false, error: error.message }) + "\n");
59
+ return 1;
60
+ }
61
+ const repoRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
62
+
63
+ // --dry-run is side-effect-free: mint a run id in-memory without persisting the state file.
64
+ const { runId } = ensureRunId({ env: process.env, root: opts.dryRun ? undefined : repoRoot });
65
+ const prompt = opts.prompt ?? buildDevLoopPrompt({ issue: opts.issue, pr: opts.pr });
66
+ const { command, args, env } = buildHeadlessClaudeInvocation({
67
+ prompt,
68
+ runId,
69
+ claudeBin: opts.claudeBin,
70
+ });
71
+
72
+ if (opts.dryRun) {
73
+ process.stdout.write(
74
+ JSON.stringify({ ok: true, dryRun: true, command, args, runId, DEVLOOPS_RUN_ID: env.DEVLOOPS_RUN_ID }, null, 2) + "\n",
75
+ );
76
+ return 0;
77
+ }
78
+
79
+ const res = spawnSync(command, args, { cwd: repoRoot, env, stdio: "inherit" });
80
+ if (res.error) {
81
+ process.stderr.write(
82
+ JSON.stringify({ ok: false, error: `failed to spawn ${command}: ${res.error.message}` }) + "\n",
83
+ );
84
+ return 127;
85
+ }
86
+ return res.status ?? 1;
87
+ }
88
+
89
+ process.exit(main(process.argv.slice(2)));
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Headless read-only dev-loop info smoke (#775).
4
+ *
5
+ * Exercises a read-only dev-loop info path non-interactively (no LLM, no interactive session,
6
+ * no `@earendil-works/pi-*`), so CI / the Docker image can verify the headless info surface.
7
+ * Parallel to the Pi Docker smoke (dual-harness). Exits 0 on success.
8
+ *
9
+ * Default: `dev-loops status` — a fully offline readiness snapshot that needs no GitHub auth
10
+ * (it reports "needs setup" rather than failing), so it is safe in a hermetic CI verify job.
11
+ *
12
+ * `--loop-info --issue <n>` (or `--pr <n>`) additionally runs `dev-loops loop info`, which
13
+ * queries GitHub and therefore needs read access (a token / `gh` auth). Opt-in: skipped by
14
+ * default so the smoke stays secret-free.
15
+ *
16
+ * Usage: node scripts/claude/headless-info-smoke.mjs [--loop-info --issue <n> | --pr <n>]
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ function parseArgs(argv) {
23
+ const opts = { loopInfo: false };
24
+ const requireValue = (name, i) => {
25
+ const v = argv[i + 1];
26
+ if (v === undefined || v.startsWith("--")) throw new Error(`${name} requires a value`);
27
+ return v;
28
+ };
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const a = argv[i];
31
+ if (a === "--loop-info") opts.loopInfo = true;
32
+ else if (a === "--issue") { opts.issue = requireValue(a, i); i++; }
33
+ else if (a === "--pr") { opts.pr = requireValue(a, i); i++; }
34
+ else throw new Error(`unknown argument: ${a}`);
35
+ }
36
+ if (opts.issue != null && opts.pr != null) {
37
+ throw new Error("--issue and --pr are mutually exclusive");
38
+ }
39
+ return opts;
40
+ }
41
+
42
+ function runCli(cliEntry, repoRoot, args) {
43
+ return spawnSync(process.execPath, [cliEntry, ...args], { cwd: repoRoot, encoding: "utf8" });
44
+ }
45
+
46
+ function main(argv) {
47
+ let opts;
48
+ try {
49
+ opts = parseArgs(argv);
50
+ } catch (error) {
51
+ process.stderr.write(JSON.stringify({ ok: false, error: error.message }) + "\n");
52
+ return 1;
53
+ }
54
+ const repoRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
55
+ const cliEntry = path.join(repoRoot, "cli", "index.mjs");
56
+
57
+ // Offline, secret-free read-only info path — the CI/Docker smoke.
58
+ const statusRes = runCli(cliEntry, repoRoot, ["status"]);
59
+ if (statusRes.status !== 0) {
60
+ process.stderr.write(
61
+ JSON.stringify({ ok: false, error: "headless `dev-loops status` smoke failed", status: statusRes.status, stderr: statusRes.stderr }) + "\n",
62
+ );
63
+ return statusRes.status ?? 1;
64
+ }
65
+ process.stdout.write(statusRes.stdout);
66
+
67
+ // Opt-in GitHub-backed info path (needs read access; not run by the hermetic CI smoke).
68
+ if (opts.loopInfo) {
69
+ if (opts.issue == null && opts.pr == null) {
70
+ process.stderr.write(
71
+ JSON.stringify({ ok: false, error: "--loop-info requires an explicit --issue <n> or --pr <n> target" }) + "\n",
72
+ );
73
+ return 1;
74
+ }
75
+ const target = opts.pr ? ["--pr", String(opts.pr)] : ["--issue", String(opts.issue)];
76
+ const infoRes = runCli(cliEntry, repoRoot, ["loop", "info", ...target]);
77
+ if (infoRes.status !== 0) {
78
+ process.stderr.write(
79
+ JSON.stringify({ ok: false, error: "headless `dev-loops loop info` smoke failed (needs GitHub read access)", status: infoRes.status, stderr: infoRes.stderr }) + "\n",
80
+ );
81
+ return infoRes.status ?? 1;
82
+ }
83
+ process.stdout.write(infoRes.stdout);
84
+ }
85
+
86
+ process.stdout.write(JSON.stringify({ ok: true, smoke: "headless-info", loopInfo: opts.loopInfo }) + "\n");
87
+ return 0;
88
+ }
89
+
90
+ process.exit(main(process.argv.slice(2)));
@@ -207,6 +207,9 @@ export function authorMatchesFilter(commentAuthorLogin, authorFilter) {
207
207
  if (normalizedLogin.length === 0 || normalizedFilter.length === 0) {
208
208
  return false;
209
209
  }
210
+ if (normalizedFilter.toLowerCase() === "all") {
211
+ return true;
212
+ }
210
213
  if (normalizedFilter.toLowerCase() === "copilot") {
211
214
  return isCopilotLogin(normalizedLogin) || normalizedLogin.toLowerCase() === "copilot";
212
215
  }
@@ -17,11 +17,11 @@ Required:
17
17
  --repo <owner/name> Repository slug (e.g. owner/repo)
18
18
  --pr <number> Pull request number
19
19
  Optional:
20
- --author <login> Match threads containing a comment from this author (default: Copilot)
20
+ --author <login> Match threads containing a comment from this author (default: all)
21
21
  --message <text> Reply body text; provide exactly one message source via --message or stdin
22
22
  --resolve Resolve each matched thread after the reply succeeds
23
23
  Output (stdout, JSON):
24
- { "ok": true, "repo": "owner/name", "pr": 17, "author": "Copilot", "resolve": true,
24
+ { "ok": true, "repo": "owner/name", "pr": 17, "author": "all", "resolve": true,
25
25
  "matchedThreadCount": 2, "repliedThreadCount": 2, "resolvedThreadCount": 2,
26
26
  "skippedThreadCount": 1, "results": [{ ... }] }
27
27
  Error output (stderr, JSON):
@@ -39,7 +39,7 @@ export function parseReplyResolveThreadsCliArgs(argv) {
39
39
  help: false,
40
40
  repo: undefined,
41
41
  pr: undefined,
42
- author: "Copilot",
42
+ author: "all",
43
43
  message: undefined,
44
44
  resolve: false,
45
45
  };
@@ -8,6 +8,7 @@ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
8
  import { loadPrGateCoordinationContext } from "../loop/detect-pr-gate-coordination-state.mjs";
9
9
  import { evaluatePrGateCoordination, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
10
10
  import { STATE } from "@dev-loops/core/loop/copilot-loop-state";
11
+ import { resolveRunId } from "@dev-loops/core/loop/run-context";
11
12
  import { claimRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
12
13
  import { detectStaleRunner } from "../loop/_stale-runner-detection.mjs";
13
14
  import { detectInternalOnly } from "../loop/detect-internal-only-pr.mjs";
@@ -452,7 +453,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
452
453
  // Root cause 1: allow resurrected sessions to claim ownership when the previous
453
454
  // run's coordination record is stale. Without this, a new run ID is rejected even
454
455
  // though the old run is dead, forcing manual file deletion.
455
- const envRunId = typeof env?.PI_SUBAGENT_RUN_ID === "string" ? env.PI_SUBAGENT_RUN_ID.trim() : "";
456
+ const envRunId = resolveRunId(env) ?? "";
456
457
  if (envRunId) {
457
458
  try {
458
459
  const staleCheck = await detectStaleRunner({ repo: options.repo, pr: options.pr, cwd: repoRoot });
@@ -606,7 +607,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
606
607
  if (existing) {
607
608
  const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody }, { env, ghCommand });
608
609
  // Post-update verification: verify the updated comment is visible via direct API fetch by comment ID.
609
- // PI_SUBAGENT_RUN_ID is set (production context).
610
+ // A run id is set (production context) — DEVLOOPS_RUN_ID, or the PI_SUBAGENT_RUN_ID alias.
610
611
  let updateVerificationWarning = null;
611
612
  if (envRunId) {
612
613
  let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, { env, ghCommand });
@@ -639,7 +640,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
639
640
  // comment is not yet returned by paginated list endpoints. A direct fetch
640
641
  // by comment ID confirms the comment is persisted, preventing the evidence
641
642
  // checker from falsely reporting "missing" and triggering a duplicate post.
642
- // Only active when PI_SUBAGENT_RUN_ID is set (production context).
643
+ // Only active when a run id is set (production context) — DEVLOOPS_RUN_ID or the PI_SUBAGENT_RUN_ID alias.
643
644
  let verified = true;
644
645
  let verificationWarning = null;
645
646
  if (envRunId) {
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { parseRepoSlugParts } from "@dev-loops/core/github/repo-slug";
4
+ import { resolveRunId } from "@dev-loops/core/loop/run-context";
4
5
  import {
5
6
  loadStateFile as loadSharedStateFile,
6
7
  saveStateFile as saveSharedStateFile,
@@ -581,7 +582,7 @@ export async function ensureAsyncRunnerOwnership({
581
582
  claimIfMissing = true,
582
583
  requireExisting = false,
583
584
  } = {}) {
584
- const runId = normalizeRunId(env?.PI_SUBAGENT_RUN_ID);
585
+ const runId = normalizeRunId(resolveRunId(env));
585
586
  if (runId === null) {
586
587
  return {
587
588
  ok: true,
@@ -2,6 +2,7 @@
2
2
  import { buildParseError, formatCliError, isCopilotLogin, isDirectCliRun, normalizeTimestamp } from "../_core-helpers.mjs";
3
3
  import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
4
4
  import { detectRepoSlug, parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
+ import { resolveRunId } from "@dev-loops/core/loop/run-context";
5
6
  import path from "node:path";
6
7
  import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
7
8
  import { autoDetectSnapshot } from "./detect-copilot-loop-state.mjs";
@@ -315,9 +316,9 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
315
316
  let interpretation = interpretLoopState(snapshot, refinementConfig);
316
317
 
317
318
  // Check for human comments since last subagent action
318
- // Only active in async subagent context (PI_SUBAGENT_RUN_ID set)
319
+ // Only active in async subagent context (DEVLOOPS_RUN_ID, or the PI_SUBAGENT_RUN_ID alias, set)
319
320
  let humanCommentCheck = { paused: false };
320
- if (env.PI_SUBAGENT_RUN_ID) {
321
+ if (resolveRunId(env)) {
321
322
  humanCommentCheck = await detectRecentHumanComments(
322
323
  { repo: options.repo, pr: options.pr, claimedAtMs: runnerOwnership?.activeRun?.claimedAt ? new Date(runnerOwnership.activeRun.claimedAt).getTime() : undefined },
323
324
  { env, ghCommand },
@@ -3,6 +3,7 @@ import process from "node:process";
3
3
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
4
4
  import { parsePrNumber, requireOptionValue } from "../_cli-primitives.mjs";
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { resolveRunId as resolveEnvRunId } from "@dev-loops/core/loop/run-context";
6
7
  import {
7
8
  detectStaleRunner,
8
9
  STALE_RUNNER_ERROR,
@@ -19,9 +20,9 @@ Optional:
19
20
  Override the staleness threshold (default 30 minutes,
20
21
  or $PI_DEV_LOOP_STALE_RUNNER_MAX_AGE_MS).
21
22
  --run-id <id> Override the active run id (default: read from
22
- PI_SUBAGENT_RUN_ID). When supplied, the detector
23
- additionally verifies the current run id is still
24
- the active owner.
23
+ DEVLOOPS_RUN_ID, falling back to the PI_SUBAGENT_RUN_ID
24
+ alias). When supplied, the detector additionally verifies
25
+ the current run id is still the active owner.
25
26
  Output (stdout, JSON; always includes staleRunnerCheck):
26
27
  {
27
28
  "ok": true,
@@ -100,10 +101,7 @@ function resolveRunId(explicitRunId, env) {
100
101
  if (typeof explicitRunId === "string" && explicitRunId.trim().length > 0) {
101
102
  return explicitRunId.trim();
102
103
  }
103
- if (typeof env?.PI_SUBAGENT_RUN_ID === "string" && env.PI_SUBAGENT_RUN_ID.trim().length > 0) {
104
- return env.PI_SUBAGENT_RUN_ID.trim();
105
- }
106
- return null;
104
+ return resolveEnvRunId(env);
107
105
  }
108
106
  function buildStaleRunnerCheck(detection) {
109
107
  if (detection.status === "no_owner_record") {
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
6
6
  import { requireOptionValue, parsePositiveInteger } from "../_cli-primitives.mjs";
7
7
  import { detectRepoSlug, normalizeRepoSlug } from "@dev-loops/core/github/repo-slug";
8
+ import { runContextEnv } from "@dev-loops/core/loop/run-context";
8
9
 
9
10
  // REPO_ROOT resolves to the git repo root (scripts/loop/info.mjs → scripts/ → repo/)
10
11
  const REPO_ROOT = path.resolve(fileURLToPath(new URL("..", import.meta.url)), "..");
@@ -186,12 +187,12 @@ function buildPrInfo(prNumber, repo, cwd) {
186
187
  function buildIssueInfo(issueNumber, repo, cwd) {
187
188
  const issueData = ghJson(["issue", "view", String(issueNumber), "--repo", repo, "--json", "number,title,body,state,labels,assignees,milestone,url"], cwd);
188
189
 
189
- // Run startup resolver with synthetic PI_SUBAGENT_RUN_ID to avoid
190
+ // Run startup resolver with a synthetic neutral run id to avoid
190
191
  // async-start contract rejection for GitHub-first issue routes.
191
192
  let startupBundle = null;
192
193
  try {
193
194
  const startupScript = path.join(REPO_ROOT, "scripts/loop/resolve-dev-loop-startup.mjs");
194
- const env = { ...process.env, PI_SUBAGENT_RUN_ID: "info-readonly-placeholder" };
195
+ const env = { ...process.env, ...runContextEnv("info-readonly-placeholder") };
195
196
  const raw = execFileSync(process.execPath, [startupScript, "--issue", String(issueNumber)], {
196
197
  cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env,
197
198
  });
@@ -27,6 +27,7 @@ import {
27
27
  } from "../_inspect-run-viewer-adapter.mjs";
28
28
  import { dedupeRepoSlugOptions, repoSlugEquals } from "@dev-loops/core/github/repo-slug";
29
29
  import { buildDevLoopHandoffEnvelope } from "@dev-loops/core/loop/handoff-envelope";
30
+ import { runContextEnv } from "@dev-loops/core/loop/run-context";
30
31
  import { loadDevLoopConfig } from "@dev-loops/core/config";
31
32
 
32
33
  const execFile = promisify(execFileCallback);
@@ -100,7 +101,7 @@ async function runResolverForTarget(target, { repoRoot = process.cwd() } = {}) {
100
101
  throw new Error("Cannot resolve handoff envelope: target repo is required");
101
102
  }
102
103
  const args = ["scripts/loop/resolve-dev-loop-startup.mjs", "--pr", String(target.pr)];
103
- const env = { ...process.env, PI_SUBAGENT_RUN_ID: "viewer-operator-tool" };
104
+ const env = { ...process.env, ...runContextEnv("viewer-operator-tool") };
104
105
  const { stdout, stderr } = await execFile("node", args, { cwd: repoRoot, timeout: 30000, env });
105
106
  try {
106
107
  return JSON.parse(stdout);
@@ -68,9 +68,9 @@ Stop reasons:
68
68
  HEAD does not match PR head commit
69
69
  unknown_state Unrecognized combined state
70
70
  Async-start contract:
71
- This loop must run within a visible Pi-managed async context when
71
+ This loop must run within a visible harness-managed async context when
72
72
  workflow.asyncStartMode is set to required (default). It fails closed unless
73
- PI_SUBAGENT_RUN_ID is set, to prevent hidden detached-process fallback
73
+ DEVLOOPS_RUN_ID (or the PI_SUBAGENT_RUN_ID alias) is set, to prevent hidden detached-process fallback
74
74
  (nohup, disowned shell jobs, etc.). Snapshot/test input mode
75
75
  (both --copilot-input and --reviewer-input) is exempt. Any relaxed
76
76
  async-start posture is maintainer-controlled repository policy, not an
@@ -3,6 +3,7 @@ import process from "node:process";
3
3
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
4
4
  import { parsePrNumber, requireOptionValue } from "../_cli-primitives.mjs";
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { resolveRunId as resolveEnvRunId } from "@dev-loops/core/loop/run-context";
6
7
  import {
7
8
  assertRunnerOwnership,
8
9
  claimRunnerOwnership,
@@ -16,7 +17,8 @@ const USAGE = `Usage:
16
17
  pr-runner-coordination.mjs assert --repo <owner/name> --pr <number> [--run-id <id>] [--require-existing]
17
18
  pr-runner-coordination.mjs release --repo <owner/name> --pr <number> [--run-id <id>]
18
19
  Durable one-runner-per-PR coordination helper.
19
- If --run-id is omitted for claim/assert/release/takeover, PI_SUBAGENT_RUN_ID is used.
20
+ If --run-id is omitted for claim/assert/release/takeover, DEVLOOPS_RUN_ID is used
21
+ (falling back to the PI_SUBAGENT_RUN_ID alias).
20
22
  Output:
21
23
  stdout: { "ok": true, ... }
22
24
  stderr: { "ok": false, "error": "...", ... }
@@ -81,9 +83,7 @@ function parseCliArgs(argv) {
81
83
  function resolveRunId(explicitRunId, env) {
82
84
  return typeof explicitRunId === "string" && explicitRunId.trim().length > 0
83
85
  ? explicitRunId.trim()
84
- : (typeof env?.PI_SUBAGENT_RUN_ID === "string" && env.PI_SUBAGENT_RUN_ID.trim().length > 0
85
- ? env.PI_SUBAGENT_RUN_ID.trim()
86
- : null);
86
+ : resolveEnvRunId(env);
87
87
  }
88
88
  export async function runPrRunnerCoordination(options, { env = process.env, cwd = process.cwd() } = {}) {
89
89
  if (options.command === "status") {
@@ -59,14 +59,27 @@ directly.
59
59
 
60
60
  ## Enforcement posture
61
61
 
62
- - This contract is enforced by convention and review, not by tool-level guards.
63
- - Mechanical enforcement (pre-commit hooks, tool-level write guards) is a
64
- non-goal for this document and may be addressed in follow-up work.
65
- - A `dev-loop` async subagent should reject delegation attempts that bypass
66
- the contract (e.g., direct mutation requests from the main agent).
62
+ - Under **Pi**, this contract is enforced by convention and review.
63
+ - Under **Claude Code**, the **Edit/Write tool** path is enforced **mechanically** by a
64
+ `PreToolUse` Write/Edit hook (`.claude/hooks/pre-tool-use-write-guard.mjs`, wired via
65
+ `.claude/settings.json` for this repo's own sessions and via `.claude/hooks/hooks.json` for the
66
+ Claude plugin): a Write/Edit whose target is inside the repo working tree and not
67
+ gitignored is **denied** when it originates from the main agent, and allowed only inside the
68
+ `dev-loop` subagent context (detected via the neutral `DEVLOOPS_RUN_ID` run-id contract, or
69
+ the dev-loop `agent_type` — a generic subagent is not authorized).
70
+ Strict enforcement is opt-in via `DEVLOOPS_MAIN_AGENT_READONLY=1` (default fail-open) so
71
+ adopting the harness does not retroactively break a repo's own interactive dev; full run-id
72
+ propagation into the Claude subagent context completes with the headless/agent wiring.
73
+ - **Scope of mechanical enforcement:** the hook covers the Edit and Write tools. Bash-driven
74
+ repo mutations the contract also forbids (`git commit`/`git push`/branch creation, in-place
75
+ edits like `sed -i`, shell redirection `> file` / `tee`) run through the Bash tool and remain
76
+ **convention-enforced** for now; the only Bash command the gate hook blocks is the ungated
77
+ `gh pr ready`. Tightening Bash-mutation coverage is possible follow-up.
78
+ - A companion `PreToolUse` Bash hook reproduces the `gh pr ready` draft-gate guard.
79
+ - A `dev-loop` async subagent should still reject delegation attempts that bypass the contract.
67
80
 
68
81
  ## Non-goals
69
82
 
70
- - Tool-level enforcement (file-write guards, pre-commit hooks)
83
+ - Pre-commit hooks (out of scope; the boundary is enforced at the Claude tool layer).
71
84
  - Changing dev-loop resolver behavior
72
85
  - Modifying the subagent API itself