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.
Files changed (85) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/refiner.md +1 -0
  4. package/.claude/hooks/_run-context.mjs +9 -16
  5. package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
  6. package/.claude/skills/dev-loop/SKILL.md +17 -7
  7. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  8. package/.claude/skills/docs/anti-patterns.md +1 -1
  9. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  10. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  11. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  12. package/.claude/skills/docs/local-planning-flow.md +63 -0
  13. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  14. package/.claude/skills/docs/merge-preconditions.md +100 -1
  15. package/.claude/skills/docs/plan-file-contract.md +37 -0
  16. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  17. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  18. package/.claude/skills/docs/stop-conditions.md +1 -0
  19. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  20. package/.claude/skills/local-implementation/SKILL.md +8 -2
  21. package/CHANGELOG.md +69 -0
  22. package/README.md +9 -2
  23. package/agents/refiner.agent.md +1 -0
  24. package/cli/index.mjs +21 -2
  25. package/extension/README.md +1 -1
  26. package/package.json +6 -4
  27. package/scripts/README.md +2 -2
  28. package/scripts/github/capture-review-threads.mjs +20 -2
  29. package/scripts/github/offer-human-handoff.mjs +147 -0
  30. package/scripts/github/probe-ci-status.mjs +468 -0
  31. package/scripts/github/probe-copilot-review.mjs +69 -3
  32. package/scripts/github/request-copilot-review.mjs +3 -3
  33. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  34. package/scripts/github/upsert-checkpoint-verdict.mjs +20 -5
  35. package/scripts/lib/jq-output.mjs +297 -0
  36. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  37. package/scripts/loop/_worktree-path.mjs +27 -0
  38. package/scripts/loop/check-retro-tooling.mjs +246 -0
  39. package/scripts/loop/cleanup-worktree.mjs +175 -0
  40. package/scripts/loop/copilot-pr-handoff.mjs +22 -4
  41. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
  42. package/scripts/loop/detect-stale-runner.mjs +3 -4
  43. package/scripts/loop/docs-grill-contract.mjs +70 -0
  44. package/scripts/loop/ensure-worktree.mjs +219 -0
  45. package/scripts/loop/info.mjs +21 -2
  46. package/scripts/loop/outer-loop.mjs +1 -1
  47. package/scripts/loop/pr-runner-coordination.mjs +21 -5
  48. package/scripts/loop/pre-flight-gate.mjs +10 -7
  49. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  50. package/scripts/loop/provision-worktree.mjs +243 -0
  51. package/scripts/loop/resolve-dev-loop-startup.mjs +181 -10
  52. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  53. package/scripts/loop/run-queue.mjs +25 -1
  54. package/scripts/loop/run-watch-cycle.mjs +152 -25
  55. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  56. package/scripts/pages/build-site.mjs +136 -0
  57. package/scripts/projects/add-queue-item.mjs +32 -7
  58. package/scripts/projects/archive-done-items.mjs +2 -1
  59. package/scripts/projects/ensure-queue-board.mjs +2 -1
  60. package/scripts/projects/list-queue-items.mjs +14 -3
  61. package/scripts/projects/move-queue-item.mjs +14 -3
  62. package/scripts/projects/reorder-queue-item.mjs +5 -4
  63. package/scripts/projects/sync-item-status.mjs +2 -1
  64. package/scripts/refine/_refine-helpers.mjs +20 -0
  65. package/scripts/refine/exit-spike.mjs +186 -0
  66. package/scripts/refine/promote-plan.mjs +387 -0
  67. package/scripts/refine/refine-plan-file.mjs +165 -0
  68. package/scripts/refine/validate-plan-file.mjs +64 -0
  69. package/scripts/refine/validate-spike-file.mjs +87 -0
  70. package/skills/copilot-pr-followup/SKILL.md +9 -7
  71. package/skills/dev-loop/SKILL.md +13 -3
  72. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  73. package/skills/docs/anti-patterns.md +1 -1
  74. package/skills/docs/artifact-authority-contract.md +86 -31
  75. package/skills/docs/copilot-loop-operations.md +1 -1
  76. package/skills/docs/issue-intake-procedure.md +4 -0
  77. package/skills/docs/local-planning-flow.md +63 -0
  78. package/skills/docs/local-planning-worked-example.md +139 -0
  79. package/skills/docs/merge-preconditions.md +100 -1
  80. package/skills/docs/plan-file-contract.md +37 -0
  81. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  82. package/skills/docs/spike-mode-contract.md +237 -0
  83. package/skills/docs/stop-conditions.md +1 -0
  84. package/skills/docs/tracker-first-loop-state.md +6 -6
  85. 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`.trim();
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, or the PI_SUBAGENT_RUN_ID alias, set)
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
- stdout.write(`${JSON.stringify(result)}\n`);
536
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout });
523
537
  }
524
538
  if (isDirectCliRun(import.meta.url)) {
525
- runCli().catch((error) => {
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 fetchPrFacts(options, runtime);
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 $PI_DEV_LOOP_STALE_RUNNER_MAX_AGE_MS).
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, falling back to the PI_SUBAGENT_RUN_ID
25
- alias). When supplied, the detector additionally verifies
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
+ }