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.
- package/.claude/.claude-plugin/plugin.json +9 -0
- package/.claude/agents/dev-loop.md +69 -0
- package/.claude/agents/developer.md +35 -0
- package/.claude/agents/docs.md +31 -0
- package/.claude/agents/fixer.md +51 -0
- package/.claude/agents/quality.md +26 -0
- package/.claude/agents/refiner.md +85 -0
- package/.claude/agents/review.md +61 -0
- package/.claude/hooks/_hook-io.mjs +38 -0
- package/.claude/hooks/hooks.json +35 -0
- package/.claude/hooks/post-tool-use-merge.mjs +23 -0
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +73 -0
- package/.claude/hooks/pre-tool-use-write-guard.mjs +55 -0
- package/.claude/skills/copilot-pr-followup/SKILL.md +376 -0
- package/.claude/skills/dev-loop/SKILL.md +140 -0
- package/.claude/skills/dev-loop/templates/bootstrap-agents.md +26 -0
- package/.claude/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
- package/.claude/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
- package/.claude/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
- package/.claude/skills/dev-loop/templates/dev-mode-review.md +17 -0
- package/.claude/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
- package/.claude/skills/dev-loop/templates/merged-phase-plan.md +19 -0
- package/.claude/skills/dev-loop/templates/phase-doc.md +27 -0
- package/.claude/skills/dev-loop/templates/phase-summary.md +13 -0
- package/.claude/skills/dev-loop/templates/phase-variant.md +15 -0
- package/.claude/skills/dev-loop/templates/retrospective.md +11 -0
- package/.claude/skills/dev-loop/templates/review.md +32 -0
- package/.claude/skills/dev-loop/templates/ui-vision-review.md +55 -0
- package/.claude/skills/docs/acceptance-criteria-verification.md +21 -0
- package/.claude/skills/docs/anti-patterns.md +21 -0
- package/.claude/skills/docs/artifact-authority-contract.md +119 -0
- package/.claude/skills/docs/confirmation-rules.md +28 -0
- package/.claude/skills/docs/copilot-ci-status-contract.md +52 -0
- package/.claude/skills/docs/copilot-loop-operations.md +233 -0
- package/.claude/skills/docs/debt-remediation-contract.md +107 -0
- package/.claude/skills/docs/entrypoint-strategies.md +115 -0
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +234 -0
- package/.claude/skills/docs/issue-intake-procedure.md +235 -0
- package/.claude/skills/docs/main-agent-contract.md +85 -0
- package/.claude/skills/docs/merge-preconditions.md +29 -0
- package/.claude/skills/docs/pr-lifecycle-contract.md +209 -0
- package/.claude/skills/docs/public-dev-loop-contract.md +497 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +159 -0
- package/.claude/skills/docs/stop-conditions.md +29 -0
- package/.claude/skills/docs/structural-quality.md +42 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +281 -0
- package/.claude/skills/docs/validation-policy.md +27 -0
- package/.claude/skills/docs/workflow-handoff-contract.md +135 -0
- package/.claude/skills/final-approval/SKILL.md +18 -0
- package/.claude/skills/local-implementation/SKILL.md +636 -0
- package/CHANGELOG.md +51 -0
- package/README.md +16 -0
- package/agents/dev-loop.agent.md +4 -0
- package/cli/index.mjs +5 -5
- package/extension/checks.ts +18 -29
- package/extension/harness-types.ts +47 -0
- package/extension/index.ts +10 -8
- package/extension/pi-extension-adapter.ts +64 -0
- package/extension/post-merge-update.ts +69 -190
- package/package.json +16 -9
- package/scripts/README.md +6 -3
- package/scripts/claude/generate-claude-assets.mjs +175 -0
- package/scripts/claude/headless-dev-loop.mjs +89 -0
- package/scripts/claude/headless-info-smoke.mjs +90 -0
- package/scripts/github/_review-thread-mutations.mjs +3 -0
- package/scripts/github/reply-resolve-review-threads.mjs +3 -3
- package/scripts/github/upsert-checkpoint-verdict.mjs +4 -3
- package/scripts/loop/_pr-runner-coordination.mjs +2 -1
- package/scripts/loop/copilot-pr-handoff.mjs +3 -2
- package/scripts/loop/detect-stale-runner.mjs +5 -7
- package/scripts/loop/info.mjs +3 -2
- package/scripts/loop/inspect-run-viewer/server.mjs +2 -1
- package/scripts/loop/outer-loop.mjs +2 -2
- package/scripts/loop/pr-runner-coordination.mjs +4 -4
- 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:
|
|
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": "
|
|
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: "
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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") {
|
package/scripts/loop/info.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
: (
|
|
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
|
-
-
|
|
63
|
-
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
-
|
|
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
|