dev-loops 0.2.1 → 0.2.3

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dev-loops",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Agent-harness-agnostic dev-loop: agents, skills, and hooks for GitHub/Copilot-driven development loops.",
5
5
  "author": {
6
6
  "name": "Manuel Fittko",
@@ -225,6 +225,7 @@ When unresolved feedback exists, use a narrow follow-up loop:
225
225
  - if the refreshed snapshot reports unresolved threads, re-enter the reply/resolve loop for the missed threads
226
226
  12. only after GitHub-side reply/resolve work is done for the addressed threads and the refreshed thread snapshot proves zero unresolved threads remain, decide whether another Copilot pass is desired
227
227
  - resolve the review-round cap from config via `resolveRefinementConfig(config, "maxCopilotRounds")` from `@dev-loops/core/config`; default config ships `maxCopilotRounds: 5`
228
+ - **Opt out entirely:** `maxCopilotRounds: 0` disables the external Copilot review gate for the repo — the loop runs `draft_gate → pre_approval_gate` with the local harness only, never requesting or waiting on Copilot. Use this when the repo has no Copilot reviewer configured or prefers local-harness-only review.
228
229
  - use the completed Copilot review-round count from `detect-copilot-loop-state.mjs` / `copilot-pr-handoff.mjs` as the current PR's review-round count
229
230
  - if completed review rounds have reached the maximum (default: 5), do **not** re-request Copilot review
230
231
  - when the round limit is reached **and** the refreshed thread snapshot proves zero unresolved threads **and** current-head CI is green or credibly green, treat that clean state as eligible for `pre_approval_gate` fallback instead of deadlocking on another Copilot rerequest
@@ -25,7 +25,7 @@ Required installed runtime contract docs are shared bundled copies under `../doc
25
25
 
26
26
  The main agent must **always** dispatch the `dev-loop` async subagent for any dev-loop work.
27
27
  Do not run `dev-loops loop startup` or any startup resolver in the main agent.
28
- The resolver requires `PI_SUBAGENT_RUN_ID` for async-required routes (the default `required` mode); the startup resolver can also run without it for non-async routes. Regardless, only the `dev-loop` async subagent runs it — never the main agent.
28
+ For async-required routes (config `workflow.asyncStartMode`, default `required`) the resolver needs an async run-id marker (`DEVLOOPS_RUN_ID`, or the `PI_SUBAGENT_RUN_ID` alias) that the Pi harness injects when it dispatches the async subagent; under the Claude Code harness the requirement is relaxed automatically (no marker needed). The startup resolver also runs without a marker for non-async routes. Regardless, only the `dev-loop` subagent runs it — never the main agent.
29
29
 
30
30
  ### Dev-loop subagent (post-dispatch)
31
31
 
@@ -85,7 +85,7 @@ Do not preload route packs before the resolver selects the strategy.
85
85
 
86
86
  ## Async dispatch
87
87
 
88
- **Async dispatch rule (enforced):** the resolver enforces fail-closed for GitHub-first strategies when `canonicalStateSummary.requiresAsyncDispatch` is `true` (default `required` mode). Inline invocation without `PI_SUBAGENT_RUN_ID` is rejected only for those routes. See [Startup procedure](#startup-procedure).
88
+ **Async dispatch rule (enforced):** the resolver fails closed for GitHub-first strategies when `canonicalStateSummary.requiresAsyncDispatch` is `true` (default `required` mode) inline invocation without an async run-id marker (`DEVLOOPS_RUN_ID`, or the `PI_SUBAGENT_RUN_ID` alias) is rejected for those routes. Under the Claude Code harness this requirement is relaxed automatically. See [Startup procedure](#startup-procedure).
89
89
 
90
90
 
91
91
  ## Fallback gate-comment poster
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.2.3
6
+
7
+ ### Added
8
+
9
+ - **Opt out of the Copilot review gate via `refinement.maxCopilotRounds: 0`** (#832). For repos
10
+ without a Copilot reviewer configured (or that prefer local-harness-only review), setting
11
+ `maxCopilotRounds: 0` disables the external Copilot review cycle entirely — the loop runs
12
+ `draft_gate → pre_approval_gate` with no Copilot request or wait. The config schema now accepts
13
+ `0` (`nonnegative`; negative still rejected); `evaluatePrGateCoordination` routes `0` through the
14
+ existing `internal_only` path, `shouldGuardCopilotReviewRequest` never forces a request at `0`,
15
+ and the watch-cycle handoff (`copilot-pr-handoff`) skips the request too. Default (`5`) unchanged.
16
+ Documented in the README, extension config docs, and the `copilot-pr-followup` skill.
17
+
18
+ ## 0.2.2
19
+
20
+ ### Fixed
21
+
22
+ - **Claude Code: dev-loop no longer dead-ends on the async-start contract** (#830). Running
23
+ `/dev-loop` from the installed plugin failed immediately because `dev-loops loop startup`
24
+ enforces an async-start contract — it requires a run-id env marker (`DEVLOOPS_RUN_ID` /
25
+ `PI_SUBAGENT_RUN_ID`) that Pi injects when dispatching an async subagent but Claude Code's
26
+ Agent tool does not. That contract guards against detached, uninspectable background
27
+ processes, a risk that does not exist under Claude's Agent model (each subagent run is
28
+ visible and inspectable). The async requirement remains configurable via
29
+ `workflow.asyncStartMode` (`required` | `allowed`); under the Claude harness it is now
30
+ **relaxed to `allowed` at runtime** via `resolveEffectiveAsyncStartMode`, which consults the
31
+ new `isClaudeHarness` helper (`CLAUDECODE=1`) in `@dev-loops/core/loop/run-context`. An
32
+ explicit `DEVLOOPS_RUN_ID` still resolves as `valid`, and Pi behavior is unchanged (outside
33
+ Claude the configured mode is honored verbatim).
34
+ - The async-start CLI contract test is now hermetic — it clears `CLAUDECODE` (and the run-id
35
+ markers) so the rejection path is exercised regardless of the harness the suite runs under.
36
+ - The generated `dev-loop` skill prose no longer claims `PI_SUBAGENT_RUN_ID` is *required* — it
37
+ now describes the async run-id marker (`DEVLOOPS_RUN_ID` / `PI_SUBAGENT_RUN_ID` alias) and notes
38
+ the Claude-harness relaxation, so the plugin's docs match the runtime behavior. Subagent
39
+ spawning via the `dev-loop` agent is confirmed correctly wired: it grants the `Agent` tool
40
+ (the current subagent-spawning tool, renamed from `Task` in Claude Code v2.1.63) and the
41
+ strategy skills delegate to the worker agents (`developer`/`quality`/`refiner`/`fixer`/`review`/`docs`).
42
+
5
43
  ## 0.2.1
6
44
 
7
45
  ### Added
package/README.md CHANGED
@@ -193,7 +193,7 @@ npx dev-loops gates # see what reviewers will check
193
193
  Key surfaces:
194
194
  - **Gate angles** — which review lenses run at draft and pre-approval gates
195
195
  - **Persona prompts** — focused instructions per angle (DRY, KISS, YAGNI, SRP, SoC, and more)
196
- - **Refinement** — fan-out count and mode for parallel review variants
196
+ - **Refinement** — fan-out count and mode for parallel review variants; `refinement.maxCopilotRounds` caps Copilot re-review rounds (default `5`), and **`maxCopilotRounds: 0` disables the Copilot review gate entirely** for local-harness-only review (`draft_gate → pre_approval_gate`, no Copilot) — useful when the repo has no Copilot reviewer configured
197
197
  - **Autonomy** — which gates require operator confirmation
198
198
  - **Workflow defaults** — retrospective enforcement, draft-first posture, dev-mode policy
199
199
 
@@ -133,7 +133,9 @@ personas:
133
133
 
134
134
  # Override gate requirements
135
135
  refinement:
136
- fanOut: 5 # run 5 parallel review variants instead of 3
136
+ fanOut: 5 # run 5 parallel review variants instead of 3
137
+ maxCopilotRounds: 0 # 0 disables the Copilot review gate entirely (local-harness-only
138
+ # review: draft_gate → pre_approval_gate, no Copilot). Default: 5.
137
139
 
138
140
  autonomy:
139
141
  stopAt:
package/package.json CHANGED
@@ -40,14 +40,9 @@
40
40
  "repo-wiki:lint": "node scripts/repo-wiki.mjs lint --repo .",
41
41
  "repo-wiki:search": "node scripts/repo-wiki.mjs search --repo .",
42
42
  "repo-wiki:init": "node scripts/repo-wiki.mjs init --repo .",
43
- "repo-wiki:local": "node scripts/repo-wiki-local.mjs",
44
- "repo-wiki:local-bootstrap": "node scripts/repo-wiki-local.mjs scan --repo . && node scripts/repo-wiki-local.mjs plan --repo . && node scripts/repo-wiki-local.mjs compile --repo .",
45
- "repo-wiki:local-scan": "node scripts/repo-wiki-local.mjs scan --repo .",
46
- "repo-wiki:local-plan": "node scripts/repo-wiki-local.mjs plan --repo .",
47
- "repo-wiki:local-lint-docs": "node scripts/repo-wiki-local.mjs lint-docs --repo .",
48
- "repo-wiki:local-compile": "node scripts/repo-wiki-local.mjs compile --repo .",
49
- "repo-wiki:local-lint": "node scripts/repo-wiki-local.mjs lint --repo .",
50
- "repo-wiki:prepare": "node scripts/repo-wiki-local.mjs prepare"
43
+ "repo-wiki:prepare": "node scripts/repo-wiki.mjs --source local prepare",
44
+ "repo-wiki:local": "node scripts/repo-wiki.mjs --source local",
45
+ "repo-wiki:local-prepare": "node scripts/repo-wiki.mjs --source local prepare"
51
46
  },
52
47
  "bin": {
53
48
  "dev-loops": "./cli/index.mjs"
@@ -58,7 +53,6 @@
58
53
  },
59
54
  "devDependencies": {
60
55
  "@playwright/test": "^1.60.0",
61
- "c8": "^11.0.0",
62
56
  "tsx": "^4.22.0"
63
57
  },
64
58
  "pi": {
@@ -74,8 +68,7 @@
74
68
  },
75
69
  "dependencies": {
76
70
  "mermaid": "11.15.0",
77
- "zod": "^4.4.3",
78
- "@dev-loops/core": "^0.2.1"
71
+ "@dev-loops/core": "^0.2.3"
79
72
  },
80
73
  "repository": {
81
74
  "type": "git",
@@ -85,7 +78,7 @@
85
78
  "url": "https://github.com/mfittko/dev-loops/issues"
86
79
  },
87
80
  "homepage": "https://github.com/mfittko/dev-loops#readme",
88
- "version": "0.2.1",
81
+ "version": "0.2.3",
89
82
  "files": [
90
83
  "cli/",
91
84
  "lib/",
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
- import { defineSubcommand, isDirectCliRun } from "@dev-loops/core/cli/subcommand-runner";
3
+ import process from "node:process";
4
+ import { parseArgs } from "node:util";
5
+ import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
6
+ import { parsePositiveInteger } from "@dev-loops/core/cli/primitives";
4
7
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
8
  import {
6
9
  replyAndMaybeResolve,
@@ -9,34 +12,90 @@ import {
9
12
 
10
13
  export { hasCommitShaReference } from "./_review-thread-mutations.mjs";
11
14
 
12
- const { runAsScript } = defineSubcommand({
13
- name: "reply-resolve-review-thread --repo <owner/name> --pr <n> --comment-id <n> --thread-id <id> --body-file <path>",
14
- description: "Reply to a review thread comment and resolve the thread.",
15
- options: [
16
- { flag: "--repo", type: "string", required: true, description: "GitHub repository slug" },
17
- { flag: "--pr", type: "pr", required: true, description: "Pull request number" },
18
- { flag: "--comment-id", type: "positiveInt", required: true, description: "GraphQL databaseId of the comment to reply to" },
19
- { flag: "--thread-id", type: "string", required: true, description: "GraphQL node ID of the review thread" },
20
- { flag: "--body-file", type: "string", required: true, description: "Path to file containing the reply body text" },
21
- ],
22
- async run({ repo, pr, commentId, threadId, bodyFile }) {
23
- parseRepoSlug(repo);
24
-
25
- const rawBody = await readFile(bodyFile, "utf8");
26
- if (rawBody.trim().length === 0) throw new Error("--body-file must contain non-empty text");
27
- validateResolutionMessage(rawBody);
28
-
29
- const result = await replyAndMaybeResolve(
30
- { repo, pr, commentId, threadId, body: rawBody, resolve: true },
31
- { env: process.env, ghCommand: "gh" },
32
- );
33
-
34
- process.stdout.write(JSON.stringify({
35
- ok: true, repo, pr, commentId, threadId,
36
- replyId: result.replyId, replyUrl: result.replyUrl, resolved: true,
37
- }) + "\n");
15
+ const USAGE = `Usage: dev-loops reply-resolve-review-thread --repo <owner/name> --pr <n> --comment-id <n> --thread-id <id> --body-file <path>
16
+
17
+ Reply to a review thread comment and resolve the thread.
18
+
19
+ Required:
20
+ --repo <owner/name> GitHub repository slug
21
+ --pr <n> Pull request number
22
+ --comment-id <n> GraphQL databaseId of the comment to reply to
23
+ --thread-id <id> GraphQL node ID of the review thread
24
+ --body-file <path> Path to file containing the reply body text`;
25
+
26
+ function parseError(message) {
27
+ return Object.assign(new Error(message), { usage: USAGE });
28
+ }
29
+
30
+ function parseCliArgs(argv) {
31
+ let values;
32
+ try {
33
+ ({ values } = parseArgs({
34
+ args: argv,
35
+ options: {
36
+ repo: { type: "string" },
37
+ pr: { type: "string" },
38
+ "comment-id": { type: "string" },
39
+ "thread-id": { type: "string" },
40
+ "body-file": { type: "string" },
41
+ help: { type: "boolean", short: "h" },
42
+ },
43
+ strict: true,
44
+ allowPositionals: false,
45
+ }));
46
+ } catch (err) {
47
+ throw parseError(err instanceof Error ? err.message : String(err));
48
+ }
49
+
50
+ if (values.help) {
51
+ return { help: true };
52
+ }
53
+
54
+ if (!values.repo) throw parseError("Missing required option: --repo");
55
+ if (!values.pr) throw parseError("Missing required option: --pr");
56
+ if (!values["comment-id"]) throw parseError("Missing required option: --comment-id");
57
+ if (!values["thread-id"]) throw parseError("Missing required option: --thread-id");
58
+ if (!values["body-file"]) throw parseError("Missing required option: --body-file");
59
+
60
+ const repoSlug = values.repo;
61
+ parseRepoSlug(repoSlug);
62
+ const pr = parsePositiveInteger(values.pr, "--pr", parseError);
63
+ const commentId = parsePositiveInteger(values["comment-id"], "--comment-id", parseError);
64
+
65
+ return { repo: repoSlug, pr, commentId, threadId: values["thread-id"], bodyFile: values["body-file"] };
66
+ }
67
+
68
+ async function run(argv) {
69
+ const parsed = parseCliArgs(argv);
70
+ if (parsed.help) {
71
+ process.stdout.write(`${USAGE}\n`);
38
72
  return 0;
39
- },
40
- });
73
+ }
74
+
75
+ const { repo: repoSlug, pr, commentId, threadId, bodyFile } = parsed;
76
+ const rawBody = await readFile(bodyFile, "utf8");
77
+ if (rawBody.trim().length === 0) throw new Error("--body-file must contain non-empty text");
78
+ validateResolutionMessage(rawBody);
79
+
80
+ const result = await replyAndMaybeResolve(
81
+ { repo: repoSlug, pr, commentId, threadId, body: rawBody, resolve: true },
82
+ { env: process.env, ghCommand: "gh" },
83
+ );
84
+
85
+ process.stdout.write(JSON.stringify({
86
+ ok: true, repo: repoSlug, pr, commentId, threadId,
87
+ replyId: result.replyId, replyUrl: result.replyUrl, resolved: true,
88
+ }) + "\n");
89
+ return 0;
90
+ }
41
91
 
42
- if (isDirectCliRun(import.meta.url)) { runAsScript(); }
92
+ if (isDirectCliRun(import.meta.url)) {
93
+ run(process.argv.slice(2)).then(
94
+ (code) => { process.exitCode = typeof code === "number" ? code : 0; },
95
+ (error) => {
96
+ const usage = error instanceof Error && typeof error.usage === "string" ? error.usage : undefined;
97
+ process.stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error), ...(usage && { usage }) })}\n`);
98
+ process.exitCode = 1;
99
+ },
100
+ );
101
+ }
@@ -1,11 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { defineSubcommand, isDirectCliRun } from "@dev-loops/core/cli/subcommand-runner";
4
+ import process from "node:process";
5
+ import { parseArgs } from "node:util";
6
+ import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
5
7
 
