projectstore-codex 0.0.1 → 0.28.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.
Files changed (184) hide show
  1. package/.codex-plugin/plugin.json +48 -0
  2. package/README.md +15 -7
  3. package/bin/projectstore-codex.mjs +88 -0
  4. package/hooks/hooks.json +59 -0
  5. package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
  6. package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
  7. package/node_modules/projectstore/.mcp.json +14 -0
  8. package/node_modules/projectstore/AGENTS.md +26 -0
  9. package/node_modules/projectstore/LICENSE +21 -0
  10. package/node_modules/projectstore/README.md +284 -0
  11. package/node_modules/projectstore/agents/archaeologist.md +76 -0
  12. package/node_modules/projectstore/agents/clerk.md +93 -0
  13. package/node_modules/projectstore/agents/critic.md +94 -0
  14. package/node_modules/projectstore/agents/librarian.md +81 -0
  15. package/node_modules/projectstore/agents/planner.md +80 -0
  16. package/node_modules/projectstore/agents/reviewer.md +98 -0
  17. package/node_modules/projectstore/bin/projectstore.mjs +7 -0
  18. package/node_modules/projectstore/commands/adr.md +57 -0
  19. package/node_modules/projectstore/commands/agents.md +180 -0
  20. package/node_modules/projectstore/commands/bind.md +128 -0
  21. package/node_modules/projectstore/commands/codemap.md +50 -0
  22. package/node_modules/projectstore/commands/concept.md +17 -0
  23. package/node_modules/projectstore/commands/doctor.md +166 -0
  24. package/node_modules/projectstore/commands/epic.md +40 -0
  25. package/node_modules/projectstore/commands/graph.md +56 -0
  26. package/node_modules/projectstore/commands/kanban.md +40 -0
  27. package/node_modules/projectstore/commands/meeting.md +17 -0
  28. package/node_modules/projectstore/commands/reconcile.md +73 -0
  29. package/node_modules/projectstore/commands/research.md +17 -0
  30. package/node_modules/projectstore/commands/review.md +89 -0
  31. package/node_modules/projectstore/commands/runbook.md +17 -0
  32. package/node_modules/projectstore/commands/scaffold.md +23 -0
  33. package/node_modules/projectstore/commands/search.md +22 -0
  34. package/node_modules/projectstore/commands/spec.md +91 -0
  35. package/node_modules/projectstore/commands/status.md +27 -0
  36. package/node_modules/projectstore/commands/statusline.md +46 -0
  37. package/node_modules/projectstore/commands/story.md +113 -0
  38. package/node_modules/projectstore/docs/extending.md +172 -0
  39. package/node_modules/projectstore/docs/getting-started.md +133 -0
  40. package/node_modules/projectstore/docs/harnesses.md +176 -0
  41. package/node_modules/projectstore/docs/how-it-works.md +263 -0
  42. package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
  43. package/node_modules/projectstore/docs/images/loop.svg +93 -0
  44. package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
  45. package/node_modules/projectstore/docs/images/team-light.svg +79 -0
  46. package/node_modules/projectstore/docs/images/team.svg +79 -0
  47. package/node_modules/projectstore/harnesses/claude-code.json +483 -0
  48. package/node_modules/projectstore/harnesses/codex.json +332 -0
  49. package/node_modules/projectstore/hooks/hooks.json +59 -0
  50. package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
  51. package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
  52. package/node_modules/projectstore/hooks/session-start.mjs +301 -0
  53. package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
  54. package/node_modules/projectstore/package.json +70 -0
  55. package/node_modules/projectstore/scaffold/checklists.json +88 -0
  56. package/node_modules/projectstore/scaffold/headings.json +171 -0
  57. package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
  58. package/node_modules/projectstore/scripts/binding.mjs +165 -0
  59. package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
  60. package/node_modules/projectstore/scripts/cli.mjs +595 -0
  61. package/node_modules/projectstore/scripts/codemap.mjs +99 -0
  62. package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
  63. package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
  64. package/node_modules/projectstore/scripts/draft.mjs +261 -0
  65. package/node_modules/projectstore/scripts/graph.mjs +219 -0
  66. package/node_modules/projectstore/scripts/harness.mjs +608 -0
  67. package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
  68. package/node_modules/projectstore/scripts/kanban.mjs +174 -0
  69. package/node_modules/projectstore/scripts/lib.mjs +3085 -0
  70. package/node_modules/projectstore/scripts/mcp.mjs +391 -0
  71. package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
  72. package/node_modules/projectstore/scripts/provenance.mjs +375 -0
  73. package/node_modules/projectstore/scripts/query.mjs +490 -0
  74. package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
  75. package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
  76. package/node_modules/projectstore/scripts/statusline.mjs +253 -0
  77. package/node_modules/projectstore/scripts/story-section.mjs +209 -0
  78. package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
  79. package/node_modules/projectstore/scripts/tokens.mjs +449 -0
  80. package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
  81. package/node_modules/projectstore/scripts/version-guard.mjs +261 -0
  82. package/node_modules/projectstore/scripts/worktree.mjs +109 -0
  83. package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
  84. package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
  85. package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
  86. package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
  87. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
  88. package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
  89. package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
  90. package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
  91. package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
  92. package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
  93. package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
  94. package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
  95. package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
  96. package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
  97. package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
  98. package/node_modules/projectstore/templates/de/strings.json +6 -0
  99. package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
  100. package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
  101. package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
  102. package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
  103. package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
  104. package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
  105. package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
  106. package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
  107. package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
  108. package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
  109. package/node_modules/projectstore/templates/en/strings.json +6 -0
  110. package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
  111. package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
  112. package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
  113. package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
  114. package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
  115. package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
  116. package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
  117. package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
  118. package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
  119. package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
  120. package/node_modules/projectstore/templates/es/strings.json +6 -0
  121. package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
  122. package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
  123. package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
  124. package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
  125. package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
  126. package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
  127. package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
  128. package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
  129. package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
  130. package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
  131. package/node_modules/projectstore/templates/fr/strings.json +6 -0
  132. package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
  133. package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
  134. package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
  135. package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
  136. package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
  137. package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
  138. package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
  139. package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
  140. package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
  141. package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
  142. package/node_modules/projectstore/templates/ru/strings.json +6 -0
  143. package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
  144. package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
  145. package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
  146. package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
  147. package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
  148. package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
  149. package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
  150. package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
  151. package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
  152. package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
  153. package/node_modules/projectstore/templates/zh/strings.json +6 -0
  154. package/package.json +35 -14
  155. package/skills/projectstore-adr/SKILL.md +76 -0
  156. package/skills/projectstore-agents/SKILL.md +50 -0
  157. package/skills/projectstore-archaeologist/SKILL.md +109 -0
  158. package/skills/projectstore-bind/SKILL.md +44 -0
  159. package/skills/projectstore-clerk/SKILL.md +126 -0
  160. package/skills/projectstore-codemap/SKILL.md +69 -0
  161. package/skills/projectstore-concept/SKILL.md +36 -0
  162. package/skills/projectstore-critic/SKILL.md +127 -0
  163. package/skills/projectstore-decision-detector/SKILL.md +59 -0
  164. package/skills/projectstore-doctor/SKILL.md +33 -0
  165. package/skills/projectstore-epic/SKILL.md +59 -0
  166. package/skills/projectstore-graph/SKILL.md +75 -0
  167. package/skills/projectstore-kanban/SKILL.md +60 -0
  168. package/skills/projectstore-librarian/SKILL.md +114 -0
  169. package/skills/projectstore-meeting/SKILL.md +36 -0
  170. package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
  171. package/skills/projectstore-planner/SKILL.md +113 -0
  172. package/skills/projectstore-reconcile/SKILL.md +92 -0
  173. package/skills/projectstore-research/SKILL.md +36 -0
  174. package/skills/projectstore-review/SKILL.md +108 -0
  175. package/skills/projectstore-reviewer/SKILL.md +131 -0
  176. package/skills/projectstore-runbook/SKILL.md +36 -0
  177. package/skills/projectstore-scaffold/SKILL.md +42 -0
  178. package/skills/projectstore-search/SKILL.md +41 -0
  179. package/skills/projectstore-spec/SKILL.md +110 -0
  180. package/skills/projectstore-status/SKILL.md +47 -0
  181. package/skills/projectstore-statusline/SKILL.md +29 -0
  182. package/skills/projectstore-story/SKILL.md +132 -0
  183. package/skills/projectstore-story-completion/SKILL.md +69 -0
  184. package/skills/projectstore-vault-communication/SKILL.md +115 -0
