dev-loops 0.2.0 → 0.2.2

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,7 +1,11 @@
1
1
  {
2
2
  "name": "dev-loops",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Agent-harness-agnostic dev-loop: agents, skills, and hooks for GitHub/Copilot-driven development loops.",
5
+ "author": {
6
+ "name": "Manuel Fittko",
7
+ "email": "manuel.fittko@sofatutor.com"
8
+ },
5
9
  "license": "MIT",
6
10
  "homepage": "https://github.com/mfittko/dev-loops#readme",
7
11
  "repository": "https://github.com/mfittko/dev-loops.git",
@@ -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
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "dev-loops",
3
+ "owner": {
4
+ "name": "Manuel Fittko",
5
+ "email": "manuel.fittko@sofatutor.com"
6
+ },
7
+ "description": "Turn GitHub issues into merged PRs: agent-harness-agnostic dev-loop agents, skills, and hooks for Claude Code.",
8
+ "plugins": [
9
+ {
10
+ "name": "dev-loops",
11
+ "source": "./.claude",
12
+ "description": "Agent-harness-agnostic dev-loop: agents, skills, and hooks for GitHub/Copilot-driven development loops.",
13
+ "homepage": "https://github.com/mfittko/dev-loops#readme",
14
+ "repository": "https://github.com/mfittko/dev-loops.git",
15
+ "license": "MIT",
16
+ "keywords": ["dev-loop", "workflow", "github", "copilot", "claude-code", "plugin"]
17
+ }
18
+ ]
19
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,50 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.2.2
6
+
7
+ ### Fixed
8
+
9
+ - **Claude Code: dev-loop no longer dead-ends on the async-start contract** (#830). Running
10
+ `/dev-loop` from the installed plugin failed immediately because `dev-loops loop startup`
11
+ enforces an async-start contract — it requires a run-id env marker (`DEVLOOPS_RUN_ID` /
12
+ `PI_SUBAGENT_RUN_ID`) that Pi injects when dispatching an async subagent but Claude Code's
13
+ Agent tool does not. That contract guards against detached, uninspectable background
14
+ processes, a risk that does not exist under Claude's Agent model (each subagent run is
15
+ visible and inspectable). The async requirement remains configurable via
16
+ `workflow.asyncStartMode` (`required` | `allowed`); under the Claude harness it is now
17
+ **relaxed to `allowed` at runtime** via `resolveEffectiveAsyncStartMode`, which consults the
18
+ new `isClaudeHarness` helper (`CLAUDECODE=1`) in `@dev-loops/core/loop/run-context`. An
19
+ explicit `DEVLOOPS_RUN_ID` still resolves as `valid`, and Pi behavior is unchanged (outside
20
+ Claude the configured mode is honored verbatim).
21
+ - The async-start CLI contract test is now hermetic — it clears `CLAUDECODE` (and the run-id
22
+ markers) so the rejection path is exercised regardless of the harness the suite runs under.
23
+ - The generated `dev-loop` skill prose no longer claims `PI_SUBAGENT_RUN_ID` is *required* — it
24
+ now describes the async run-id marker (`DEVLOOPS_RUN_ID` / `PI_SUBAGENT_RUN_ID` alias) and notes
25
+ the Claude-harness relaxation, so the plugin's docs match the runtime behavior. Subagent
26
+ spawning via the `dev-loop` agent is confirmed correctly wired: it grants the `Agent` tool
27
+ (the current subagent-spawning tool, renamed from `Task` in Claude Code v2.1.63) and the
28
+ strategy skills delegate to the worker agents (`developer`/`quality`/`refiner`/`fixer`/`review`/`docs`).
29
+
30
+ ## 0.2.1
31
+
32
+ ### Added
33
+
34
+ - **Claude Code marketplace catalog** (#828): ship `.claude-plugin/marketplace.json` at the repo
35
+ root so the repo can be added as a plugin marketplace (`/plugin marketplace add mfittko/dev-loops`,
36
+ or the *Manage Plugins → Marketplaces → Add* UI) and the plugin installed with
37
+ `/plugin install dev-loops@dev-loops`. The catalog's single plugin entry sources the existing
38
+ in-repo plugin at `./.claude`; the plugin version stays authoritative in `plugin.json`. A
39
+ contract test locks the catalog shape, and `.claude-plugin/` is added to the npm `files`
40
+ allowlist. Verified end-to-end with `claude plugin validate` + `marketplace add`/`install`
41
+ (4 skills, 7 agents, 2 hooks).
42
+
43
+ ### Changed
44
+
45
+ - `plugin.json` now declares an `author` (clears the marketplace-validation warning).
46
+ - README "Claude Code plugin" section drops the `(preview)` framing and documents marketplace
47
+ install; the two CLI help lines that said plugin packaging was "in progress" are updated.
48
+
5
49
  ## 0.2.0
6
50
 
7
51
  ### Added — Claude Code harness (agent-harness-agnostic dev-loop)
package/README.md CHANGED
@@ -45,11 +45,20 @@ Or run directly without installing:
45
45
  npx dev-loops --help
46
46
  ```
47
47
 
48
- ### Claude Code plugin (preview)
48
+ ### Claude Code plugin
49
49
 
50
50
  The repo ships a Claude Code plugin rooted at `.claude/` (manifest at
51
- `.claude/.claude-plugin/plugin.json`) exposing the dev-loop agents and skills. Load it for a
52
- session with:
51
+ `.claude/.claude-plugin/plugin.json`) exposing the dev-loop **agents, skills, and hooks**.
52
+
53
+ Install it from the bundled marketplace catalog (`.claude-plugin/marketplace.json`) by running
54
+ these slash commands inside Claude Code:
55
+
56
+ ```text
57
+ /plugin marketplace add mfittko/dev-loops # register the marketplace
58
+ /plugin install dev-loops@dev-loops # install the plugin
59
+ ```
60
+
61
+ Or load it directly for a single session without installing:
53
62
 
54
63
  ```bash
55
64
  claude --plugin-dir .claude # load it for a session
@@ -58,8 +67,11 @@ claude --plugin-dir .claude plugin details dev-loops # inspect the discovere
58
67
 
59
68
  When installed from npm, point at the bundled copy: `claude --plugin-dir node_modules/dev-loops/.claude`.
60
69
 
61
- Preview: hook bundling and shared-doc/Pi-prose neutralization in the generated skills are
62
- tracked follow-ups (the skills load but some in-body links resolve only inside this repo for now).
70
+ The plugin is self-contained: it bundles the shared contract docs and templates the skills
71
+ reference, and strips Pi-runtime-only prose from the generated assets. The hooks provide the
72
+ `gh pr ready` draft-gate guard and the main-agent read-only boundary (the read-only enforcement
73
+ is opt-in via `DEVLOOPS_MAIN_AGENT_READONLY=1`). Skill references to a project's own `PLAN.md` /
74
+ `AGENTS.md` resolve against the consumer repo, by design.
63
75
 
64
76
  ### Pi extension
65
77
 
package/cli/index.mjs CHANGED
@@ -210,7 +210,7 @@ function buildCliHelpLines() {
210
210
  "",
211
211
  "`/dev-loops hide` remains an extension-only Pi command.",
212
212
  "Run via `npx dev-loops` (or `npm install -g dev-loops` for the shell command); see the",
213
- "README for Pi-extension setup (Claude Code plugin packaging is in progress).",
213
+ "README for Pi-extension and Claude Code plugin setup.",
214
214
  ];
215
215
  }
216
216
 
@@ -232,7 +232,7 @@ function orderedCliSetupSteps(checks) {
232
232
  return [
233
233
  "1. Use `/dev-loop` (Claude Code) or `/skill:dev-loop` (Pi) to start or continue a dev loop — the single public entry.",
234
234
  "2. Run `dev-loops status` whenever you want a concise readiness snapshot.",
235
- "3. Run via `npx dev-loops` (or `npm install -g dev-loops` for the shell command); see the README for Pi-extension setup (Claude Code plugin packaging is in progress).",
235
+ "3. Run via `npx dev-loops` (or `npm install -g dev-loops` for the shell command); see the README for Pi-extension and Claude Code plugin setup.",
236
236
  ];
237
237
  }
238
238
 
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.0"
71
+ "@dev-loops/core": "^0.2.2"
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.0",
81
+ "version": "0.2.2",
89
82
  "files": [
90
83
  "cli/",
91
84
  "lib/",
@@ -93,6 +86,7 @@
93
86
  "extension/",
94
87
  "skills/",
95
88
  "agents/",
89
+ ".claude-plugin/",
96
90
  ".claude/.claude-plugin/",
97
91
  ".claude/agents/",
98
92
  ".claude/skills/",
@@ -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
+ }
@@ -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)) {
@@ -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
- }