6
8
  const CHECKPOINT_FILE = ".pi/dev-loop-retrospective-checkpoint.json";
7
9
  const ALLOWED_STATES = new Set(["required", "complete", "skipped", "none", "missing"]);
8
10
 
11
+ const USAGE = `Usage: dev-loops checkpoint-contract --state <state> [--notes <text>] [--reason <text>]
12
+
13
+ Write .pi/dev-loop-retrospective-checkpoint.json using the retrospective contract format.
14
+
15
+ Required:
16
+ --state <state> Checkpoint state (required, complete, skipped, none, missing)
17
+
18
+ Optional:
19
+ --notes <text> Required when --state is complete
20
+ --reason <text> Required when --state is skipped`;
21
+
22
+ function parseError(message) {
23
+ return Object.assign(new Error(message), { usage: USAGE });
24
+ }
25
+
9
26
  export function buildRetrospectiveCheckpointPayload({ state, notes = null, reason = null }, now = new Date()) {
10
27
  const timestamp = now.toISOString();
11
28
  if (state === "complete") return { state, completedAt: timestamp, notes };
@@ -16,34 +33,66 @@ export function buildRetrospectiveCheckpointPayload({ state, notes = null, reaso
16
33
  throw new Error(`Unsupported state: ${state}`);
17
34
  }
18
35
 
19
- const { runAsScript } = defineSubcommand({
20
- name: "checkpoint-contract --state <state>",
21
- description: "Write .pi/dev-loop-retrospective-checkpoint.json using the retrospective contract format.",
22
- options: [
23
- { flag: "--state", type: "string", required: true, choices: [...ALLOWED_STATES],
24
- description: "Checkpoint state (required, complete, skipped, none, missing)" },
25
- { flag: "--notes", type: "string", description: "Required when --state is complete" },
26
- { flag: "--reason", type: "string", description: "Required when --state is skipped" },
27
- ],
28
- async run({ state, notes, reason }, { args: _args, usage }) {
29
- if (!ALLOWED_STATES.has(state)) {
30
- throw Object.assign(new Error(`Invalid --state: "${state}". Allowed: ${[...ALLOWED_STATES].join(", ")}.`), { usage });
31
- }
32
- if (state === "complete" && !notes) {
33
- throw Object.assign(new Error('state "complete" requires --notes'), { usage });
34
- }
35
- if (state === "skipped" && !reason) {
36
- throw Object.assign(new Error('state "skipped" requires --reason'), { usage });
37
- }
38
-
39
- const cwd = process.cwd();
40
- const payload = buildRetrospectiveCheckpointPayload({ state, notes, reason });
41
- const checkpointPath = path.join(cwd, CHECKPOINT_FILE);
42
- await mkdir(path.dirname(checkpointPath), { recursive: true });
43
- await writeFile(checkpointPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
44
- process.stdout.write(JSON.stringify({ ok: true, path: CHECKPOINT_FILE, checkpoint: payload }) + "\n");
36
+ function parseCliArgs(argv) {
37
+ let values;
38
+ try {
39
+ ({ values } = parseArgs({
40
+ args: argv,
41
+ options: {
42
+ state: { type: "string" },
43
+ notes: { type: "string" },
44
+ reason: { type: "string" },
45
+ help: { type: "boolean", short: "h" },
46
+ },
47
+ strict: true,
48
+ allowPositionals: false,
49
+ }));
50
+ } catch (err) {
51
+ throw parseError(err instanceof Error ? err.message : String(err));
52
+ }
53
+
54
+ if (values.help) {
55
+ return { help: true };
56
+ }
57
+
58
+ if (!values.state) throw parseError("Missing required option: --state");
59
+ const state = values.state;
60
+ if (!ALLOWED_STATES.has(state)) {
61
+ throw parseError(`--state must be one of: ${[...ALLOWED_STATES].join(", ")}`);
62
+ }
63
+ if (state === "complete" && !values.notes) {
64
+ throw parseError('state "complete" requires --notes');
65
+ }
66
+ if (state === "skipped" && !values.reason) {
67
+ throw parseError('state "skipped" requires --reason');
68
+ }
69
+
70
+ return { state, notes: values.notes ?? null, reason: values.reason ?? null };
71
+ }
72
+
73
+ async function run(argv) {
74
+ const parsed = parseCliArgs(argv);
75
+ if (parsed.help) {
76
+ process.stdout.write(`${USAGE}\n`);
45
77
  return 0;
46
- },
47
- });
78
+ }
48
79
 
49
- if (isDirectCliRun(import.meta.url)) { runAsScript(); }
80
+ const { state, notes, reason } = parsed;
81
+ const payload = buildRetrospectiveCheckpointPayload({ state, notes, reason });
82
+ const checkpointPath = path.join(process.cwd(), CHECKPOINT_FILE);
83
+ await mkdir(path.dirname(checkpointPath), { recursive: true });
84
+ await writeFile(checkpointPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
85
+ process.stdout.write(JSON.stringify({ ok: true, path: CHECKPOINT_FILE, checkpoint: payload }) + "\n");
86
+ return 0;
87
+ }
88
+
89
+ if (isDirectCliRun(import.meta.url)) {
90
+ run(process.argv.slice(2)).then(
91
+ (code) => { process.exitCode = typeof code === "number" ? code : 0; },
92
+ (error) => {
93
+ const usage = error instanceof Error && typeof error.usage === "string" ? error.usage : undefined;
94
+ process.stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error), ...(usage && { usage }) })}\n`);
95
+ process.exitCode = 1;
96
+ },
97
+ );
98
+ }
@@ -366,12 +366,31 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
366
366
  // Detect internal tooling PRs — suppress Copilot review request step entirely.
367
367
  // Internal-only PRs (scripts/docs/tests/config) skip the request, not just the wait.
368
368
  let internalOnlySkipCopilot = false;
369
- // Skip internal detection in sequential stub/test mode to avoid consuming stub entries.
370
- // Claims-mode stubs handle interleaved calls; detection runs normally.
371
- if (!env.GH_SEQUENCE_PATH || env.GH_STUB_MODE === "claims") {
372
- if (options.watchStatus === undefined &&
369
+ // Config opt-out (#832): maxCopilotRounds: 0 disables the external Copilot review
370
+ // gate for the repo (local-harness-only review). Treat it like an internal-only PR
371
+ // skip the request, not just the wait — regardless of stub/sequence mode. This
372
+ // mirrors the gate-coordination side, which routes maxCopilotRounds: 0 to internal_only.
373
+ if (refinementConfig?.maxCopilotRounds === 0 &&
374
+ options.watchStatus === undefined &&
373
375
  (interpretation.state === STATE.PR_READY_NO_FEEDBACK ||
374
376
  interpretation.state === STATE.READY_TO_REREQUEST_REVIEW)) {
377
+ internalOnlySkipCopilot = true;
378
+ interpretation = {
379
+ ...interpretation,
380
+ state: STATE.INTERNAL_TOOLING_DIRECT_GATE,
381
+ nextAction: NEXT_ACTIONS[STATE.INTERNAL_TOOLING_DIRECT_GATE],
382
+ allowedTransitions: TRANSITIONS[STATE.INTERNAL_TOOLING_DIRECT_GATE] || [STATE.DONE],
383
+ };
384
+ }
385
+ // Skip internal detection in sequential stub/test mode to avoid consuming stub entries.
386
+ // Claims-mode stubs handle interleaved calls; detection runs normally.
387
+ if (
388
+ !internalOnlySkipCopilot &&
389
+ (!env.GH_SEQUENCE_PATH || env.GH_STUB_MODE === "claims") &&
390
+ options.watchStatus === undefined &&
391
+ (interpretation.state === STATE.PR_READY_NO_FEEDBACK ||
392
+ interpretation.state === STATE.READY_TO_REREQUEST_REVIEW)
393
+ ) {
375
394
  try {
376
395
  const internalCheck = await detectPrInternalOnly(options, { env, ghCommand });
377
396
  if (internalCheck.ok && internalCheck.internalOnly) {
@@ -387,7 +406,6 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
387
406
  // Best-effort: if detection fails, fall through to normal request behavior
388
407
  }
389
408
  }
390
- }
391
409
 
392
410
  let reviewRequestStatus;
393
411
  const shouldRequestReview = !internalOnlySkipCopilot && options.watchStatus === undefined
@@ -22,6 +22,7 @@ import {
22
22
  ASYNC_START_STATUS,
23
23
  buildAsyncStartRejection,
24
24
  validateAsyncStartContext,
25
+ resolveEffectiveAsyncStartMode,
25
26
  } from "@dev-loops/core/loop/async-start-contract";
26
27
  import { loadDevLoopConfig, resolveConductorModel, resolveAutonomyStopAt, resolveWorkflowConfig } from "@dev-loops/core/config";
27
28
  const USAGE = `Usage: outer-loop.mjs --repo <owner/name> --pr <number>
@@ -286,9 +287,11 @@ export async function runOuterLoop(options, { env = process.env, ghCommand = "gh
286
287
  devLoopConfig = loaded.config;
287
288
  }
288
289
  }
289
- const asyncStartMode = devLoopConfig === null
290
+ const configuredAsyncStartMode = devLoopConfig === null
290
291
  ? "required"
291
292
  : resolveWorkflowConfig(devLoopConfig, "asyncStartMode");
293
+ // Relaxed to "allowed" under the Claude harness (Pi async-start contract does not apply).
294
+ const asyncStartMode = resolveEffectiveAsyncStartMode(configuredAsyncStartMode, env);
292
295
  const asyncStartValidation = validateAsyncStartContext({ env, isSnapshotMode, asyncStartMode });
293
296
  if (asyncStartValidation.status === ASYNC_START_STATUS.REJECTED) {
294
297
  return buildAsyncStartRejection(asyncStartValidation);
@@ -16,6 +16,7 @@ import {
16
16
  import {
17
17
  validateAsyncStartContext,
18
18
  buildAsyncStartRejection,
19
+ resolveEffectiveAsyncStartMode,
19
20
  ASYNC_START_STATUS,
20
21
  } from "@dev-loops/core/loop/async-start-contract";
21
22
  import { detectRepoSlug } from "@dev-loops/core/github/repo-slug";
@@ -411,7 +412,10 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
411
412
  ? (STRATEGY_ASYNC_DISPATCH[bundle.selectedStrategy] ?? false)
412
413
  : false;
413
414
  if (requiresAsyncDispatch) {
414
- const validation = validateAsyncStartContext({ env: effectiveEnv, asyncStartMode });
415
+ // The configured async-start mode is relaxed to "allowed" under the Claude
416
+ // harness, where the Pi async-subagent start contract does not apply.
417
+ const effectiveAsyncStartMode = resolveEffectiveAsyncStartMode(asyncStartMode, effectiveEnv);
418
+ const validation = validateAsyncStartContext({ env: effectiveEnv, asyncStartMode: effectiveAsyncStartMode });
415
419
  if (validation.status === ASYNC_START_STATUS.REJECTED) {
416
420
  return buildAsyncStartRejection(validation);
417
421
  }
@@ -1,42 +1,36 @@
1
1
  #!/usr/bin/env node
2
- // Primary repo-wiki wrapper. Proxies to the published @mfittko/repo-wiki npm package
3
- // at a pinned version, after validating that the consumer-repo config exists.
2
+ // Primary repo-wiki wrapper. Proxies to either the published @mfittko/repo-wiki
3
+ // npm package (default) or a pinned local source checkout when --source local or
4
+ // REPO_WIKI_SOURCE=local is set.
4
5
  import { spawnSync } from "node:child_process";
5
6
  import { existsSync } from "node:fs";
7
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
8
  import path from "node:path";
7
9
  import process from "node:process";
8
10
  import { fileURLToPath } from "node:url";
11
+ import { parseArgs } from "node:util";
9
12
 
10
13
  import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
11
14
 
12
15
  // Pinned to the latest published release at the time this slice was opened.
13
- // Bump deliberately when the consumer repo wants to adopt a newer release.
14
16
  export const REPO_WIKI_NPM_PACKAGE = "@mfittko/repo-wiki";
15
17
  export const REPO_WIKI_NPM_VERSION = "0.2.6";
18
+ export const REPO_WIKI_MIN_NODE_MAJOR = 20;
19
+
20
+ export const REPO_WIKI_GIT_URL = "https://github.com/mfittko/repo-wiki.git";
21
+ export const REPO_WIKI_REF = "d7e772e3d702a75896a6f4eec574a4e4e5bfa6dd";
22
+ export const REPO_WIKI_LOCAL_MIN_NODE_MAJOR = 24;
16
23
 
17
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
25
  const PROJECT_ROOT = path.resolve(__dirname, "..");
19
26
 
20
- export const REPO_WIKI_MIN_NODE_MAJOR = 20;
27
+ export const REPO_WIKI_LOCAL_CONFIG_PATH = resolveRepoWikiConfigPath();
28
+ export const REPO_WIKI_SCHEMA_PATH = path.join(PROJECT_ROOT, ".llmwiki", "schema.md");
21
29
 
22
30
  export function resolveRepoWikiConfigPath(projectRoot = PROJECT_ROOT) {
23
31
  return path.join(projectRoot, ".llmwiki", "config.json");
24
32
  }
25
33
 
26
- export const REPO_WIKI_CONFIG_PATH = resolveRepoWikiConfigPath();
27
- export const REPO_WIKI_SCHEMA_PATH = path.join(PROJECT_ROOT, ".llmwiki", "schema.md");
28
-
29
- export function parseCliArgs(argv) {
30
- const args = Array.isArray(argv) ? [...argv] : [];
31
- if (args.length === 0) {
32
- return { passthroughArgs: ["--help"] };
33
- }
34
- if (args[0] === "--help" || args[0] === "-h") {
35
- return { passthroughArgs: ["--help"] };
36
- }
37
- return { passthroughArgs: args };
38
- }
39
-
40
34
  export function assertSupportedNodeVersion(version = process.versions.node) {
41
35
  const major = Number.parseInt(String(version).split(".")[0] ?? "", 10);
42
36
  if (!Number.isInteger(major) || major < REPO_WIKI_MIN_NODE_MAJOR) {
@@ -46,15 +40,21 @@ export function assertSupportedNodeVersion(version = process.versions.node) {
46
40
  }
47
41
  }
48
42
 
43
+ export function assertSupportedLocalNodeVersion(version = process.versions.node) {
44
+ const major = Number.parseInt(String(version).split(".")[0] ?? "", 10);
45
+ if (!Number.isInteger(major) || major < REPO_WIKI_LOCAL_MIN_NODE_MAJOR) {
46
+ throw new Error(
47
+ `repo-wiki local helper requires Node.js ${REPO_WIKI_LOCAL_MIN_NODE_MAJOR}+ because repo-wiki itself requires Node.js ${REPO_WIKI_LOCAL_MIN_NODE_MAJOR}+. Current runtime: ${version}`,
48
+ );
49
+ }
50
+ }
51
+
49
52
  export function assertConsumerConfigPresent({
50
- configPath = REPO_WIKI_CONFIG_PATH,
53
+ configPath = REPO_WIKI_LOCAL_CONFIG_PATH,
51
54
  projectRoot = PROJECT_ROOT,
52
55
  } = {}) {
53
- // When callers override projectRoot without overriding configPath, derive the
54
- // expected config path from the override so tests and other call sites stay
55
- // consistent with the per-project layout.
56
56
  const resolvedConfigPath =
57
- configPath === REPO_WIKI_CONFIG_PATH ? resolveRepoWikiConfigPath(projectRoot) : configPath;
57
+ configPath === REPO_WIKI_LOCAL_CONFIG_PATH ? resolveRepoWikiConfigPath(projectRoot) : configPath;
58
58
  if (!existsSync(resolvedConfigPath)) {
59
59
  throw new Error(
60
60
  `Missing required repo-wiki config at ${path.relative(projectRoot, resolvedConfigPath) || resolvedConfigPath}.\n` +
@@ -73,38 +73,185 @@ export function buildNpxInvocation({
73
73
  return ["npx", "--yes", `${packageName}@${version}`, ...passthroughArgs];
74
74
  }
75
75
 
76
- export function runNpxInvocation({
76
+ function run(command, args, options = {}) {
77
+ const result = spawnSync(command, args, {
78
+ cwd: options.cwd ?? PROJECT_ROOT,
79
+ env: { ...process.env, ...(options.env ?? {}) },
80
+ stdio: options.stdio ?? "inherit",
81
+ encoding: options.encoding ?? "utf8",
82
+ });
83
+
84
+ if (result.error) {
85
+ throw result.error;
86
+ }
87
+ if (result.status !== 0 && !options.allowNonZero) {
88
+ const printable = [command, ...args].join(" ");
89
+ throw new Error(`Command failed (${result.status ?? "unknown"}): ${printable}`);
90
+ }
91
+
92
+ return result;
93
+ }
94
+
95
+ function runNpxInvocation({
77
96
  command,
78
97
  args,
79
98
  cwd = PROJECT_ROOT,
80
99
  env = process.env,
81
100
  } = {}) {
82
- const result = spawnSync(command, args, {
83
- cwd,
84
- env,
101
+ return run(command, args, { cwd, env, stdio: "inherit", allowNonZero: true });
102
+ }
103
+
104
+ const VALID_REPO_WIKI_SOURCES = new Set(["npm", "local"]);
105
+
106
+ export function parseCliArgs(argv) {
107
+ const args = Array.isArray(argv) ? [...argv] : [];
108
+ const { values } = parseArgs({
109
+ args,
110
+ options: {
111
+ source: { type: "string" },
112
+ },
113
+ strict: false,
114
+ });
115
+
116
+ const sourceEnv = process.env.REPO_WIKI_SOURCE;
117
+ const source = values.source || sourceEnv || "npm";
118
+ if (!VALID_REPO_WIKI_SOURCES.has(source)) {
119
+ throw new Error(`Unknown repo-wiki source "${source}". Valid sources: npm, local`);
120
+ }
121
+
122
+ const passthroughArgs = [];
123
+ for (let i = 0; i < args.length; i++) {
124
+ const a = args[i];
125
+ if (a === "--source") {
126
+ i++;
127
+ continue;
128
+ }
129
+ if (a.startsWith("--source=")) continue;
130
+ passthroughArgs.push(a);
131
+ }
132
+
133
+ if (passthroughArgs.length === 0 || passthroughArgs[0] === "help" || passthroughArgs[0] === "--help" || passthroughArgs[0] === "-h") {
134
+ return { source, prepareOnly: false, passthroughArgs: ["--help"] };
135
+ }
136
+
137
+ if (passthroughArgs[0] === "prepare") {
138
+ return { source, prepareOnly: true, passthroughArgs: [] };
139
+ }
140
+
141
+ return { source, prepareOnly: false, passthroughArgs };
142
+ }
143
+
144
+ export function resolveRepoWikiPaths(projectRoot = PROJECT_ROOT, ref = REPO_WIKI_REF) {
145
+ const baseDir = path.join(projectRoot, ".tmp", "repo-wiki", ref);
146
+ const sourceDir = path.join(baseDir, "source");
147
+ const cliPath = path.join(sourceDir, "dist", "bin", "repo-wiki.js");
148
+ const buildStampPath = path.join(baseDir, "build-stamp.json");
149
+ return { projectRoot, baseDir, sourceDir, cliPath, buildStampPath };
150
+ }
151
+
152
+ async function readBuildStamp(buildStampPath) {
153
+ try {
154
+ return JSON.parse(await readFile(buildStampPath, "utf8"));
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ async function writeBuildStamp(buildStampPath) {
161
+ await writeFile(buildStampPath, JSON.stringify({ ref: REPO_WIKI_REF }, null, 2) + "\n", "utf8");
162
+ }
163
+
164
+ export async function ensureRepoWikiPrepared(projectRoot = PROJECT_ROOT) {
165
+ assertSupportedLocalNodeVersion();
166
+ const { baseDir, sourceDir, cliPath, buildStampPath } = resolveRepoWikiPaths(projectRoot);
167
+ await mkdir(baseDir, { recursive: true });
168
+
169
+ let currentHead = null;
170
+ try {
171
+ run("git", ["-C", sourceDir, "rev-parse", "--is-inside-work-tree"], { stdio: "ignore" });
172
+ currentHead = run("git", ["-C", sourceDir, "rev-parse", "HEAD"], { stdio: "pipe" }).stdout.trim();
173
+ } catch {
174
+ run("git", ["clone", REPO_WIKI_GIT_URL, sourceDir], { cwd: baseDir });
175
+ }
176
+
177
+ if (currentHead !== REPO_WIKI_REF) {
178
+ run("git", ["-C", sourceDir, "fetch", "origin", REPO_WIKI_REF, "--depth", "1"]);
179
+ run("git", ["-C", sourceDir, "checkout", "--force", REPO_WIKI_REF]);
180
+ }
181
+
182
+ const stamp = await readBuildStamp(buildStampPath);
183
+ if (stamp?.ref !== REPO_WIKI_REF) {
184
+ run("npm", ["install", "--silent"], { cwd: sourceDir });
185
+ run("npm", ["run", "build", "--silent"], { cwd: sourceDir });
186
+ await writeBuildStamp(buildStampPath);
187
+ } else {
188
+ try {
189
+ run(process.execPath, [cliPath, "--help"], { stdio: "ignore" });
190
+ } catch {
191
+ run("npm", ["install", "--silent"], { cwd: sourceDir });
192
+ run("npm", ["run", "build", "--silent"], { cwd: sourceDir });
193
+ await writeBuildStamp(buildStampPath);
194
+ }
195
+ }
196
+
197
+ return resolveRepoWikiPaths(projectRoot);
198
+ }
199
+
200
+ async function runRepoWikiNpm(passthroughArgs, projectRoot) {
201
+ assertSupportedNodeVersion();
202
+ assertConsumerConfigPresent({ projectRoot });
203
+ const invocation = buildNpxInvocation({ passthroughArgs });
204
+ const result = runNpxInvocation({ command: invocation[0], args: invocation.slice(1), cwd: projectRoot });
205
+
206
+ if (result.status !== 0) {
207
+ return { ok: false, status: result.status ?? 1, source: "npm", invocation };
208
+ }
209
+ return { ok: true, status: 0, source: "npm", invocation };
210
+ }
211
+
212
+ async function _runRepoWikiLocal(passthroughArgs, projectRoot) {
213
+ const { cliPath } = await ensureRepoWikiPrepared(projectRoot);
214
+ const result = spawnSync(process.execPath, [cliPath, ...passthroughArgs], {
215
+ cwd: projectRoot,
216
+ env: process.env,
85
217
  stdio: "inherit",
86
218
  });
87
219
  if (result.error) {
88
220
  throw result.error;
89
221
  }
90
- return result;
222
+ if (result.status !== 0) {
223
+ return { ok: false, status: result.status ?? 1, source: "local", cliPath };
224
+ }
225
+ return { ok: true, status: 0, source: "local", cliPath };
91
226
  }
92
227
 
93
228
  // Composable entry point: returns a structured result instead of calling
94
229
  // process.exit so importers can reuse this wrapper without terminating the host
95
230
  // process. The direct-run block below owns the process-level exit-code mapping.
231
+ // Convenience export for callers that explicitly want the pinned source path.
232
+ export async function runRepoWikiLocal(argv, projectRoot = PROJECT_ROOT) {
233
+ const args = argv.some((a) => a === "--source" || a.startsWith("--source="))
234
+ ? argv
235
+ : ["--source", "local", ...argv];
236
+ return runRepoWiki(args, projectRoot);
237
+ }
238
+
96
239
  export async function runRepoWiki(argv, projectRoot = PROJECT_ROOT) {
97
- assertSupportedNodeVersion();
98
- assertConsumerConfigPresent({ projectRoot });
99
- const { passthroughArgs } = parseCliArgs(argv);
100
- const invocation = buildNpxInvocation({ passthroughArgs });
240
+ const { source, prepareOnly, passthroughArgs } = parseCliArgs(argv);
101
241
 
102
- const result = runNpxInvocation({ command: invocation[0], args: invocation.slice(1), cwd: projectRoot });
242
+ if (source === "local") {
243
+ if (prepareOnly) {
244
+ const { cliPath } = await ensureRepoWikiPrepared(projectRoot);
245
+ return { ok: true, status: 0, source: "local", prepared: true, cliPath };
246
+ }
247
+ return _runRepoWikiLocal(passthroughArgs, projectRoot);
248
+ }
103
249
 
104
- if (result.status !== 0) {
105
- return { ok: false, status: result.status ?? 1, invocation };
250
+ if (prepareOnly) {
251
+ // prepare is meaningless for the npm source; treat as help.
252
+ return runRepoWikiNpm(["--help"], projectRoot);
106
253
  }
107
- return { ok: true, status: 0, invocation };
254
+ return runRepoWikiNpm(passthroughArgs, projectRoot);
108
255
  }
109
256
 
110
257
  if (isDirectCliRun(import.meta.url)) {
@@ -229,6 +229,7 @@ When unresolved feedback exists, use a narrow follow-up loop:
229
229
  - if the refreshed snapshot reports unresolved threads, re-enter the reply/resolve loop for the missed threads
230
230
  12. only after GitHub-side reply/resolve work is done for the addressed threads and the refreshed thread snapshot proves zero unresolved threads remain, decide whether another Copilot pass is desired
231
231
  - resolve the review-round cap from config via `resolveRefinementConfig(config, "maxCopilotRounds")` from `@dev-loops/core/config`; default config ships `maxCopilotRounds: 5`
232
+ - **Opt out entirely:** `maxCopilotRounds: 0` disables the external Copilot review gate for the repo — the loop runs `draft_gate → pre_approval_gate` with the local harness only, never requesting or waiting on Copilot. Use this when the repo has no Copilot reviewer configured or prefers local-harness-only review.
232
233
  - use the completed Copilot review-round count from `detect-copilot-loop-state.mjs` / `copilot-pr-handoff.mjs` as the current PR's review-round count
233
234
  - if completed review rounds have reached the maximum (default: 5), do **not** re-request Copilot review
234
235
  - when the round limit is reached **and** the refreshed thread snapshot proves zero unresolved threads **and** current-head CI is green or credibly green, treat that clean state as eligible for `pre_approval_gate` fallback instead of deadlocking on another Copilot rerequest
@@ -26,7 +26,7 @@ Required installed runtime contract docs are shared bundled copies under `../doc
26
26
 
27
27
  The main agent must **always** dispatch the `dev-loop` async subagent for any dev-loop work.
28
28
  Do not run `dev-loops loop startup` or any startup resolver in the main agent.
29
- The resolver requires `PI_SUBAGENT_RUN_ID` for async-required routes (the default `required` mode); the startup resolver can also run without it for non-async routes. Regardless, only the `dev-loop` async subagent runs it — never the main agent.
29
+ For async-required routes (config `workflow.asyncStartMode`, default `required`) the resolver needs an async run-id marker (`DEVLOOPS_RUN_ID`, or the `PI_SUBAGENT_RUN_ID` alias) that the Pi harness injects when it dispatches the async subagent; under the Claude Code harness the requirement is relaxed automatically (no marker needed). The startup resolver also runs without a marker for non-async routes. Regardless, only the `dev-loop` subagent runs it — never the main agent.
30
30
 
31
31
  ### Dev-loop subagent (post-dispatch)
32
32
 
@@ -86,7 +86,7 @@ Do not preload route packs before the resolver selects the strategy.
86
86
 
87
87
  ## Async dispatch
88
88
 
89
- **Async dispatch rule (enforced):** the resolver enforces fail-closed for GitHub-first strategies when `canonicalStateSummary.requiresAsyncDispatch` is `true` (default `required` mode). Inline invocation without `PI_SUBAGENT_RUN_ID` is rejected only for those routes. See [Startup procedure](#startup-procedure).
89
+ **Async dispatch rule (enforced):** the resolver fails closed for GitHub-first strategies when `canonicalStateSummary.requiresAsyncDispatch` is `true` (default `required` mode) inline invocation without an async run-id marker (`DEVLOOPS_RUN_ID`, or the `PI_SUBAGENT_RUN_ID` alias) is rejected for those routes. Under the Claude Code harness this requirement is relaxed automatically. See [Startup procedure](#startup-procedure).
90
90
 
91
91
 
92
92
  ## Fallback gate-comment poster
@@ -1,156 +0,0 @@
1
- #!/usr/bin/env node
2
- // Pinned-source fallback repo-wiki wrapper. Clones mfittko/repo-wiki at a fixed
3
- // commit, installs/builds it locally, and proxies the requested command against
4
- // this repo.
5
- //
6
- // This helper is retained for environments that prefer a pinned source checkout
7
- // over the published npm install path (deterministic source pin, controlled
8
- // GitHub-only network access, offline reproduction after the initial clone).
9
- // It still requires git access to https://github.com/mfittko/repo-wiki.git for
10
- // the initial clone/fetch step.
11
- //
12
- // The primary repo-wiki entrypoint is `scripts/repo-wiki.mjs` (npm-installed).
13
- import { spawnSync } from "node:child_process";
14
- import { mkdir, readFile, writeFile } from "node:fs/promises";
15
- import path from "node:path";
16
- import process from "node:process";
17
- import { fileURLToPath } from "node:url";
18
-
19
- import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
20
-
21
- export const REPO_WIKI_GIT_URL = "https://github.com/mfittko/repo-wiki.git";
22
- export const REPO_WIKI_REF = "d7e772e3d702a75896a6f4eec574a4e4e5bfa6dd";
23
- export const REPO_WIKI_MIN_NODE_MAJOR = 24;
24
-
25
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
- const PROJECT_ROOT = path.resolve(__dirname, "..");
27
-
28
- export function parseCliArgs(argv) {
29
- const args = Array.isArray(argv) ? [...argv] : [];
30
- if (args.length === 0) {
31
- return { prepareOnly: false, passthroughArgs: ["--help"] };
32
- }
33
- if (args[0] === "prepare") {
34
- return { prepareOnly: true, passthroughArgs: [] };
35
- }
36
- return { prepareOnly: false, passthroughArgs: args };
37
- }
38
-
39
- export function resolveRepoWikiPaths(projectRoot = PROJECT_ROOT, ref = REPO_WIKI_REF) {
40
- const baseDir = path.join(projectRoot, ".tmp", "repo-wiki", ref);
41
- const sourceDir = path.join(baseDir, "source");
42
- const cliPath = path.join(sourceDir, "dist", "bin", "repo-wiki.js");
43
- const buildStampPath = path.join(baseDir, "build-stamp.json");
44
- return { projectRoot, baseDir, sourceDir, cliPath, buildStampPath };
45
- }
46
-
47
- export function assertSupportedNodeVersion(version = process.versions.node) {
48
- const major = Number.parseInt(String(version).split(".")[0] ?? "", 10);
49
- if (!Number.isInteger(major) || major < REPO_WIKI_MIN_NODE_MAJOR) {
50
- throw new Error(
51
- `repo-wiki local helper requires Node.js ${REPO_WIKI_MIN_NODE_MAJOR}+ because repo-wiki itself requires Node.js ${REPO_WIKI_MIN_NODE_MAJOR}+. Current runtime: ${version}`,
52
- );
53
- }
54
- }
55
-
56
- function run(command, args, options = {}) {
57
- const result = spawnSync(command, args, {
58
- cwd: options.cwd ?? PROJECT_ROOT,
59
- env: { ...process.env, ...(options.env ?? {}) },
60
- stdio: options.stdio ?? "inherit",
61
- encoding: options.encoding ?? "utf8",
62
- });
63
-
64
- if (result.error) {
65
- throw result.error;
66
- }
67
- if (result.status !== 0) {
68
- const printable = [command, ...args].join(" ");
69
- throw new Error(`Command failed (${result.status ?? "unknown"}): ${printable}`);
70
- }
71
-
72
- return result;
73
- }
74
-
75
- async function readBuildStamp(buildStampPath) {
76
- try {
77
- return JSON.parse(await readFile(buildStampPath, "utf8"));
78
- } catch {
79
- return null;
80
- }
81
- }
82
-
83
- async function writeBuildStamp(buildStampPath) {
84
- await writeFile(buildStampPath, JSON.stringify({ ref: REPO_WIKI_REF }, null, 2) + "\n", "utf8");
85
- }
86
-
87
- export async function ensureRepoWikiPrepared(projectRoot = PROJECT_ROOT) {
88
- assertSupportedNodeVersion();
89
- const { baseDir, sourceDir, cliPath, buildStampPath } = resolveRepoWikiPaths(projectRoot);
90
- await mkdir(baseDir, { recursive: true });
91
-
92
- let currentHead = null;
93
- try {
94
- run("git", ["-C", sourceDir, "rev-parse", "--is-inside-work-tree"], { stdio: "ignore" });
95
- currentHead = run("git", ["-C", sourceDir, "rev-parse", "HEAD"], { stdio: "pipe" }).stdout.trim();
96
- } catch {
97
- run("git", ["clone", REPO_WIKI_GIT_URL, sourceDir], { cwd: baseDir });
98
- }
99
-
100
- if (currentHead !== REPO_WIKI_REF) {
101
- run("git", ["-C", sourceDir, "fetch", "origin", REPO_WIKI_REF, "--depth", "1"]);
102
- run("git", ["-C", sourceDir, "checkout", "--force", REPO_WIKI_REF]);
103
- }
104
-
105
- const stamp = await readBuildStamp(buildStampPath);
106
- if (stamp?.ref !== REPO_WIKI_REF) {
107
- run("npm", ["install", "--silent"], { cwd: sourceDir });
108
- run("npm", ["run", "build", "--silent"], { cwd: sourceDir });
109
- await writeBuildStamp(buildStampPath);
110
- } else {
111
- try {
112
- run(process.execPath, [cliPath, "--help"], { stdio: "ignore" });
113
- } catch {
114
- run("npm", ["install", "--silent"], { cwd: sourceDir });
115
- run("npm", ["run", "build", "--silent"], { cwd: sourceDir });
116
- await writeBuildStamp(buildStampPath);
117
- }
118
- }
119
-
120
- return resolveRepoWikiPaths(projectRoot);
121
- }
122
-
123
- // Composable entry point: returns a structured result instead of calling
124
- // process.exit so importers can reuse this wrapper without terminating the host
125
- // process. The direct-run block below owns the process-level exit-code mapping.
126
- export async function runRepoWikiLocal(argv, projectRoot = PROJECT_ROOT) {
127
- const { prepareOnly, passthroughArgs } = parseCliArgs(argv);
128
- const { cliPath } = await ensureRepoWikiPrepared(projectRoot);
129
- if (prepareOnly) {
130
- return { ok: true, status: 0, prepared: true, cliPath };
131
- }
132
-
133
- const result = spawnSync(process.execPath, [cliPath, ...passthroughArgs], {
134
- cwd: projectRoot,
135
- env: process.env,
136
- stdio: "inherit",
137
- });
138
- if (result.error) {
139
- throw result.error;
140
- }
141
- if (result.status !== 0) {
142
- return { ok: false, status: result.status ?? 1, cliPath };
143
- }
144
- return { ok: true, status: 0, prepared: true, cliPath };
145
- }
146
-
147
- if (isDirectCliRun(import.meta.url)) {
148
- runRepoWikiLocal(process.argv.slice(2)).then((result) => {
149
- if (!result.ok) {
150
- process.exitCode = result.status;
151
- }
152
- }).catch((error) => {
153
- console.error(error instanceof Error ? error.message : String(error));
154
- process.exitCode = 1;
155
- });
156
- }