@@ -0,0 +1,595 @@
1
+ // projectstore — cli.mjs
2
+ //
3
+ // The token-free front-end: `projectstore <verb>` as a thin argv/output
4
+ // shell over the same core operations the hooks and the command files call
5
+ // (distribution ADR decisions 3 and 5). No logic lives here — each verb row
6
+ // names the module it wraps and how (spawned, imported, or new), and the
7
+ // table is exported because it is a CONTRACT: the MCP read surface maps one
8
+ // tool per CLI verb (MCP ADR decision 2), and its drift test reads this file.
9
+ //
10
+ // Every --json result travels in one envelope, {schema_version, verb,
11
+ // project, ok, result}, built here and nowhere else — failures included, so
12
+ // a consumer always has something to parse. The bare scripts keep their own
13
+ // output (`node scripts/doctor.mjs --json` is still a bare array — the
14
+ // command files and the tests depend on it). Exit codes: 0 ok, 1 findings or
15
+ // a refusal, 2 usage or an internal failure, 3 not bound.
16
+ //
17
+ // The gate (distribution ADR decision 6) binds every write verb this bin
18
+ // exposes: the install family through install-harness.mjs's own preview and
19
+ // confirmation, and `reconcile --write` through the same rule — naming what
20
+ // is written (`--only <target>`) is the non-interactive confirmation, a bare
21
+ // `--write` asks on a terminal and refuses without one. There is no --yes.
22
+ //
23
+ // The project is resolved once, here — --project, then the neutral
24
+ // PROJECTSTORE_PROJECT_DIR, then a project-dir variable a harness declared,
25
+ // then cwd — and handed to every child through childEnv(), the one place a
26
+ // branded name is written. bin/ reads no branded variable; the portability
27
+ // suite greps it too.
28
+ //
29
+ // The bin runs ITS OWN copy of the core: sibling scripts resolve from this
30
+ // file's URL, and the plugin root handed to children and to the installer
31
+ // is this package's root — so `npx projectstore doctor` inside a Claude Code
32
+ // session runs the npm copy's doctor over the npm copy's templates, and
33
+ // reports the npm copy's version, rather than the marketplace copy's.
34
+ //
35
+ // The install verbs are imported lazily: hooks never import this module
36
+ // (the suite asserts it), but the MCP server will, and it needs no installer.
37
+ // Pure node, no external deps.
38
+
39
+ import { spawnSync } from "node:child_process";
40
+ import { readFileSync, existsSync } from "node:fs";
41
+ import { resolve, dirname } from "node:path";
42
+ import { fileURLToPath } from "node:url";
43
+ import { parseArgs } from "node:util";
44
+ import { createInterface } from "node:readline/promises";
45
+ import { projectRootDeclared, childEnv, harnessIds, harnessForOverlay, pinPluginRoot } from "./harness.mjs";
46
+ import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster } from "./lib.mjs";
47
+ import { READ_OPERATIONS, LINEAGE_KINDS, LINEAGE_DEFAULT_DEPTH, SEARCH_DEFAULT_LIMIT, GRAPH_EDGE_CAP, DIRECTIONS } from "./query.mjs";
48
+ // binding.mjs is a write module imported statically where the install family
49
+ // is lazy: it is a dependency-free leaf with no side effects, so the MCP
50
+ // server's module graph gains nothing it could trip on.
51
+ import { planBind, applyBind, renderBindPlan, bindResult, DEFAULT_LAYOUT, DEFAULT_LANGUAGE } from "./binding.mjs";
52
+
53
+ export const SCHEMA_VERSION = 1;
54
+ const HERE = dirname(fileURLToPath(import.meta.url));
55
+ export const PACKAGE_ROOT = dirname(HERE);
56
+ const script = (name) => resolve(HERE, name);
57
+
58
+ export function packageVersion() {
59
+ try { return String(JSON.parse(readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf8")).version || ""); } catch { return ""; }
60
+ }
61
+
62
+ export function envelope(verb, project, ok, result) {
63
+ return { schema_version: SCHEMA_VERSION, verb, project, ok, result };
64
+ }
65
+
66
+ // --project, then the neutral variable (MCP ADR decision 6), then a
67
+ // project-dir variable a harness declared (null when none is set — never
68
+ // cwd-by-inference from harness.mjs, which is what an in-process caller with
69
+ // its own cwd would otherwise inherit), then the caller's cwd.
70
+ export function resolveProject({ project = null, env = process.env, cwd = process.cwd() } = {}) {
71
+ if (project) return resolve(cwd, project);
72
+ if (env.PROJECTSTORE_PROJECT_DIR) return resolve(cwd, env.PROJECTSTORE_PROJECT_DIR);
73
+ const declared = projectRootDeclared(env);
74
+ return declared ? resolve(cwd, declared) : resolve(cwd);
75
+ }
76
+
77
+ // ─── The verb table ────────────────────────────────────────────────────
78
+ //
79
+ // wraps: "script" (spawned), "module" (imported), "new" (code that exists
80
+ // only for the CLI). output: "envelope" (--json wraps), "text". mcp: the MCP
81
+ // tools that mirror the verb (MCP ADR decision 2), empty when none.
82
+
83
+ const opt = (name, arg, summary, multiple = false) => Object.freeze({ name, arg, summary, multiple });
84
+ const JSON_OPT = opt("json", false, "the envelope");
85
+ const READ_JSON = [JSON_OPT];
86
+ const HARNESS_OPT = opt("harness", "<id>", "the harness — and, non-interactively, the confirmation; there is no --yes", true);
87
+ const SURFACE_OPT = opt("surface", "<key>", "one surface and those beneath it", true);
88
+ // The layout move's remedy names this for any copy but the package's own
89
+ // registration (the layout spec, contract 12 as amended 2026-10-03): it makes
90
+ // "no host command" true by construction instead of by recognising the root.
91
+ const NO_REGISTER_OPT = opt("no-register", false, "leave the plugin registration alone: change only this project's files");
92
+ const INSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, NO_REGISTER_OPT, JSON_OPT];
93
+ const UNINSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, opt("global", false, "also remove the harness-global plugin registration"), JSON_OPT];
94
+
95
+ export const VERBS = Object.freeze([
96
+ Object.freeze({
97
+ verb: "doctor", summary: "Check the install wiring and the vault's consistency.",
98
+ module: "./doctor.mjs", wraps: "script", how: "spawn", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze(["doctor"]),
99
+ options: [opt("install", false, "only the install section"), opt("vault", false, "only the vault section"), JSON_OPT],
100
+ run: runDoctor,
101
+ }),
102
+ Object.freeze({
103
+ verb: "reconcile", summary: "Regenerate the derived views (kanban, code map, graph, indexes).",
104
+ module: "./reconcile.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: true, mcp: Object.freeze([]),
105
+ options: [opt("write", false, "apply the regeneration (asks on a terminal; --only names what is written and confirms headless)"), opt("only", "<target>", "one derived view"), JSON_OPT],
106
+ run: runReconcile,
107
+ }),
108
+ Object.freeze({
109
+ verb: "plan", summary: "Show what install would write for a harness, without writing.",
110
+ module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze([]),
111
+ options: INSTALL_OPTS, run: runInstallVerb,
112
+ }),
113
+ Object.freeze({
114
+ verb: "install", summary: "Install projectstore's surfaces for a harness, behind a preview.",
115
+ module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
116
+ options: INSTALL_OPTS, run: runInstallVerb,
117
+ }),
118
+ Object.freeze({
119
+ verb: "uninstall", summary: "Remove what install wrote, and only that.",
120
+ module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
121
+ options: UNINSTALL_OPTS, run: runInstallVerb,
122
+ }),
123
+ Object.freeze({
124
+ verb: "upgrade", summary: "Re-run install after a plugin update; re-stamps what this installation wrote and leaves the rest.",
125
+ module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
126
+ options: INSTALL_OPTS, run: runInstallVerb,
127
+ }),
128
+ Object.freeze({
129
+ verb: "status", summary: "The binding, what is in progress, and whether the derived views are fresh.",
130
+ module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze(["status"]),
131
+ options: READ_JSON, run: runRead("status"),
132
+ }),
133
+ Object.freeze({
134
+ verb: "orientation", summary: "The SessionStart skeleton and the facts behind it.",
135
+ module: "./query.mjs", wraps: "module", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["orientation"]),
136
+ options: READ_JSON, run: runRead("orientation"),
137
+ }),
138
+ Object.freeze({
139
+ verb: "search", summary: "Find a phrase in the vault's artifacts — deterministic, bounded, no shell.",
140
+ module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["search"]),
141
+ options: [opt("kind", "<type>", "only artifacts of this kind", true), opt("status", "<status>", "only artifacts in this status"), opt("limit", "<n>", `at most n matches (default ${SEARCH_DEFAULT_LIMIT}, hard cap 100)`), opt("include-derived", false, "search the derived views too"), opt("case-sensitive", false, "match case"), JSON_OPT],
142
+ run: runRead("search"),
143
+ }),
144
+ Object.freeze({
145
+ verb: "show", summary: "One artifact: its frontmatter, and its body or one section on request.",
146
+ module: "./query.mjs", wraps: "module", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["get_artifact"]),
147
+ options: [opt("body", false, "include the body"), opt("section", "<id>", "one section by its registry id (description, acceptance, …)"), JSON_OPT],
148
+ run: runRead("show"),
149
+ }),
150
+ Object.freeze({
151
+ verb: "graph", summary: "graph neighbors <path> | graph lineage <path> — the live link graph by vault path.",
152
+ module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["neighbors", "lineage"]),
153
+ options: [opt("kind", "<edge-kind>", "only edges of this kind (lineage: one of its four)", true), opt("direction", DIRECTIONS.join("|"), "neighbors: which edges"), opt("depth", "<n>", `lineage: how far (default ${LINEAGE_DEFAULT_DEPTH})`), opt("limit", "<n>", `neighbors: cap per direction (≤ ${GRAPH_EDGE_CAP})`), JSON_OPT],
154
+ run: runGraph,
155
+ }),
156
+ Object.freeze({
157
+ verb: "codemap", summary: "codemap --for <selector> — which code an epic or artifact maps to, or which artifacts map to a path.",
158
+ module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["code_refs"]),
159
+ options: [opt("for", "<selector>", "an epic id, an artifact, or a repo path"), opt("reverse", false, "read the selector as a path even if it names an artifact"), JSON_OPT],
160
+ run: runCodemap,
161
+ }),
162
+ Object.freeze({
163
+ verb: "agents", summary: "agents model <name> | agents show | agents configure — the harness overlay's agents block (ADR-008, read per invocation).",
164
+ module: "./lib.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
165
+ options: [opt("harness", "<id>", "configure: the overlay to write — and, non-interactively, the confirmation; there is no --yes", true), opt("default", "<model>", "configure: agents.default.model (pins the clerk to sonnet unless --agent clerk=… says otherwise; an empty model clears it)"), opt("agent", "<name>=<model>", "configure: agents.per_agent.<name>.model (an empty model removes the key)", true), opt("reset", false, "configure: empty the agents block first; --default and --agent given with it apply on top"), JSON_OPT],
166
+ run: runAgents,
167
+ }),
168
+ Object.freeze({
169
+ verb: "bind", summary: "bind <vault> — bind this project to an existing vault (naming the vault is the confirmation).",
170
+ module: "./binding.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
171
+ options: [opt("layout", "<name>", `the layout (default ${DEFAULT_LAYOUT})`), opt("language", "<code>", `the template language (default ${DEFAULT_LANGUAGE})`), opt("rebind", false, "point an already bound project at another vault; every other setting is kept"), JSON_OPT],
172
+ run: runBind(false),
173
+ }),
174
+ Object.freeze({
175
+ verb: "init", summary: "init <vault> — create the vault directory and bind to it; the layout's folders come from /projectstore:scaffold.",
176
+ module: "./binding.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
177
+ options: [opt("layout", "<name>", `the layout (default ${DEFAULT_LAYOUT})`), opt("language", "<code>", `the template language (default ${DEFAULT_LANGUAGE})`), opt("rebind", false, "an already bound project: create the new vault and point the project at it; every other setting is kept"), JSON_OPT],
178
+ run: runBind(true),
179
+ }),
180
+ Object.freeze({
181
+ verb: "mcp", summary: "Serve the read tools over MCP (stdio) for the project named by --project or PROJECTSTORE_PROJECT_DIR; never the ambient cwd.",
182
+ module: "./mcp.mjs", wraps: "new", how: "import", output: "text", writes: false, requiresBinding: false, mcp: Object.freeze([]),
183
+ options: [], run: runMcp,
184
+ }),
185
+ Object.freeze({
186
+ verb: "version", summary: "Print the package version (also --version).",
187
+ module: null, wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze([]),
188
+ options: [JSON_OPT], run: runVersion,
189
+ }),
190
+ ]);
191
+
192
+ // Verbs the story names that land with a later slice — listed so
193
+ // `projectstore <verb>` says where instead of "unknown verb". Empty since
194
+ // A7: every verb the CLI story names has landed. The seam stays for the next
195
+ // story that adds a verb in slices.
196
+ export const PLANNED_VERBS = Object.freeze([]);
197
+
198
+ export function usage() {
199
+ const lines = ["usage: projectstore <verb> [options] [--project <dir>] [--json]", "", "verbs:"];
200
+ for (const v of VERBS) {
201
+ lines.push(` ${v.verb.padEnd(11)} ${v.summary}`);
202
+ for (const o of v.options) lines.push(` --${o.name}${o.arg ? " " + o.arg : ""}${o.multiple ? " (repeatable)" : ""} ${o.summary}`);
203
+ }
204
+ if (PLANNED_VERBS.length) lines.push("", ` planned: ${PLANNED_VERBS.map((v) => `${v.verb} (${v.lands})`).join(", ")}`);
205
+ lines.push("", ` harnesses: ${harnessIds().join(", ")}`, " --version print the package version", " exit codes: 0 ok, 1 findings or refusal, 2 usage, 3 not bound");
206
+ return lines.join("\n");
207
+ }
208
+
209
+ // ─── run ───────────────────────────────────────────────────────────────
210
+
211
+ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, ask = null } = {}) {
212
+ let parsed;
213
+ try {
214
+ parsed = parseArgs({
215
+ args: argv,
216
+ allowPositionals: true,
217
+ strict: true,
218
+ options: {
219
+ project: { type: "string" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" },
220
+ harness: { type: "string", multiple: true }, surface: { type: "string", multiple: true }, global: { type: "boolean" }, "no-register": { type: "boolean" },
221
+ write: { type: "boolean" }, only: { type: "string" }, install: { type: "boolean" }, vault: { type: "boolean" },
222
+ kind: { type: "string", multiple: true }, status: { type: "string" }, limit: { type: "string" }, "include-derived": { type: "boolean" }, "case-sensitive": { type: "boolean" },
223
+ body: { type: "boolean" }, section: { type: "string" }, direction: { type: "string" }, depth: { type: "string" }, for: { type: "string" }, reverse: { type: "boolean" },
224
+ layout: { type: "string" }, language: { type: "string" }, rebind: { type: "boolean" },
225
+ default: { type: "string" }, agent: { type: "string", multiple: true }, reset: { type: "boolean" },
226
+ },
227
+ });
228
+ } catch (e) {
229
+ // --json cannot be known before parsing; a raw scan is enough here.
230
+ if (argv.includes("--json")) stdout.write(JSON.stringify(envelope(argv.find((a) => !a.startsWith("-")) || null, null, false, { error: e.message }), null, 2) + "\n");
231
+ stderr.write(`${e.message}\n${usage()}\n`);
232
+ return 2;
233
+ }
234
+ const { values, positionals } = parsed;
235
+ // A failure before a verb runs still answers in the envelope under --json:
236
+ // a consumer (the MCP server, a script) always has something to parse.
237
+ const fail = (verb, project, message, code, { help = false } = {}) => {
238
+ if (values.json) stdout.write(JSON.stringify(envelope(verb, project, false, { error: message, exit: code }), null, 2) + "\n");
239
+ stderr.write(message + "\n" + (help ? usage() + "\n" : ""));
240
+ return code;
241
+ };
242
+ // In-process reads resolve layouts and registries from THIS package, as the
243
+ // children already do through ownEnv — not from whichever copy the host
244
+ // session's variable points at.
245
+ pinPluginRoot(PACKAGE_ROOT);
246
+ if (values.version) return runVersion({ values, stdout });
247
+ if (values.help || !positionals.length) { (values.help ? stdout : stderr).write(usage() + "\n"); return values.help ? 0 : 2; }
248
+ const verb = positionals[0];
249
+ const row = VERBS.find((v) => v.verb === verb);
250
+ if (!row) {
251
+ const planned = PLANNED_VERBS.find((v) => v.verb === verb);
252
+ return fail(verb, null, (planned ? `${verb} lands with roadmap ${planned.lands}; not in this release.` : `unknown verb: ${verb}`), 2, { help: true });
253
+ }
254
+ // The options map is global (parseArgs), the rows are not: an option a row
255
+ // does not declare is a usage error, so help cannot lie about what a verb
256
+ // takes.
257
+ const GLOBAL = new Set(["project", "json", "help", "version"]);
258
+ const declared = new Set(row.options.map((o) => o.name));
259
+ const stray = Object.keys(values).filter((k) => !GLOBAL.has(k) && !declared.has(k));
260
+ if (stray.length) return fail(verb, null, `${verb} does not take --${stray[0]}`, 2, { help: true });
261
+ const project = resolveProject({ project: values.project, env, cwd });
262
+ const cfg = readConfigAt(project);
263
+ if (row.requiresBinding && !cfg) return fail(verb, project, `${project} is not bound to a vault — run /projectstore:bind <vault> in a session, or \`projectstore bind <vault>\` (\`projectstore init <vault>\` also creates the vault).`, 3);
264
+ try {
265
+ return await row.run({ row, values, positionals: positionals.slice(1), cfg, project, env, cwd, stdin, stdout, stderr, ask });
266
+ } catch (e) {
267
+ // An internal failure is not "findings" (1) — it is 2, with an envelope
268
+ // when one was asked for.
269
+ const msg = e && e.message ? e.message : String(e);
270
+ if (values.json) stdout.write(JSON.stringify(envelope(verb, project, false, { error: msg }), null, 2) + "\n");
271
+ stderr.write(`${verb} failed: ${msg}\n`);
272
+ return 2;
273
+ }
274
+ }
275
+
276
+ // The environment a child or the installer runs in: the resolved project and
277
+ // THIS package as the plugin root, so the bin never answers for a sibling
278
+ // copy of the core.
279
+ function ownEnv(env, project) {
280
+ return childEnv(env, { projectRoot: project, pluginRoot: PACKAGE_ROOT });
281
+ }
282
+
283
+ // The one interactive question every write verb asks when nothing named the
284
+ // write. Streams and `ask` are parameters so it is testable without a tty.
285
+ async function confirmWrite(question, { stdin, stdout, ask }) {
286
+ if (ask) return /^y(es)?$/i.test(String(await ask(question)).trim());
287
+ if (!(stdin && stdin.isTTY && stdout && stdout.isTTY)) return null; // no terminal: refuse
288
+ const rl = createInterface({ input: stdin, output: stdout });
289
+ try { return /^y(es)?$/i.test(String(await rl.question(question)).trim()); } finally { rl.close(); }
290
+ }
291
+
292
+ // ─── verbs ─────────────────────────────────────────────────────────────
293
+
294
+ // The read verbs: one call into query.mjs, the result in the envelope or
295
+ // rendered. A usage error from the operation (a bad path, a missing query)
296
+ // is exit 2 with the message, never a stack trace.
297
+ function emitRead({ verb, values, project, stdout }, op, result, ok = true) {
298
+ if (values.json) stdout.write(JSON.stringify(envelope(verb, project, ok, result), null, 2) + "\n");
299
+ else stdout.write(op.render(result));
300
+ return ok ? 0 : 1;
301
+ }
302
+
303
+ function usageFail(e, { verb, values, project, stdout, stderr }) {
304
+ if (values.json) stdout.write(JSON.stringify(envelope(verb, project, false, { error: e.message }), null, 2) + "\n");
305
+ stderr.write(`${verb}: ${e.message}\n`);
306
+ return 2;
307
+ }
308
+
309
+ function runRead(name) {
310
+ return async (ctx) => {
311
+ const { row, values, positionals, cfg, project } = ctx;
312
+ const op = READ_OPERATIONS[name];
313
+ try {
314
+ let result;
315
+ if (name === "status") result = op.fn(cfg, { project });
316
+ else if (name === "orientation") result = await op.fn(cfg);
317
+ else if (name === "search") result = op.fn(cfg, positionals.join(" "), { kinds: values.kind || null, status: values.status ?? null, limit: values.limit, includeDerived: Boolean(values["include-derived"]), caseSensitive: Boolean(values["case-sensitive"]) });
318
+ else if (name === "show") result = op.fn(cfg, positionals[0], { body: Boolean(values.body), section: values.section ?? null });
319
+ return emitRead({ verb: row.verb, ...ctx }, op, result);
320
+ } catch (e) {
321
+ if (e && e.code === "USAGE") return usageFail(e, { verb: row.verb, ...ctx });
322
+ throw e;
323
+ }
324
+ };
325
+ }
326
+
327
+ // The MCP server, imported lazily like the install family: no other verb
328
+ // carries the protocol code. A project must have been SUPPLIED — the flag,
329
+ // the neutral variable or the harness's declared directory; resolveProject's
330
+ // cwd fallback is exactly what the MCP ADR's decision 6 forbids here.
331
+ async function runMcp({ values, project, env, stdin, stdout, stderr }) {
332
+ const supplied = Boolean(values.project || env.PROJECTSTORE_PROJECT_DIR || projectRootDeclared(env));
333
+ const { serve } = await import("./mcp.mjs");
334
+ return serve({ project: supplied ? project : null, env, stdin, stdout, stderr });
335
+ }
336
+
337
+ // git's user.name for a fresh config's default_author, from the project's
338
+ // own repository — never from the process cwd; the login name otherwise.
339
+ function gitAuthor(project, env) {
340
+ if (existsSync(project)) {
341
+ try {
342
+ const r = spawnSync("git", ["config", "--get", "user.name"], { cwd: project, encoding: "utf8", timeout: 5000 });
343
+ if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
344
+ } catch {}
345
+ }
346
+ return env.USER || env.USERNAME || "";
347
+ }
348
+
349
+ // bind / init: the vault named on the command line is the confirmation (the
350
+ // distribution ADR's decision 6 read for a binding — there is no --yes and
351
+ // nothing to ask); a change of vault needs --rebind. Exit 1 on a refusal with
352
+ // the reason, 2 on usage, 0 when already bound to the same vault.
353
+ function runBind(init) {
354
+ return async (ctx) => {
355
+ const { row, values, positionals, project, env, stdout, stderr } = ctx;
356
+ const vault = positionals[0];
357
+ if (!vault) return usageFail(Object.assign(new Error(`${row.verb} takes the vault path`), { code: "USAGE" }), { verb: row.verb, ...ctx });
358
+ // The author is the caller's to find: the plan reads nothing ambient.
359
+ const plan = planBind(project, { vault, layout: values.layout ?? null, language: values.language ?? null, rebind: Boolean(values.rebind), init, author: gitAuthor(project, env), env });
360
+ const usage = plan.refusals.find((r) => r.code === "USAGE");
361
+ if (usage) return usageFail(Object.assign(new Error(usage.message), { code: "USAGE" }), { verb: row.verb, ...ctx });
362
+ let done = null;
363
+ if (plan.ok && plan.writes) done = applyBind(plan);
364
+ const result = bindResult(plan, done);
365
+ if (values.json) stdout.write(JSON.stringify(envelope(row.verb, project, plan.ok, result), null, 2) + "\n");
366
+ else (plan.ok ? stdout : stderr).write(renderBindPlan(plan, done));
367
+ return plan.ok ? 0 : 1;
368
+ };
369
+ }
370
+
371
+ // agents model <name> — the model a plugin surface passes for that agent, from
372
+ // the active harness's overlay (ADR-008's two terms); agents show — the overlay
373
+ // as read, with the keys the allowlist rejected; agents configure — the one
374
+ // writer, behind the same gate as install: naming --harness is the
375
+ // confirmation, a bare non-TTY call refuses (layout spec, contracts 2–4).
376
+ async function runAgents(ctx) {
377
+ const { row, values, positionals, project, env, stdout, stderr, stdin, ask } = ctx;
378
+ const sub = positionals[0];
379
+ const usage = (m) => usageFail(Object.assign(new Error(m), { code: "USAGE" }), { verb: row.verb, ...ctx });
380
+ if (!["model", "show", "configure"].includes(sub)) return usage("agents takes model <name>, show, or configure");
381
+ // The flag IS the confirmation, so it names exactly one overlay: two are a
382
+ // question, not an answer.
383
+ if (values.harness && values.harness.length > 1) return usage("--harness names exactly one overlay (it is the confirmation); given twice");
384
+ const named = values.harness && values.harness[0];
385
+ if (named && !harnessIds().includes(named)) return usage(`unknown harness: ${named} — known: ${harnessIds().join(", ")}`);
386
+ const harness = named || overlayId(env);
387
+ const forConfigure = ["default", "agent", "reset"].filter((o) => values[o] !== undefined);
388
+ if (sub !== "configure" && forConfigure.length) return usage(`--${forConfigure[0]} is an option of agents configure`);
389
+ // A refusal goes to stderr, as bind's does; --json keeps its envelope on stdout.
390
+ const emit = (verb, ok, result, text) => { if (values.json) stdout.write(JSON.stringify(envelope(verb, project, ok, result), null, 2) + "\n"); else (ok ? stdout : stderr).write(text); return ok ? 0 : 1; };
391
+ const cfg = readConfigAt(project);
392
+ const roster = layoutRoster(cfg);
393
+ if (sub === "model") {
394
+ const name = positionals[1];
395
+ if (!name) return usage("agents model takes the agent's bare name (critic, planner, …)");
396
+ const r = resolveAgentModel(project, name, { harness });
397
+ return emit("agents model", true, r, `${name}: ${r.model ? `${r.model} (${r.source}, ${r.overlay})` : "no model configured — the agent's frontmatter decides"}\n`);
398
+ }
399
+ const before = readOverlayAt(project, harness);
400
+ const inBinding = Boolean(cfg && cfg.agents && typeof cfg.agents === "object");
401
+ if (sub === "show") {
402
+ // Resolved per roster agent (per configured name when no layout loads), so
403
+ // a reader reports what would run without restating the two-term rule.
404
+ const names = [...new Set([...(roster || []), ...Object.keys(before.agents.per_agent)])].sort();
405
+ const resolved = Object.fromEntries(names.map((n) => { const r = resolveAgentModel(project, n, { harness }); return [n, { model: r.model, source: r.source }]; }));
406
+ const unknown = roster ? Object.keys(before.agents.per_agent).filter((n) => !roster.includes(n)) : [];
407
+ const lines = [`overlay: ${before.path || "(none: no harness detected and none named)"}${before.path && !before.present ? " (absent)" : ""}`];
408
+ if (before.unparseable) lines.push(" not valid JSON");
409
+ if (before.agents.default) lines.push(` default: ${before.agents.default}`);
410
+ for (const n of names) lines.push(` ${n}: ${resolved[n].model ? `${resolved[n].model} (${resolved[n].source})` : "— (the agent's frontmatter)"}${unknown.includes(n) ? " — not in the roster: nothing runs under this name" : ""}`);
411
+ if (before.rejected.length) lines.push(` ignored (not an overlay key): ${before.rejected.join(", ")}`);
412
+ if (inBinding) lines.push(" the binding still carries an agents block — a pre-0.28 leftover; run upgrade");
413
+ return emit("agents show", true, { harness, path: before.path, present: before.present, unparseable: before.unparseable, agents: before.agents, resolved, roster, unknown, rejected: before.rejected, agents_in_binding: inBinding }, lines.join("\n") + "\n");
414
+ }
415
+ // configure
416
+ if (!harness) return usage("no harness detected and none named: agents configure names --harness <id>");
417
+ if (before.unparseable) return emit("agents configure", false, { error: `${before.path} is not valid JSON; fix or remove it first`, harness, path: before.path }, `${before.path} is not valid JSON; fix or remove it first\n`);
418
+ if (!values.reset && values.default === undefined && !(values.agent || []).length) return usage("agents configure takes --default <model>, --agent <name>=<model> (repeatable) or --reset");
419
+ // --reset empties the block first; --default and --agent then apply on top of
420
+ // the emptied block — "reset, then set" is one call, and nothing given is
421
+ // silently dropped.
422
+ const base = values.reset ? { default: null, per_agent: {} } : before.agents;
423
+ const next = { default: values.default !== undefined ? (values.default || null) : base.default, per_agent: { ...base.per_agent } };
424
+ for (const spec of values.agent || []) {
425
+ const m = /^([a-z][a-z0-9-]*)=(.*)$/.exec(spec);
426
+ if (!m) return usage(`--agent takes <name>=<model>, got ${spec}`);
427
+ // A name outside the roster would be written, listed, and never run.
428
+ if (roster && !roster.includes(m[1])) return usage(`no agent named ${m[1]} in the ${cfg.layout} roster — known: ${roster.join(", ")}`);
429
+ if (m[2]) next.per_agent[m[1]] = m[2]; else delete next.per_agent[m[1]];
430
+ }
431
+ // The clerk transcribes; a strong default must not lift it (commands/agents.md,
432
+ // ADR-008). WHICH model it is pinned to is the harness's vocabulary, so the
433
+ // manifest names it and this file does not: pinning every harness to
434
+ // "sonnet" wrote an Anthropic model name into a Codex overlay, which is a
435
+ // model that does not exist there. A manifest with no cheap model declared
436
+ // does not pin, and the preview says so rather than inventing one.
437
+ // No fallback model here, deliberately, and it is not the same call as
438
+ // SOURCE_WRITE_TOOLS_FALLBACK: a missing manifest cannot reach this line.
439
+ // harnessIds() derives from the manifests, so with none loadable `--harness`
440
+ // is refused as unknown and `overlayId(env)` is null, and configure has
441
+ // already returned "no harness detected and none named". A literal here would
442
+ // be the deleted `"sonnet"` restored for a case that cannot happen.
443
+ let pinnedClerk = false;
444
+ let clerkPinUnavailable = false;
445
+ const clerkAsked = (values.agent || []).some((s) => s.startsWith("clerk="));
446
+ if (next.default && !next.per_agent.clerk && !clerkAsked) {
447
+ const cheap = harnessForOverlay(harness)?.agent_translation?.cheap_model || null;
448
+ if (cheap) { next.per_agent.clerk = cheap; pinnedClerk = true; }
449
+ else clerkPinUnavailable = true;
450
+ }
451
+ // Keys inside the agents block that the allowlist rejected (an `effort`, a
452
+ // stray shape) are rewritten out by the write and announced here; keys
453
+ // outside the block are kept, and stay doctor's to name.
454
+ const dropped = before.rejected.filter((k) => k.startsWith("agents."));
455
+ const same = !dropped.length && JSON.stringify({ d: before.agents.default, p: before.agents.per_agent }) === JSON.stringify({ d: next.default, p: next.per_agent });
456
+ const preview = [`agents configure — ${harness} — ${before.path}`, ` default: ${before.agents.default || "—"} → ${next.default || "—"}`];
457
+ const names = new Set([...Object.keys(before.agents.per_agent), ...Object.keys(next.per_agent)]);
458
+ for (const n of [...names].sort()) preview.push(` ${n}: ${before.agents.per_agent[n] || "—"} → ${next.per_agent[n] || "—"}${pinnedClerk && n === "clerk" ? " (pinned: the clerk stays cheap under a strong default)" : ""}`);
459
+ // Silence here would read as "the clerk is fine", and it is not: it takes the
460
+ // strong default, which is the one thing ADR-008 says must not happen to it.
461
+ if (clerkPinUnavailable) preview.push(` clerk: takes the default — ${harness} declares no cheap model to pin it to, so name one with --agent clerk=<model>`);
462
+ if (dropped.length) preview.push(` drops from the agents block (an overlay carries only models): ${dropped.join(", ")}`);
463
+ if (same) return emit("agents configure", true, { harness, path: before.path, wrote: false, agents: next, pinnedClerk, clerkPinUnavailable, dropped }, preview.join("\n") + "\n Nothing to change.\n");
464
+ // The gate: a named harness confirms; otherwise ask, and refuse without a terminal.
465
+ let confirmed = Boolean(named);
466
+ if (!confirmed) { const a = await confirmWrite(preview.join("\n") + `\nWrite ${before.path}? [y/N] `, { stdin, stdout, ask }); if (a === null) return usage("a non-interactive agents configure names --harness <id> to confirm; there is no --yes"); confirmed = a; }
467
+ if (!confirmed) return emit("agents configure", false, { harness, path: before.path, wrote: false, declined: true, agents: next, dropped }, preview.join("\n") + "\n nothing written.\n");
468
+ const path = writeOverlayAt(project, harness, next);
469
+ const after = readOverlayAt(project, harness);
470
+ return emit("agents configure", true, { harness, path, wrote: true, agents: after.agents, pinnedClerk, clerkPinUnavailable, dropped, rejected: after.rejected, agents_in_binding: inBinding }, preview.join("\n") + `\n wrote ${path}.${inBinding ? " The binding still carries an agents block (pre-0.28); run upgrade to move it." : ""}\n`);
471
+ }
472
+
473
+ async function runGraph(ctx) {
474
+ const { row, values, positionals, cfg } = ctx;
475
+ const sub = positionals[0];
476
+ const path = positionals[1];
477
+ if (!["neighbors", "lineage"].includes(sub)) return usageFail(Object.assign(new Error("graph takes neighbors <path> or lineage <path>"), { code: "USAGE" }), { verb: row.verb, ...ctx });
478
+ const op = READ_OPERATIONS[sub];
479
+ try {
480
+ // The row declares the union; each mode takes its own — an option the
481
+ // mode would ignore is a usage error, not a silent no-op.
482
+ const notFor = sub === "neighbors" ? ["depth"] : ["direction", "limit"];
483
+ for (const o of notFor) if (values[o] !== undefined) throw Object.assign(new Error(`--${o} is not an option of graph ${sub}`), { code: "USAGE" });
484
+ const result = sub === "neighbors"
485
+ ? op.fn(cfg, path, { kinds: values.kind || null, direction: values.direction, limit: values.limit })
486
+ : op.fn(cfg, path, { depth: values.depth, kinds: values.kind && values.kind.length ? values.kind : LINEAGE_KINDS });
487
+ return emitRead({ verb: `graph ${sub}`, ...ctx }, op, result);
488
+ } catch (e) {
489
+ if (e && e.code === "USAGE") return usageFail(e, { verb: row.verb, ...ctx });
490
+ throw e;
491
+ }
492
+ }
493
+
494
+ async function runCodemap(ctx) {
495
+ const { row, values, cfg } = ctx;
496
+ const op = READ_OPERATIONS.codeRefs;
497
+ try {
498
+ if (!values.for) throw Object.assign(new Error("codemap takes --for <selector>; regeneration is reconcile --only codemap"), { code: "USAGE" });
499
+ return emitRead({ verb: row.verb, ...ctx }, op, op.fn(cfg, values.for, { reverse: Boolean(values.reverse) }));
500
+ } catch (e) {
501
+ if (e && e.code === "USAGE") return usageFail(e, { verb: row.verb, ...ctx });
502
+ throw e;
503
+ }
504
+ }
505
+
506
+ function runVersion({ values, stdout }) {
507
+ const version = packageVersion();
508
+ stdout.write(values.json ? JSON.stringify(envelope("version", null, true, { version }), null, 2) + "\n" : version + "\n");
509
+ return 0;
510
+ }
511
+
512
+ function spawnDoctor(project, env, flags) {
513
+ // A project that does not exist is still a project doctor can report on
514
+ // ("not bound") — so the cwd is only set when it can be.
515
+ return spawnSync(process.execPath, [script("doctor.mjs"), ...flags], { encoding: "utf8", cwd: existsSync(project) ? project : undefined, env: ownEnv(env, project), timeout: 60000, maxBuffer: 1 << 24 });
516
+ }
517
+
518
+ // Spawned, never imported (MCP ADR decision 2): doctor's report and main are
519
+ // not exported, and importing them would be a second doctor. doctor.mjs sets
520
+ // no exit code of its own and prints either JSON or text, so text mode is
521
+ // two spawns — one for the findings that decide the exit code, one for the
522
+ // report the user reads. Deliberate; do not "optimise" the second away
523
+ // without giving doctor an exit code first.
524
+ async function runDoctor({ values, project, env, stdout, stderr }) {
525
+ const sections = [values.install && "--install", values.vault && "--vault"].filter(Boolean);
526
+ const r = spawnDoctor(project, env, ["--json", ...sections]);
527
+ const fail = (why) => {
528
+ if (values.json) stdout.write(JSON.stringify(envelope("doctor", project, false, { error: why }), null, 2) + "\n");
529
+ stderr.write(`doctor failed: ${why}\n`);
530
+ return 2;
531
+ };
532
+ if (r.error) return fail(r.error.message);
533
+ if (r.status !== 0 && !r.stdout) return fail((r.stderr || "").trim() || `exit ${r.status}`);
534
+ let findings;
535
+ try { findings = JSON.parse(r.stdout); } catch { return fail("unparseable output"); }
536
+ const ok = !findings.some((f) => f.level === "issue");
537
+ if (values.json) stdout.write(JSON.stringify(envelope("doctor", project, ok, findings), null, 2) + "\n");
538
+ else {
539
+ const t = spawnDoctor(project, env, sections);
540
+ stdout.write(typeof t.stdout === "string" && t.stdout ? t.stdout : `${(t.stderr || "").trim() || "doctor printed nothing"}\n`);
541
+ }
542
+ return ok ? 0 : 1;
543
+ }
544
+
545
+ async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }) {
546
+ const write = Boolean(values.write);
547
+ const only = values.only ?? null;
548
+ // The gate: --only names what is written and confirms headless; a bare
549
+ // --write asks on a terminal and refuses without one.
550
+ if (write && !only) {
551
+ const yes = await confirmWrite(`Regenerate every derived view of ${project}'s vault? [y/N] `, { stdin, stdout, ask });
552
+ if (yes === null) {
553
+ stderr.write("a bare reconcile --write in a non-TTY refuses; name what is written to confirm: --only <target>\n");
554
+ if (values.json) stdout.write(JSON.stringify(envelope("reconcile", project, false, { refused: "non-tty" }), null, 2) + "\n");
555
+ return 1;
556
+ }
557
+ if (!yes) { stdout.write("nothing written.\n"); return 0; }
558
+ }
559
+ const { runReconcile: core } = await import("./reconcile.mjs");
560
+ let out;
561
+ try {
562
+ out = core({ write, only, projectDir: project, env: ownEnv(env, project) });
563
+ } catch (e) {
564
+ const msg = e && e.message ? e.message : String(e);
565
+ stderr.write(msg + "\n");
566
+ if (values.json) stdout.write(JSON.stringify(envelope("reconcile", project, false, { error: msg }), null, 2) + "\n");
567
+ return e && e.code === "UNBOUND" ? 3 : 2;
568
+ }
569
+ const failed = write && out.summary && out.summary.failed > 0;
570
+ stdout.write(JSON.stringify(values.json ? envelope("reconcile", project, !failed, out) : out, null, 2) + "\n");
571
+ return failed ? 1 : 0;
572
+ }
573
+
574
+ async function runInstallVerb({ row, values, project, env, stdin, stdout, ask }) {
575
+ const ih = await import("./install-harness.mjs");
576
+ const opts = { harnesses: values.harness || [], surfaces: values.surface && values.surface.length ? values.surface : null, globalRemoval: Boolean(values.global), register: !values["no-register"], root: PACKAGE_ROOT, env: ownEnv(env, project), stdin, stdout, ask };
577
+ if (row.verb === "plan") {
578
+ const p = ih.plan(project, opts);
579
+ stdout.write(values.json ? JSON.stringify(envelope("plan", project, p.ok && !p.incomplete, { ...p, items: p.items.map(ih.publicItem) }), null, 2) + "\n" : ih.renderPreview(p));
580
+ return p.ok && !p.incomplete ? 0 : 1;
581
+ }
582
+ const r = await ih.runVerb(row.verb, project, opts);
583
+ // A registration the plan could not make (no host CLI) or a host command
584
+ // that failed is exit 1 with the rest applied (install spec contract 4′).
585
+ const ok = r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do");
586
+ if (values.json) {
587
+ stdout.write(JSON.stringify(envelope(row.verb, project, ok, { gate: r.gate, applied: r.applied, failed: r.failed, incomplete: r.plan.incomplete, plannedAgainst: r.plan.plannedAgainst, items: r.plan.items.map(ih.publicItem), refusals: r.plan.refusals, reports: r.plan.reports }), null, 2) + "\n");
588
+ } else {
589
+ stdout.write(r.preview);
590
+ if (r.gate.confirmed) stdout.write(ih.appliedLine(r));
591
+ else if (r.gate.why === "non-tty") stdout.write(`a bare ${row.verb} in a non-TTY refuses; name a harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`);
592
+ else if (r.gate.why === "declined") stdout.write("nothing written.\n");
593
+ }
594
+ return ok ? 0 : 1;
595
+ }