dev-loops 0.3.0 → 0.5.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 +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
- package/.claude/skills/dev-loop/SKILL.md +17 -7
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/anti-patterns.md +1 -1
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +100 -1
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- package/.claude/skills/docs/stop-conditions.md +1 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
- package/.claude/skills/local-implementation/SKILL.md +8 -2
- package/CHANGELOG.md +69 -0
- package/README.md +9 -2
- package/agents/refiner.agent.md +1 -0
- package/cli/index.mjs +21 -2
- package/extension/README.md +1 -1
- package/package.json +6 -4
- package/scripts/README.md +2 -2
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/request-copilot-review.mjs +3 -3
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +20 -5
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +22 -4
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +21 -5
- package/scripts/loop/pre-flight-gate.mjs +10 -7
- package/scripts/loop/pre-push-main-guard.mjs +4 -4
- package/scripts/loop/provision-worktree.mjs +243 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +181 -10
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-queue.mjs +25 -1
- package/scripts/loop/run-watch-cycle.mjs +152 -25
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +32 -7
- package/scripts/projects/archive-done-items.mjs +2 -1
- package/scripts/projects/ensure-queue-board.mjs +2 -1
- package/scripts/projects/list-queue-items.mjs +14 -3
- package/scripts/projects/move-queue-item.mjs +14 -3
- package/scripts/projects/reorder-queue-item.mjs +5 -4
- package/scripts/projects/sync-item-status.mjs +2 -1
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/skills/copilot-pr-followup/SKILL.md +9 -7
- package/skills/dev-loop/SKILL.md +13 -3
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/anti-patterns.md +1 -1
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/copilot-loop-operations.md +1 -1
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +100 -1
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- package/skills/local-implementation/SKILL.md +8 -2
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Namespace-scoped post-merge worktree cleanup (issue #909).
|
|
4
|
+
*
|
|
5
|
+
* Resolves the canonical worktree path via the shared resolver, then runs
|
|
6
|
+
* `git worktree remove --force <path>` + `git worktree prune` from the main
|
|
7
|
+
* checkout (so it never removes the cwd).
|
|
8
|
+
*
|
|
9
|
+
* CLEANUP-SAFETY INVARIANT: refuses to remove any path NOT under
|
|
10
|
+
* `tmp/worktrees/dev-loops/`. A hand-made `tmp/worktrees/my-experiment` can
|
|
11
|
+
* never be force-removed by the loop.
|
|
12
|
+
*
|
|
13
|
+
* FAIL-SOFT: a git error is logged and reported but does NOT fail the process
|
|
14
|
+
* (exit 0 with a reason) so it never breaks a merge-completion flow. Prints a
|
|
15
|
+
* JSON result to stdout.
|
|
16
|
+
*/
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
20
|
+
import { requireTokenValue } from "../_cli-primitives.mjs";
|
|
21
|
+
import { parseArgs } from "node:util";
|
|
22
|
+
import { resolveWorktreePath, WORKTREE_NAMESPACE } from "@dev-loops/core/loop/handoff-envelope";
|
|
23
|
+
import { canonicalize } from "./_worktree-path.mjs";
|
|
24
|
+
|
|
25
|
+
const USAGE = `Usage:
|
|
26
|
+
cleanup-worktree.mjs --repo-root <p> (--issue <n> | --pr <n> | --path <p>)
|
|
27
|
+
Remove a loop-owned worktree after merge: git worktree remove --force + prune.
|
|
28
|
+
Refuses any path not under ${WORKTREE_NAMESPACE}/.
|
|
29
|
+
Required:
|
|
30
|
+
--repo-root <p> Absolute path to the main checkout (git runs here).
|
|
31
|
+
one of:
|
|
32
|
+
--issue <n> Issue number (resolves the canonical path).
|
|
33
|
+
--pr <n> PR number (resolves the canonical path).
|
|
34
|
+
--path <p> Explicit worktree path (must be under the namespace).
|
|
35
|
+
Optional:
|
|
36
|
+
-h, --help Show this help.
|
|
37
|
+
Output (stdout, JSON):
|
|
38
|
+
{ "ok": bool, "removed": <path>|null, "reason": "<why>" }
|
|
39
|
+
ok is true on success/skip (incl. fail-soft git errors); false ONLY when the
|
|
40
|
+
path is refused for being outside ${WORKTREE_NAMESPACE}/ (removed: null).`.trim();
|
|
41
|
+
|
|
42
|
+
const parseError = buildParseError(USAGE);
|
|
43
|
+
|
|
44
|
+
function parsePositiveInt(value, flag) {
|
|
45
|
+
const n = Number(value);
|
|
46
|
+
if (!Number.isInteger(n) || n < 1) throw parseError(`${flag} must be a positive integer`);
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseCleanupWorktreeCliArgs(argv) {
|
|
51
|
+
const options = { help: false, repoRoot: undefined, issue: undefined, pr: undefined, path: undefined };
|
|
52
|
+
const { tokens } = parseArgs({
|
|
53
|
+
args: [...argv],
|
|
54
|
+
options: {
|
|
55
|
+
help: { type: "boolean", short: "h" },
|
|
56
|
+
"repo-root": { type: "string" },
|
|
57
|
+
issue: { type: "string" },
|
|
58
|
+
pr: { type: "string" },
|
|
59
|
+
path: { type: "string" },
|
|
60
|
+
},
|
|
61
|
+
allowPositionals: true,
|
|
62
|
+
strict: false,
|
|
63
|
+
tokens: true,
|
|
64
|
+
});
|
|
65
|
+
for (const token of tokens) {
|
|
66
|
+
if (token.kind === "positional") throw parseError(`Unknown argument: ${token.value}`);
|
|
67
|
+
if (token.kind !== "option") continue;
|
|
68
|
+
if (token.name === "help") {
|
|
69
|
+
options.help = true;
|
|
70
|
+
return options;
|
|
71
|
+
}
|
|
72
|
+
if (token.name === "repo-root") {
|
|
73
|
+
options.repoRoot = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (token.name === "issue") {
|
|
77
|
+
options.issue = parsePositiveInt(requireTokenValue(token, parseError, { flagPattern: /^-/u }), "--issue");
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (token.name === "pr") {
|
|
81
|
+
options.pr = parsePositiveInt(requireTokenValue(token, parseError, { flagPattern: /^-/u }), "--pr");
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (token.name === "path") {
|
|
85
|
+
options.path = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
89
|
+
}
|
|
90
|
+
if (options.help) return options;
|
|
91
|
+
if (!options.repoRoot) throw parseError("Missing required --repo-root");
|
|
92
|
+
const selectors = [options.issue, options.pr, options.path].filter((v) => v !== undefined);
|
|
93
|
+
if (selectors.length === 0) throw parseError("One of --issue, --pr, or --path is required");
|
|
94
|
+
if (selectors.length > 1) throw parseError("Provide exactly one of --issue, --pr, or --path");
|
|
95
|
+
return options;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* True when `target` is at or under `<repoRoot>/tmp/worktrees/dev-loops/`.
|
|
100
|
+
* Canonicalizes (realpath) every side first: a purely lexical prefix check is
|
|
101
|
+
* bypassable when the namespace dir (or a parent) is a symlink pointing outside
|
|
102
|
+
* the repo — `git worktree remove --force` would then delete outside the repo.
|
|
103
|
+
* Two checks close that hole: (1) the resolved namespace must still live inside
|
|
104
|
+
* the resolved repo-root, and (2) the resolved target must live inside the
|
|
105
|
+
* resolved namespace.
|
|
106
|
+
*/
|
|
107
|
+
function isUnderNamespace(target, repoRoot) {
|
|
108
|
+
const realRoot = canonicalize(repoRoot);
|
|
109
|
+
const nsRoot = canonicalize(path.join(repoRoot, WORKTREE_NAMESPACE));
|
|
110
|
+
const real = canonicalize(target);
|
|
111
|
+
const within = (child, parent) => child === parent || child.startsWith(parent + path.sep);
|
|
112
|
+
// Namespace must resolve inside the repo (refuses a symlinked-out namespace),
|
|
113
|
+
// and the target must resolve inside that namespace.
|
|
114
|
+
return within(nsRoot, realRoot) && within(real, nsRoot);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function cleanupWorktree({ repoRoot, issue, pr, path: explicitPath }, { gitCommand = "git" } = {}) {
|
|
118
|
+
const root = path.resolve(repoRoot);
|
|
119
|
+
|
|
120
|
+
let target;
|
|
121
|
+
if (explicitPath !== undefined) {
|
|
122
|
+
target = path.resolve(root, explicitPath);
|
|
123
|
+
} else {
|
|
124
|
+
const kind = issue !== undefined ? "issue" : "pr";
|
|
125
|
+
const number = issue !== undefined ? issue : pr;
|
|
126
|
+
target = resolveWorktreePath({ repoRoot: root, kind, number });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Cleanup-safety invariant: only ever remove loop-owned worktrees.
|
|
130
|
+
if (!isUnderNamespace(target, root)) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
removed: null,
|
|
134
|
+
reason: `refused: ${target} is not under ${WORKTREE_NAMESPACE}/`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
execFileSync(gitCommand, ["worktree", "remove", "--force", target], {
|
|
140
|
+
cwd: root,
|
|
141
|
+
encoding: "utf8",
|
|
142
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
143
|
+
});
|
|
144
|
+
execFileSync(gitCommand, ["worktree", "prune"], {
|
|
145
|
+
cwd: root,
|
|
146
|
+
encoding: "utf8",
|
|
147
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
148
|
+
});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// Fail-soft: never break a merge-completion flow on a git error.
|
|
151
|
+
const detail = (err.stderr ?? err.message ?? "").toString().trim();
|
|
152
|
+
return { ok: true, removed: null, reason: `git error (non-fatal): ${detail}` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { ok: true, removed: target, reason: "removed" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
159
|
+
const options = parseCleanupWorktreeCliArgs(argv);
|
|
160
|
+
if (options.help) {
|
|
161
|
+
stdout.write(`${USAGE}\n`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const result = cleanupWorktree(options);
|
|
165
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
169
|
+
try {
|
|
170
|
+
runCli();
|
|
171
|
+
} catch (error) {
|
|
172
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
enforceExternalHealthyWaitTimeout,
|
|
18
18
|
} from "@dev-loops/core/loop/timeout-policy";
|
|
19
19
|
import { parseArgs } from "node:util";
|
|
20
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
20
21
|
import {
|
|
21
22
|
DEFAULT_POLL_INTERVAL_MS,
|
|
22
23
|
COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
@@ -69,9 +70,11 @@ Error output (stderr, JSON):
|
|
|
69
70
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
70
71
|
gh/runtime failures:
|
|
71
72
|
{ "ok": false, "error": "..." }
|
|
73
|
+
${JQ_OUTPUT_USAGE}
|
|
72
74
|
Exit codes:
|
|
73
75
|
0 Success
|
|
74
|
-
1 Argument error or gh failure
|
|
76
|
+
1 Argument error or gh failure
|
|
77
|
+
2 Invalid --jq filter`.trim();
|
|
75
78
|
const WATCH_STATES = new Set([
|
|
76
79
|
STATE.WAITING_FOR_COPILOT_REVIEW,
|
|
77
80
|
]);
|
|
@@ -134,6 +137,8 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
134
137
|
repo: undefined,
|
|
135
138
|
pr: undefined,
|
|
136
139
|
watchStatus: undefined,
|
|
140
|
+
jq: undefined,
|
|
141
|
+
silent: false,
|
|
137
142
|
};
|
|
138
143
|
const { tokens } = parseArgs({
|
|
139
144
|
args: [...argv],
|
|
@@ -142,6 +147,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
142
147
|
repo: { type: "string" },
|
|
143
148
|
pr: { type: "string" },
|
|
144
149
|
"watch-status": { type: "string" },
|
|
150
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
145
151
|
},
|
|
146
152
|
allowPositionals: true,
|
|
147
153
|
strict: false,
|
|
@@ -177,6 +183,14 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
177
183
|
options.watchStatus = watchStatus;
|
|
178
184
|
continue;
|
|
179
185
|
}
|
|
186
|
+
if (token.name === "jq") {
|
|
187
|
+
options.jq = requireTokenValue(token, parseError);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (token.name === "silent") {
|
|
191
|
+
options.silent = true;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
180
194
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
181
195
|
}
|
|
182
196
|
if (options.pr === undefined) {
|
|
@@ -333,7 +347,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
333
347
|
let interpretation = interpretLoopState(snapshot, refinementConfig);
|
|
334
348
|
|
|
335
349
|
// Check for human comments since last subagent action
|
|
336
|
-
// Only active in async subagent context (DEVLOOPS_RUN_ID
|
|
350
|
+
// Only active in async subagent context (DEVLOOPS_RUN_ID set)
|
|
337
351
|
let humanCommentCheck = { paused: false };
|
|
338
352
|
if (resolveRunId(env)) {
|
|
339
353
|
humanCommentCheck = await detectRecentHumanComments(
|
|
@@ -519,10 +533,14 @@ export async function runCli(
|
|
|
519
533
|
return;
|
|
520
534
|
}
|
|
521
535
|
const result = await runHandoff(options, { env, ghCommand });
|
|
522
|
-
|
|
536
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout });
|
|
523
537
|
}
|
|
524
538
|
if (isDirectCliRun(import.meta.url)) {
|
|
525
|
-
runCli().
|
|
539
|
+
runCli().then((code) => {
|
|
540
|
+
if (typeof code === "number") {
|
|
541
|
+
process.exitCode = code;
|
|
542
|
+
}
|
|
543
|
+
}).catch((error) => {
|
|
526
544
|
process.stderr.write(`${formatCliError(error)}\n`);
|
|
527
545
|
process.exitCode = 1;
|
|
528
546
|
});
|
|
@@ -163,7 +163,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
|
|
|
163
163
|
async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
164
164
|
const result = await runChild(
|
|
165
165
|
ghCommand,
|
|
166
|
-
["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup"],
|
|
166
|
+
["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeable,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup"],
|
|
167
167
|
env,
|
|
168
168
|
);
|
|
169
169
|
if (result.code !== 0) {
|
|
@@ -172,6 +172,32 @@ async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh"
|
|
|
172
172
|
}
|
|
173
173
|
return parseJsonText(result.stdout, { label: "gh pr view" });
|
|
174
174
|
}
|
|
175
|
+
|
|
176
|
+
// GitHub computes `mergeable` asynchronously, so a freshly-pushed head briefly
|
|
177
|
+
// reads `UNKNOWN`. After the initial fetch, re-poll up to `maxPolls` more times
|
|
178
|
+
// while the value stays UNKNOWN (so at most 1 + maxPolls total fetches) before
|
|
179
|
+
// deciding; never treat a transient UNKNOWN as a pass — the caller fails closed
|
|
180
|
+
// to recheck if it never settles. (issue #980)
|
|
181
|
+
export async function fetchPrFactsWithSettledMergeable(
|
|
182
|
+
options,
|
|
183
|
+
{
|
|
184
|
+
env = process.env,
|
|
185
|
+
ghCommand = "gh",
|
|
186
|
+
maxPolls = 3,
|
|
187
|
+
pollDelayMs = 1500,
|
|
188
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
189
|
+
fetch = fetchPrFacts,
|
|
190
|
+
} = {},
|
|
191
|
+
) {
|
|
192
|
+
let prData = await fetch(options, { env, ghCommand });
|
|
193
|
+
let polls = 0;
|
|
194
|
+
while (String(prData?.mergeable || "").toUpperCase() === "UNKNOWN" && polls < maxPolls) {
|
|
195
|
+
polls += 1;
|
|
196
|
+
await sleep(pollDelayMs);
|
|
197
|
+
prData = await fetch(options, { env, ghCommand });
|
|
198
|
+
}
|
|
199
|
+
return prData;
|
|
200
|
+
}
|
|
175
201
|
export function resolveLinkedIssueFromPr(prData) {
|
|
176
202
|
if (!prData || typeof prData !== "object") return null;
|
|
177
203
|
const closing = Array.isArray(prData.closingIssuesReferences) ? prData.closingIssuesReferences : [];
|
|
@@ -290,7 +316,7 @@ async function loadRetrospectiveCheckpoint(repoRoot) {
|
|
|
290
316
|
}
|
|
291
317
|
}
|
|
292
318
|
export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
293
|
-
const prData = await
|
|
319
|
+
const prData = await fetchPrFactsWithSettledMergeable(options, runtime);
|
|
294
320
|
const currentHeadSha = typeof prData?.headRefOid === "string" && prData.headRefOid.trim().length > 0
|
|
295
321
|
? prData.headRefOid.trim()
|
|
296
322
|
: null;
|
|
@@ -349,6 +375,9 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
349
375
|
const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
|
|
350
376
|
? prData.mergeStateStatus.trim().toUpperCase()
|
|
351
377
|
: null;
|
|
378
|
+
const mergeable = typeof prData?.mergeable === "string" && prData.mergeable.trim().length > 0
|
|
379
|
+
? prData.mergeable.trim().toUpperCase()
|
|
380
|
+
: null;
|
|
352
381
|
const isDraft = Boolean(prData?.isDraft);
|
|
353
382
|
const isClosed = String(prData?.state || "").toUpperCase() === "CLOSED";
|
|
354
383
|
const isMerged = String(prData?.state || "").toUpperCase() === "MERGED";
|
|
@@ -361,6 +390,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
361
390
|
pr: options.pr,
|
|
362
391
|
currentHeadSha,
|
|
363
392
|
mergeStateStatus,
|
|
393
|
+
mergeable,
|
|
364
394
|
conflictFiles,
|
|
365
395
|
prData,
|
|
366
396
|
snapshot,
|
|
@@ -396,6 +426,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
396
426
|
const draftGateConfig = resolveGateConfig(config, "draft");
|
|
397
427
|
const maxCopilotRounds = resolveRefinementConfig(config, "maxCopilotRounds");
|
|
398
428
|
const requireRetrospectiveGate = resolveWorkflowConfig(config, "requireRetrospectiveGate");
|
|
429
|
+
const requireRetrospectiveInternalTooling = resolveWorkflowConfig(config, "requireRetrospectiveInternalTooling");
|
|
399
430
|
const retrospectiveCheckpoint = await loadRetrospectiveCheckpoint(repoRoot);
|
|
400
431
|
const result = evaluatePrGateCoordination({
|
|
401
432
|
repo: context.repo,
|
|
@@ -406,6 +437,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
406
437
|
prMerged: String(context.prData?.state || "").toUpperCase() === "MERGED",
|
|
407
438
|
prTitle: context.prData?.title,
|
|
408
439
|
mergeStateStatus: context.mergeStateStatus,
|
|
440
|
+
mergeable: context.mergeable,
|
|
409
441
|
conflictFiles: context.conflictFiles,
|
|
410
442
|
lifecycleState: context.interpretation.state,
|
|
411
443
|
loopDisposition: context.disposition.loopDisposition,
|
|
@@ -415,6 +447,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
415
447
|
sameHeadCleanConverged: context.interpretation.sameHeadCleanConverged,
|
|
416
448
|
draftGateRequireCi: draftGateConfig.requireCi,
|
|
417
449
|
requireRetrospectiveGate,
|
|
450
|
+
requireRetrospectiveInternalTooling,
|
|
418
451
|
retrospectiveCheckpoint,
|
|
419
452
|
draftGate: context.gateEvidence.draftGate,
|
|
420
453
|
draftGateMarker: context.gateEvidence.draftGateMarker,
|
|
@@ -19,11 +19,10 @@ Required:
|
|
|
19
19
|
Optional:
|
|
20
20
|
--stale-runner-max-age-ms <ms>
|
|
21
21
|
Override the staleness threshold (default 30 minutes,
|
|
22
|
-
or $
|
|
22
|
+
or $DEVLOOPS_STALE_RUNNER_MAX_AGE_MS).
|
|
23
23
|
--run-id <id> Override the active run id (default: read from
|
|
24
|
-
DEVLOOPS_RUN_ID
|
|
25
|
-
|
|
26
|
-
the current run id is still the active owner.
|
|
24
|
+
DEVLOOPS_RUN_ID). When supplied, the detector additionally
|
|
25
|
+
verifies the current run id is still the active owner.
|
|
27
26
|
Output (stdout, JSON; always includes staleRunnerCheck):
|
|
28
27
|
{
|
|
29
28
|
"ok": true,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Bounded docs-grill disposition classifier behind `dev-loop`. Sibling of
|
|
2
|
+
// scripts/loop/slides-story-review-contract.mjs: this one codifies the keep/fix
|
|
3
|
+
// rule for the autonomous docs-grill step (claims vs the actual contracts,
|
|
4
|
+
// code-vs-doc drift, stale references, contract-surface accuracy). Pure module,
|
|
5
|
+
// no I/O. See docs/docs-grill-step.md.
|
|
6
|
+
|
|
7
|
+
// What each finding asserts is wrong. `drift` covers any divergence between a
|
|
8
|
+
// claim/reference and the contract surface it points at; `stale_reference`
|
|
9
|
+
// covers a link/path/command that no longer resolves; `cosmetic` covers
|
|
10
|
+
// wording-only nits with no behavioral or reference impact.
|
|
11
|
+
export const DOCS_GRILL_FINDING_KINDS = Object.freeze([
|
|
12
|
+
'drift',
|
|
13
|
+
'stale_reference',
|
|
14
|
+
'cosmetic',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// The bounded disposition the step assigns to each finding.
|
|
18
|
+
export const DOCS_GRILL_DISPOSITIONS = Object.freeze([
|
|
19
|
+
'record_finding', // real drift between code/behavior and a contract claim — record it
|
|
20
|
+
'fix_in_place', // doc-only drift the loop can correct on this branch
|
|
21
|
+
'route_followup', // doc-only drift too large for this branch — route a follow-up
|
|
22
|
+
'ignore_cosmetic', // wording nit that does not justify a block or a fix here
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Classify one docs-grill finding into its keep/fix disposition.
|
|
27
|
+
*
|
|
28
|
+
* The keep/fix rule (docs/docs-grill-step.md):
|
|
29
|
+
* - real drift between code/behavior and a contract claim -> record_finding
|
|
30
|
+
* - doc-only drift the loop can correct here -> fix_in_place
|
|
31
|
+
* - doc-only drift too large for this branch -> route_followup
|
|
32
|
+
* - cosmetic wording nit -> ignore_cosmetic
|
|
33
|
+
*
|
|
34
|
+
* A coerced/invalid finding returns a structured invalid result rather than
|
|
35
|
+
* throwing (the module's contract is a structured result, never an exception).
|
|
36
|
+
*
|
|
37
|
+
* @param {object} finding
|
|
38
|
+
* @param {string} finding.kind - one of DOCS_GRILL_FINDING_KINDS
|
|
39
|
+
* @param {boolean} [finding.docOnly] - true when only docs (no code/behavior) diverge
|
|
40
|
+
* @param {boolean} [finding.fixableHere] - true when a doc-only fix is small enough for this branch
|
|
41
|
+
* @returns {{ ok: boolean, disposition?: string, status: string, reason: string, invalid?: string[] }}
|
|
42
|
+
*/
|
|
43
|
+
export function classifyDocsGrillFinding(finding = {}) {
|
|
44
|
+
if (!finding || typeof finding !== 'object') finding = {};
|
|
45
|
+
if (!DOCS_GRILL_FINDING_KINDS.includes(finding.kind)) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
status: 'invalid_finding',
|
|
49
|
+
reason: 'unknown_finding_kind',
|
|
50
|
+
invalid: ['kind'],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (finding.kind === 'cosmetic') {
|
|
55
|
+
return { ok: true, disposition: 'ignore_cosmetic', status: 'classified', reason: 'cosmetic_nit' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// drift | stale_reference against live code/behavior is always recorded; the
|
|
59
|
+
// reason reflects the kind so downstream filtering stays accurate.
|
|
60
|
+
if (finding.docOnly !== true) {
|
|
61
|
+
const reason = finding.kind === 'stale_reference' ? 'stale_reference' : 'real_drift';
|
|
62
|
+
return { ok: true, disposition: 'record_finding', status: 'classified', reason };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Doc-only drift: fix it here when small, otherwise route a follow-up.
|
|
66
|
+
if (finding.fixableHere === true) {
|
|
67
|
+
return { ok: true, disposition: 'fix_in_place', status: 'classified', reason: 'doc_only_fixable_here' };
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, disposition: 'route_followup', status: 'classified', reason: 'doc_only_too_large' };
|
|
70
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Ensure a loop-owned worktree exists at its canonical namespaced path, then
|
|
4
|
+
* provision it (issue #909). This is the lifecycle entrypoint: create OR reuse
|
|
5
|
+
* the worktree, then copy/link the configured gitignored files in one step.
|
|
6
|
+
*
|
|
7
|
+
* - Canonical path comes from the shared resolveWorktreePath (namespaced
|
|
8
|
+
* `tmp/worktrees/dev-loops/<kind>-<n>`), so create/provision/cleanup agree.
|
|
9
|
+
* - `git fetch <base-origin>` then `git worktree add` if absent. If a worktree
|
|
10
|
+
* already exists at the exact path it is REUSED (idempotent); if one exists
|
|
11
|
+
* there on a DIFFERENT branch it is a hard conflict (we never clobber).
|
|
12
|
+
* - Provisioning is invoked via the imported provisionWorktree core (shared
|
|
13
|
+
* with provision-worktree.mjs's CLI) — not shelled out. It fails soft: a
|
|
14
|
+
* provision warning never aborts the worktree.
|
|
15
|
+
* - Does NOT run npm install (out of scope).
|
|
16
|
+
*
|
|
17
|
+
* Prints a JSON result to stdout:
|
|
18
|
+
* { ok, path, created|reused, provision: { actions, summary } }
|
|
19
|
+
* (`provision` is the full provisionWorktree() result, not just its summary.)
|
|
20
|
+
* A git create failure is a hard error (exit 1); provisioning is fail-soft.
|
|
21
|
+
*/
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
25
|
+
import { requireTokenValue } from "../_cli-primitives.mjs";
|
|
26
|
+
import { parseArgs } from "node:util";
|
|
27
|
+
import { resolveWorktreePath } from "@dev-loops/core/loop/handoff-envelope";
|
|
28
|
+
import { provisionWorktree } from "./provision-worktree.mjs";
|
|
29
|
+
import { canonicalize } from "./_worktree-path.mjs";
|
|
30
|
+
|
|
31
|
+
const USAGE = `Usage:
|
|
32
|
+
ensure-worktree.mjs --repo-root <p> (--issue <n> | --pr <n>) [--branch <name>] [--base <ref>]
|
|
33
|
+
Create (or reuse) a loop-owned worktree at its canonical namespaced path
|
|
34
|
+
(tmp/worktrees/dev-loops/<kind>-<n>) and provision it in one step.
|
|
35
|
+
Required:
|
|
36
|
+
--repo-root <p> Absolute path to the main checkout (git runs here).
|
|
37
|
+
one of:
|
|
38
|
+
--issue <n> Issue number (resolves the canonical path).
|
|
39
|
+
--pr <n> PR number (resolves the canonical path).
|
|
40
|
+
Optional:
|
|
41
|
+
--branch <name> Branch to create/check out (default: <kind>-<n>).
|
|
42
|
+
--base <ref> Base ref for a new worktree (default: origin/main).
|
|
43
|
+
-h, --help Show this help.
|
|
44
|
+
Output (stdout, JSON):
|
|
45
|
+
{ "ok": true, "path": <p>, "created": bool, "reused": bool,
|
|
46
|
+
"provision": { "actions": [...], "summary": {...} } }`.trim();
|
|
47
|
+
|
|
48
|
+
const parseError = buildParseError(USAGE);
|
|
49
|
+
|
|
50
|
+
function parsePositiveInt(value, flag) {
|
|
51
|
+
const n = Number(value);
|
|
52
|
+
if (!Number.isInteger(n) || n < 1) throw parseError(`${flag} must be a positive integer`);
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseEnsureWorktreeCliArgs(argv) {
|
|
57
|
+
const options = {
|
|
58
|
+
help: false,
|
|
59
|
+
repoRoot: undefined,
|
|
60
|
+
issue: undefined,
|
|
61
|
+
pr: undefined,
|
|
62
|
+
branch: undefined,
|
|
63
|
+
base: "origin/main",
|
|
64
|
+
};
|
|
65
|
+
const { tokens } = parseArgs({
|
|
66
|
+
args: [...argv],
|
|
67
|
+
options: {
|
|
68
|
+
help: { type: "boolean", short: "h" },
|
|
69
|
+
"repo-root": { type: "string" },
|
|
70
|
+
issue: { type: "string" },
|
|
71
|
+
pr: { type: "string" },
|
|
72
|
+
branch: { type: "string" },
|
|
73
|
+
base: { type: "string" },
|
|
74
|
+
},
|
|
75
|
+
allowPositionals: true,
|
|
76
|
+
strict: false,
|
|
77
|
+
tokens: true,
|
|
78
|
+
});
|
|
79
|
+
for (const token of tokens) {
|
|
80
|
+
if (token.kind === "positional") throw parseError(`Unknown argument: ${token.value}`);
|
|
81
|
+
if (token.kind !== "option") continue;
|
|
82
|
+
if (token.name === "help") {
|
|
83
|
+
options.help = true;
|
|
84
|
+
return options;
|
|
85
|
+
}
|
|
86
|
+
if (token.name === "repo-root") {
|
|
87
|
+
options.repoRoot = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (token.name === "issue") {
|
|
91
|
+
options.issue = parsePositiveInt(requireTokenValue(token, parseError, { flagPattern: /^-/u }), "--issue");
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (token.name === "pr") {
|
|
95
|
+
options.pr = parsePositiveInt(requireTokenValue(token, parseError, { flagPattern: /^-/u }), "--pr");
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (token.name === "branch") {
|
|
99
|
+
options.branch = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (token.name === "base") {
|
|
103
|
+
options.base = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
107
|
+
}
|
|
108
|
+
if (options.help) return options;
|
|
109
|
+
if (!options.repoRoot) throw parseError("Missing required --repo-root");
|
|
110
|
+
const selectors = [options.issue, options.pr].filter((v) => v !== undefined);
|
|
111
|
+
if (selectors.length === 0) throw parseError("One of --issue or --pr is required");
|
|
112
|
+
if (selectors.length > 1) throw parseError("Provide exactly one of --issue or --pr");
|
|
113
|
+
return options;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Remote name from a base ref like "origin/main" → "origin". */
|
|
117
|
+
function remoteFromBase(base) {
|
|
118
|
+
const slash = base.indexOf("/");
|
|
119
|
+
return slash > 0 ? base.slice(0, slash) : "origin";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function runGit(gitCommand, args, cwd) {
|
|
123
|
+
return execFileSync(gitCommand, args, {
|
|
124
|
+
cwd,
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** True when a local branch ref already exists (non-zero exit → absent). */
|
|
131
|
+
function branchExists(gitCommand, branch, cwd) {
|
|
132
|
+
try {
|
|
133
|
+
runGit(gitCommand, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], cwd);
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Parse `git worktree list --porcelain` into [{ path, branch }]. Branch is the
|
|
142
|
+
* short ref (refs/heads/foo → foo) or null for a detached/bare entry.
|
|
143
|
+
*/
|
|
144
|
+
function parseWorktreeList(porcelain) {
|
|
145
|
+
const entries = [];
|
|
146
|
+
let cur = null;
|
|
147
|
+
for (const line of porcelain.split("\n")) {
|
|
148
|
+
if (line.startsWith("worktree ")) {
|
|
149
|
+
cur = { path: line.slice("worktree ".length).trim(), branch: null };
|
|
150
|
+
entries.push(cur);
|
|
151
|
+
} else if (cur && line.startsWith("branch ")) {
|
|
152
|
+
cur.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return entries;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function ensureWorktree(
|
|
159
|
+
{ repoRoot, issue, pr, branch, base = "origin/main" },
|
|
160
|
+
{ gitCommand = "git", provision = provisionWorktree } = {},
|
|
161
|
+
) {
|
|
162
|
+
const root = path.resolve(repoRoot);
|
|
163
|
+
const kind = issue !== undefined ? "issue" : "pr";
|
|
164
|
+
const number = issue !== undefined ? issue : pr;
|
|
165
|
+
const target = resolveWorktreePath({ repoRoot: root, kind, number });
|
|
166
|
+
const wantBranch = branch || `${kind}-${number}`;
|
|
167
|
+
|
|
168
|
+
// Idempotency / conflict check BEFORE any mutation.
|
|
169
|
+
const list = parseWorktreeList(runGit(gitCommand, ["worktree", "list", "--porcelain"], root));
|
|
170
|
+
const canonicalTarget = canonicalize(target);
|
|
171
|
+
const existing = list.find((e) => canonicalize(e.path) === canonicalTarget);
|
|
172
|
+
if (existing) {
|
|
173
|
+
if (existing.branch && existing.branch !== wantBranch) {
|
|
174
|
+
// Hard conflict — never clobber an unrelated worktree at our path.
|
|
175
|
+
throw new Error(
|
|
176
|
+
`worktree conflict: ${target} already checked out on branch "${existing.branch}", not "${wantBranch}"`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
// Reuse: still (re-)provision — provisioning is idempotent.
|
|
180
|
+
const summary = await provision({ worktreePath: target, repoRoot: root });
|
|
181
|
+
return { ok: true, path: target, created: false, reused: true, provision: summary };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Create. fetch is best-effort (offline reuse of a local base ref still works),
|
|
185
|
+
// but `git worktree add` failing is a HARD error.
|
|
186
|
+
try {
|
|
187
|
+
runGit(gitCommand, ["fetch", remoteFromBase(base)], root);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
process.stderr.write(`[ensure-worktree] WARN fetch failed (continuing): ${(err.stderr ?? err.message ?? "").toString().trim()}\n`);
|
|
190
|
+
}
|
|
191
|
+
// The branch may already exist (worktree removed but branch left behind). `git
|
|
192
|
+
// worktree add -b` fails on an existing branch, so attach to it instead; only
|
|
193
|
+
// create-from-base when the branch is genuinely new.
|
|
194
|
+
if (branchExists(gitCommand, wantBranch, root)) {
|
|
195
|
+
runGit(gitCommand, ["worktree", "add", target, wantBranch], root);
|
|
196
|
+
} else {
|
|
197
|
+
runGit(gitCommand, ["worktree", "add", "-b", wantBranch, target, base], root);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const summary = await provision({ worktreePath: target, repoRoot: root });
|
|
201
|
+
return { ok: true, path: target, created: true, reused: false, provision: summary };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
205
|
+
const options = parseEnsureWorktreeCliArgs(argv);
|
|
206
|
+
if (options.help) {
|
|
207
|
+
stdout.write(`${USAGE}\n`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const result = await ensureWorktree(options);
|
|
211
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
215
|
+
runCli().catch((error) => {
|
|
216
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
217
|
+
process.exitCode = 1;
|
|
218
|
+
});
|
|
219
|
+
}
|