projectstore-codex 0.28.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore",
3
- "version": "0.28.1",
3
+ "version": "0.29.0",
4
4
  "description": "Your agent runs the project through a verified loop: task → artifact (ADR / spec / epic / story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in git — any model can pick the project up tomorrow.",
5
5
  "author": {
6
6
  "name": "Evgenii Konev",
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # projectstore-codex
2
2
 
3
- The Codex installer for [ProjectStore](https://github.com/SmartAndPoint/ProjectStore). It ships a canonical portable `plugin.json`, rendered namespaced workflow skills, lifecycle hooks, and the ProjectStore core pinned at the exact same version and bundled inside the tarball.
3
+ The Codex installer for [ProjectStore](https://github.com/SmartAndPoint/ProjectStore). It ships a Codex plugin manifest (`.codex-plugin/plugin.json`), rendered namespaced workflow skills, lifecycle hooks, and the ProjectStore core pinned at the exact same version and bundled inside the tarball.
4
4
 
5
5
  From a terminal in your project:
6
6
 
@@ -8,7 +8,7 @@ From a terminal in your project:
8
8
  npx projectstore-codex install --project "$PWD"
9
9
  ```
10
10
 
11
- The installer previews every mutation, stages a stable local marketplace under `CODEX_HOME`, asks Codex's own CLI to install the plugin, and verifies the materialised cache by version and payload digest. Restart Codex, approve the ProjectStore hooks when prompted, then run `$projectstore-bind <vault-path>`. Codex picks up hook changes late: a release that changes hooks may take effect only in the session after next.
11
+ The installer prints its plan first — every mutation and every Codex command, verbatim — and at a terminal asks `Apply N changes? [Y/n]` (without one, naming the shell is the confirmation; `--json` never asks). It then stages a stable local marketplace under `CODEX_HOME`, asks Codex's own CLI to install the plugin, verifies the materialised cache by version and payload digest, and says what to do next. `npx projectstore-codex plan --project "$PWD"` prints the same plan and writes nothing; `--verbose` adds every row's reasoning; `<verb> --help` lists a verb's options with examples. Restart Codex, approve the ProjectStore hooks when prompted, then run `$projectstore-bind <vault-path>`. Codex picks up hook changes late: a release that changes hooks may take effect only in the session after next.
12
12
 
13
13
  Upgrade with `npx projectstore-codex@<version> upgrade --project "$PWD"`. Project uninstall leaves the user-global Codex plugin in place; `uninstall --global` is the explicit machine-wide removal.
14
14
 
@@ -8,8 +8,10 @@
8
8
  // `--harness codex` inserted after a verb that takes it. Every other
9
9
  // argument passes through, so `projectstore-codex <verb> …` is exactly
10
10
  // `projectstore <verb> --harness codex …` — the same preview, the
11
- // same files, the same exit code. Naming the shell is the confirmation the
12
- // core's install gate asks for, exactly as naming --harness is.
11
+ // same files, the same exit code. Naming the shell names the harness, exactly
12
+ // as --harness does: without a terminal that is the confirmation the core's
13
+ // install gate asks for; at a terminal the core shows the plan and asks (the
14
+ // install spec, contract 9 as amended 2026-10-04).
13
15
  import { existsSync } from "node:fs";
14
16
  import { spawnSync } from "node:child_process";
15
17
  import { constants as osConstants } from "node:os";
@@ -72,17 +74,20 @@ if (!core) {
72
74
  process.stderr.write(`${SHELL}: ${fixed.error}\n`);
73
75
  process.exitCode = 2;
74
76
  } else {
75
- // stdio inherited: the core's install gate asks on a terminal and refuses
76
- // without one, so the child must see the real stdin and stdout. No
77
- // timeout — the child waits on a human at the preview. exitCode, not
78
- // exit(): the core's own bin says why (a pending write on a pipe).
77
+ // stdio inherited: the core's install gate shows the plan and asks on a
78
+ // terminal, and refuses a bare run without one, so the child must see the
79
+ // real stdin and stdout. No timeout — the child waits on a human at the
80
+ // question. exitCode, not exit(): the core's own bin says why (a pending
81
+ // write on a pipe). PROJECTSTORE_SHELL lets the core's --help speak this
82
+ // shell's name; nothing the core plans or writes reads it.
79
83
  const r = spawnSync(process.execPath, [core, ...fixed.argv], {
80
84
  stdio: "inherit",
81
- env: { ...process.env, PROJECTSTORE_DISTRIBUTION_ROOT: root },
85
+ env: { ...process.env, PROJECTSTORE_DISTRIBUTION_ROOT: root, PROJECTSTORE_SHELL: SHELL },
82
86
  });
83
87
  if (r.error) process.stderr.write(`${SHELL}: ${r.error.message}\n`);
84
- // A signal is relayed the shell way (128 + its number): Ctrl-C at the
85
- // preview is 130 here as it would be on the core itself.
88
+ // A signal is relayed the shell way (128 + its number): the core reads
89
+ // its answer in the terminal's line mode, so Ctrl-C at the question is a
90
+ // real SIGINT — 130 here as it would be on the core itself.
86
91
  process.exitCode = r.status ?? (r.signal ? 128 + (osConstants.signals[r.signal] || 0) : 2);
87
92
  }
88
93
  }
@@ -12,7 +12,7 @@
12
12
  "name": "projectstore",
13
13
  "displayName": "projectstore",
14
14
  "description": "📚 Your agent runs the project through a verified loop: task → artifact (ADR · spec · epic · story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in an Obsidian-friendly vault, every write approved by you — and any model can pick the project up tomorrow.",
15
- "version": "0.28.1",
15
+ "version": "0.29.0",
16
16
  "author": {
17
17
  "name": "Evgenii Konev",
18
18
  "email": "ekonev@smartandpoint.com",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "projectstore",
3
3
  "displayName": "projectstore",
4
- "version": "0.28.1",
4
+ "version": "0.29.0",
5
5
  "description": "Your agent runs the project through a verified loop: task → artifact (ADR / spec / epic / story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in git — any model can pick the project up tomorrow.",
6
6
  "author": {
7
7
  "name": "Evgenii Konev @ SmartAndPoint",
@@ -94,11 +94,11 @@ Contributors: `git clone` this repo, then `claude --plugin-dir ./ProjectStore`.
94
94
  npx projectstore-claude install --project "$PWD"
95
95
  ```
96
96
 
97
- The same tree is published to npm as [`projectstore`](https://www.npmjs.com/package/projectstore) — one source package carrying every harness's manifest — and `projectstore-claude` is its Claude Code shell: the core pinned at the same version and bundled inside, the harness fixed, so the one command has the same shape on every harness. It registers the plugin with Claude Code: it writes a small local marketplace of its own under your Claude home, then drives `claude plugin marketplace add` / `plugin install` **at local scope**, so the registration lands in this checkout's `.claude/settings.local.json` and nowhere else. Every host command is printed before it runs; naming the harness is the confirmation. Restart Claude Code afterwards. A git-marketplace copy already enabled for the checkout is silenced there (not globally) so the plugin does not load twice; `uninstall` turns it back on. Pin or upgrade with `npx projectstore-claude@<version> upgrade --project "$PWD"` — the version you name is the version you run. The core's low-level form, `npx projectstore <verb> --harness claude-code …`, is exactly what the shell runs. bun works the same on the packed bin.
97
+ The same tree is published to npm as [`projectstore`](https://www.npmjs.com/package/projectstore) — one source package carrying every harness's manifest — and `projectstore-claude` is its Claude Code shell: the core pinned at the same version and bundled inside, the harness fixed, so the one command has the same shape on every harness. It registers the plugin with Claude Code: it writes a small local marketplace of its own under your Claude home, then drives `claude plugin marketplace add` / `plugin install` **at local scope**, so the registration lands in this checkout's `.claude/settings.local.json` and nowhere else. It prints its plan first — every file it writes and every host command, verbatim — and at a terminal asks `Apply N changes? [Y/n]`; then it shows each step as it runs and what to do next. Without a terminal (a script, CI, an agent's tool) naming the harness is the confirmation. `plan` prints the same plan and writes nothing; `--verbose` adds every row's reasoning; `npx projectstore-claude <verb> --help` lists a verb's options with examples. Restart Claude Code afterwards. A git-marketplace copy already enabled for the checkout is silenced there (not globally) so the plugin does not load twice; `uninstall` turns it back on. Pin or upgrade with `npx projectstore-claude@<version> upgrade --project "$PWD"` — the version you name is the version you run. The core's low-level form, `npx projectstore <verb> --harness claude-code …`, is exactly what the shell runs. bun works the same on the packed bin.
98
98
 
99
99
  **Codex has its own shell with the same one-command shape:**
100
- `npx projectstore-codex install --project "$PWD"`. It carries a canonical
101
- portable manifest, namespaced workflow and role skills, lifecycle hooks, and
100
+ `npx projectstore-codex install --project "$PWD"`. It carries a Codex
101
+ plugin manifest, namespaced workflow and role skills, lifecycle hooks, and
102
102
  the exact bundled core. It stages a stable marketplace under `CODEX_HOME`,
103
103
  drives `codex plugin marketplace add` and `codex plugin add`, then reads the
104
104
  installation back and verifies its version and payload digest. Restart Codex,
@@ -120,7 +120,7 @@ The package also carries a `bin`. Without a session — in CI, or in a shell —
120
120
 
121
121
  ```
122
122
  npx projectstore doctor --json
123
- npx projectstore install --harness claude-code # the low-level form the shell runs: previews, then writes the agents block and the status line; naming the harness is the confirmation, there is no --yes
123
+ npx projectstore install --harness claude-code # the low-level form the shell runs: shows the plan, asks at a terminal, then writes; without a terminal naming the harness is the confirmation — there is no --yes
124
124
  npx projectstore reconcile --write --only kanban
125
125
  ```
126
126
 
@@ -45,14 +45,15 @@ model names and those are harness-specific — is
45
45
 
46
46
  ## Codex
47
47
 
48
- Experimental, measured on `codex-cli 0.153.4`. The initial spike captured 759
48
+ Experimental, measured on `codex-cli 0.153.4` and, for hook loading, 0.160.0.
49
+ The initial spike captured 759
49
50
  hook firings. The 2026-09-30 gate then built the npm shell from a packed core,
50
51
  passed Codex's plugin validator, installed and upgraded it through an isolated
51
52
  `CODEX_HOME`, verified the materialised cache by version and digest, and loaded
52
53
  the `projectstore-status` skill in a fresh Codex session. The validator (the
53
54
  plugin-creator skill's `validate_plugin.py`) reads only
54
- `.codex-plugin/plugin.json`. The canonical root `plugin.json` was validated
55
- separately on 2026-10-04, against the schema it declares (Agent Plugins 1.0.0).
55
+ `.codex-plugin/plugin.json`, which from 0.28.2 is the shell's only manifest
56
+ (below).
56
57
 
57
58
  That run exercised one skill, not every installed surface, so `verified` stays
58
59
  `null`. The hooks are the reason it matters. On 2026-10-03 the first real
@@ -63,7 +64,8 @@ was all the user saw. The core had taken the shell's root for its own. That is
63
64
  fixed, and the suite now runs every rendered hook from the built shell. A live
64
65
  Codex session firing them from an installed release is still owed.
65
66
 
66
- How 0.153.4 starts a hook was read from its source, not measured: under the
67
+ How 0.153.4 starts a hook was read from its source; on 0.160.0 it was seen
68
+ once, with the hook's `$0` reading `/bin/zsh` and `node` found. Under the
67
69
  session's shell as `<shell> -c`, with the environment the Codex process had when
68
70
  it built the session's hooks. `$SHELL -lc` (or `/bin/sh -lc`) is the fallback
69
71
  when the hooks are built without exactly one ready local environment. So a hook
@@ -93,16 +95,27 @@ npx --package "./dist/projectstore-codex-$(node -p 'require("./package.json").ve
93
95
 
94
96
  What is known, and how:
95
97
 
96
- - **Hooks are rendered; their live firing from an installed release is not
97
- yet observed.** The canonical portable `plugin.json` selects
98
- `./hooks/hooks.json` through `extensions.com.openai`; the compatibility
99
- `.codex-plugin/plugin.json` stays inside the current ingestion schema. Five
98
+ - **Hooks load from Codex's default file, so the shell carries no root
99
+ `plugin.json`.** `.codex-plugin/plugin.json` is its only manifest and names
100
+ no hooks, so Codex falls back to `hooks/hooks.json`. 0.28.1, and every
101
+ build from a checkout since rc.3, also carried a root Agent Plugins
102
+ `plugin.json`, with the hooks under `extensions.com.openai`. Codex picks
103
+ that manifest first, and since openai/codex#37027 (merged 2026-08-05; in
104
+ `codex-cli` 0.153.4 and 0.160.0 alike) it loads no hooks from that format.
105
+ So no hook of ours loaded from those builds on any Codex measured here
106
+ (0.153.4, 0.160.0). On the maintainer's machine
107
+ on 2026-10-04, `/hooks` listed none of ours; with that file renamed away in
108
+ the installed cache, it listed all six, and the app server's `hooks/list`
109
+ gives 0 for 0.28.1's root and 6 for 0.28.2's. Five
100
110
  events: `SessionStart`, `PreToolUse`, `PostToolUse`, `Stop`, `PreCompact`.
101
111
  Of the 759 captured firings, 757 came from the earlier inline form, across
102
112
  `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse` and `Stop`,
103
113
  and 2 from a file-site `hooks/hooks.json` with an absolute `node` path.
104
- `PreCompact` has never been observed firing on Codex, and neither has the
105
- `${PLUGIN_ROOT}` form selected through `extensions.com.openai`. The hook process
114
+ That live `codex-cli` 0.160.0 session ran on a hand-edited 0.28.1 cache, not
115
+ an installed release. Our SessionStart and PreCompact were observed in it. A
116
+ throwaway recorder saw PreToolUse, PostToolUse and Stop fire, but ours on
117
+ PostToolUse, matched to `apply_patch`, still awaits a write. SessionStart ran
118
+ on the session's first message, not when the window opened. The hook process
106
119
  receives `PLUGIN_ROOT` in its environment, naming the shell's root with the
107
120
  core beneath it in `node_modules/projectstore/`, and **no project-directory
108
121
  variable at all**. The project comes from the payload's `cwd`, which every
@@ -20,6 +20,9 @@
20
20
  "/plugin marketplace add SmartAndPoint/ProjectStore && /plugin install projectstore@SmartAndPoint (git marketplace, user-wide)",
21
21
  "restart Claude Code"
22
22
  ],
23
+ "next": [
24
+ "restart Claude Code in this project"
25
+ ],
23
26
  "notes": [
24
27
  "Git marketplace only: auto-update is OFF by default for third-party plugins: /plugin -> Marketplaces -> SmartAndPoint -> toggle auto-update. The npm registration updates when you run `npx projectstore-claude upgrade --project <dir>`.",
25
28
  "Pin a git-marketplace release with: /plugin marketplace add SmartAndPoint/ProjectStore#<tag>; pin the npm registration with npx projectstore-claude@<version>.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "Harness capability manifest — Codex. The committed adapters/codex tree renders the source commands, roles, passive skills and hooks into Codex vocabulary. Measurements began with 759 captured hook firings on 2026-09-07/08; the complete built shell, isolated marketplace install, upgrade, validator and fresh-session skill load passed on codex-cli 0.153.4 on 2026-09-30. The validator reads only .codex-plugin/plugin.json; the canonical root plugin.json was validated separately on 2026-10-04 against the schema it declares (Agent Plugins 1.0.0).",
2
+ "_comment": "Harness capability manifest — Codex. The committed adapters/codex tree renders the source commands, roles, passive skills and hooks into Codex vocabulary. Measurements began with 759 captured hook firings on 2026-09-07/08; the complete built shell, isolated marketplace install, upgrade, validator and fresh-session skill load passed on codex-cli 0.153.4 on 2026-09-30. The validator reads only .codex-plugin/plugin.json, which from 0.28.2 is the shell's one manifest: Codex picks a root Agent Plugins plugin.json first and has loaded no hooks from that format since openai/codex#37027 (merged 2026-08-05).",
3
3
  "id": "codex",
4
4
  "display_name": "Codex",
5
5
  "emit": true,
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "hooks": {
56
56
  "config_file": "hooks/hooks.json",
57
- "config_file_reason": "The canonical portable plugin.json selects ./hooks/hooks.json under extensions.com.openai; the compatibility manifest selects the same root-relative file. This keeps one rendered hook definition for both loaders. The legacy inline form fired 757 events across five event types; the file-selected portable form is gated by the compatibility matrix and fresh-session run.",
57
+ "config_file_reason": "Codex's default hooks file. A legacy .codex-plugin/plugin.json with no `hooks` key falls back to hooks/hooks.json (DEFAULT_HOOKS_CONFIG_FILE in codex-rs/core-plugins/src/loader.rs at rust-v0.160.0), so the manifest names no hooks at all. The shell ships no root Agent Plugins plugin.json. Codex picks that manifest first and then loads no hooks from it, a runtime boundary since openai/codex#37027 (loader.rs:955 at rust-v0.153.4, 952-962 at rust-v0.160.0). With one, the maintainer's codex-cli 0.160.0 listed none of ours in /hooks on 2026-10-04; with the file renamed away in the installed cache, it listed all six. The app-server's hooks/list agrees: 0 for 0.28.1's root, 6 for 0.28.2's. The spike's inline form fired 757 events across five event types.",
58
58
  "events": {
59
59
  "SessionStart": "SessionStart",
60
60
  "PreToolUse": "PreToolUse",
@@ -192,7 +192,7 @@
192
192
  "supported": true,
193
193
  "kind": "host",
194
194
  "scope": "user",
195
- "scope_reason": "Selected by canonical plugin.json and the compatibility manifest, then loaded with the plugin; trust is granted per hook, machine-wide, and cannot be silenced per checkout."
195
+ "scope_reason": "Loaded with the plugin from its default hooks/hooks.json, which .codex-plugin/plugin.json leaves to Codex by naming no hooks; trust is granted per hook, machine-wide, and cannot be silenced per checkout."
196
196
  },
197
197
  "mcp": {
198
198
  "supported": false,
@@ -298,6 +298,10 @@
298
298
  "restart Codex and approve the ProjectStore hooks when prompted",
299
299
  "run `$projectstore-bind <vault-path>` in Codex"
300
300
  ],
301
+ "next": [
302
+ "restart Codex from a terminal and approve the ProjectStore hooks when it asks",
303
+ "in an unbound project, run `$projectstore-bind <vault-path>` in Codex"
304
+ ],
301
305
  "notes": [
302
306
  "The installation is user-global because Codex's marketplace, plugin row and cache live in CODEX_HOME; the AGENTS.md block is project-local.",
303
307
  "Upgrade with `npx projectstore-codex@<version> upgrade --project \"$PWD\"`; the named package version is also the plugin version.",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore",
3
- "version": "0.28.1",
3
+ "version": "0.29.0",
4
4
  "description": "Your agent runs the project through a verified loop: task → artifact (ADR / spec / epic / story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in git — any model can pick the project up tomorrow.",
5
5
  "keywords": [
6
6
  "project-management",
@@ -80,10 +80,11 @@ const COMMAND_OVERRIDES = {
80
80
 
81
81
  ## register / unregister
82
82
 
83
- Preview the requested change and ask for explicit approval. On approval, run the
84
- core's \`install\` or \`uninstall\` verb with \`--harness codex --surface
85
- agents_block --project "$PWD"\`. Print its output verbatim. Never edit the
86
- managed block by hand.
83
+ Preview the requested change with \`plan --harness codex --surface agents_block
84
+ --project "$PWD"\` and ask for explicit approval. On approval, run the core's
85
+ \`install\` or \`uninstall\` verb with \`--harness codex --surface agents_block
86
+ --project "$PWD" --json\` — \`--json\` never waits on a terminal's question — and
87
+ report the envelope's result. Never edit the managed block by hand.
87
88
 
88
89
  ## status
89
90
 
@@ -134,7 +135,9 @@ arguments and \`--json\`. Summarize every finding without re-deriving it.
134
135
  When \`--fix\` is absent, remain read-only. When it is present, separate fixes
135
136
  by owner: derived vault views use \`$projectstore-reconcile\`; Codex plugin or
136
137
  agents-block drift uses the core's \`upgrade --harness codex\` path. Preview
137
- each mutation and ask for explicit approval before running it. Unsupported
138
+ each mutation with \`plan --harness codex\` and ask for explicit approval before
139
+ running it; then run \`upgrade --harness codex --json\`, whose envelope is the
140
+ result — \`--json\` never waits on a terminal's question. Unsupported
138
141
  surfaces remain unsupported; do not create host configuration by hand. Never
139
142
  claim a fix after a non-zero exit.`,
140
143
  },
@@ -41,7 +41,7 @@ import { readFileSync, existsSync } from "node:fs";
41
41
  import { resolve, dirname } from "node:path";
42
42
  import { fileURLToPath } from "node:url";
43
43
  import { parseArgs } from "node:util";
44
- import { createInterface } from "node:readline/promises";
44
+ import * as term from "./term.mjs";
45
45
  import { projectRootDeclared, childEnv, harnessIds, harnessForOverlay, pinPluginRoot } from "./harness.mjs";
46
46
  import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster } from "./lib.mjs";
47
47
  import { READ_OPERATIONS, LINEAGE_KINDS, LINEAGE_DEFAULT_DEPTH, SEARCH_DEFAULT_LIMIT, GRAPH_EDGE_CAP, DIRECTIONS } from "./query.mjs";
@@ -83,14 +83,15 @@ export function resolveProject({ project = null, env = process.env, cwd = proces
83
83
  const opt = (name, arg, summary, multiple = false) => Object.freeze({ name, arg, summary, multiple });
84
84
  const JSON_OPT = opt("json", false, "the envelope");
85
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);
86
+ const HARNESS_OPT = opt("harness", "<id>", "the harness — and, without a terminal, the confirmation (a terminal is asked); there is no --yes", true);
87
87
  const SURFACE_OPT = opt("surface", "<key>", "one surface and those beneath it", true);
88
88
  // The layout move's remedy names this for any copy but the package's own
89
89
  // registration (the layout spec, contract 12 as amended 2026-10-03): it makes
90
90
  // "no host command" true by construction instead of by recognising the root.
91
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];
92
+ const VERBOSE_OPT = opt("verbose", false, "every row's reasoning, each step's why and the host's own notes");
93
+ const INSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, NO_REGISTER_OPT, VERBOSE_OPT, JSON_OPT];
94
+ const UNINSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, opt("global", false, "also remove the harness-global plugin registration"), VERBOSE_OPT, JSON_OPT];
94
95
 
95
96
  export const VERBS = Object.freeze([
96
97
  Object.freeze({
@@ -195,17 +196,141 @@ export const VERBS = Object.freeze([
195
196
  // story that adds a verb in slices.
196
197
  export const PLANNED_VERBS = Object.freeze([]);
197
198
 
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}`);
199
+ // The verbs as a person meets them: setting a project up, reading the vault,
200
+ // keeping it consistent, serving it. A verb not listed lands in "Other", so a
201
+ // new row is never hidden by this table.
202
+ const HELP_GROUPS = [
203
+ ["Set up", ["install", "upgrade", "uninstall", "plan", "bind", "init", "agents"]],
204
+ ["Read", ["status", "search", "show", "graph", "codemap", "orientation"]],
205
+ ["Check and repair", ["doctor", "reconcile"]],
206
+ ["Serve", ["mcp", "version"]],
207
+ ];
208
+
209
+ // Examples per verb. `{cmd}` is how this run was invoked (a shell's own name
210
+ // when PROJECTSTORE_SHELL says so, else the core — a shell passes the read
211
+ // verbs through, so its name serves them too), `{h}` the harness argument the
212
+ // core needs and a shell fixes.
213
+ const EXAMPLES = {
214
+ install: ["{cmd} install{h}", "{cmd} plan{h} # the same plan; nothing is written"],
215
+ upgrade: ["{cmd}@<version> upgrade{h} # the version you name is the version that runs", "{cmd} upgrade{h} --verbose"],
216
+ uninstall: ["{cmd} uninstall{h}", "{cmd} uninstall{h} --surface statusline"],
217
+ plan: ["{cmd} plan{h}", "{cmd} plan{h} --json # one envelope, for scripts and agents"],
218
+ status: ["{cmd} status", "{cmd} status --json"],
219
+ orientation: ["{cmd} orientation --json"],
220
+ search: ['{cmd} search "entry rule" --kind spec', "{cmd} search queue --status accepted --limit 5"],
221
+ show: ["{cmd} show adr/README.md", "{cmd} show epics/PS-CORE/epic.md --section acceptance"],
222
+ graph: ["{cmd} graph neighbors adr/README.md --direction out", "{cmd} graph lineage epics/PS-CORE/epic.md --depth 2"],
223
+ codemap: ["{cmd} codemap --for PS-CORE", "{cmd} codemap --for scripts/lib.mjs --reverse"],
224
+ doctor: ["{cmd} doctor", "{cmd} doctor --install --json"],
225
+ reconcile: ["{cmd} reconcile # what would change; nothing is written", "{cmd} reconcile --write"],
226
+ bind: ["{cmd} bind ~/vaults/my-project", "{cmd} bind ~/vaults/other --rebind"],
227
+ init: ["{cmd} init ~/vaults/new-project --language ru"],
228
+ agents: ["{cmd} agents show", "{cmd} agents configure{h} --default opus --agent clerk=sonnet"],
229
+ mcp: ['{cmd} mcp --project "$PWD"'],
230
+ };
231
+
232
+ // Positional arguments a summary does not already name.
233
+ const ARGS = { search: "<phrase>", show: "<path>" };
234
+
235
+ // "bind <vault> — bind this project…" names its own form; "graph neighbors
236
+ // <path> | graph lineage <path> — …" names two.
237
+ function forms(row) {
238
+ const m = /^(.+?) — (.+)$/.exec(row.summary);
239
+ if (m && m[1].startsWith(row.verb + " ")) {
240
+ const list = m[1].split(" | ").map((f) => f.replace(new RegExp(`^${row.verb} `), ""));
241
+ return { forms: list, about: m[2][0].toUpperCase() + m[2].slice(1) };
242
+ }
243
+ return { forms: [ARGS[row.verb] || ""], about: row.summary };
244
+ }
245
+
246
+ function invocation(env = process.env) {
247
+ const shell = env.PROJECTSTORE_SHELL || null;
248
+ return { shell, cmd: shell ? `npx ${shell}` : "npx projectstore" };
249
+ }
250
+
251
+ function optionLines(options) {
252
+ const rows = options.map((o) => [`--${o.name}${o.arg ? " " + o.arg : ""}`, `${o.summary}${o.multiple ? " (repeatable)" : ""}`]);
253
+ const w = Math.min(28, Math.max(0, ...rows.map(([l]) => l.length)));
254
+ return rows.map(([l, r]) => (l.length > w ? ` ${l}\n ${" ".repeat(w)} ${r}` : ` ${l.padEnd(w)} ${r}`));
255
+ }
256
+
257
+ const EXIT_CODES = "Exit codes 0 ok · 1 findings or a refusal · 2 usage · 3 not bound";
258
+
259
+ export function usage(env = process.env) {
260
+ const { cmd } = invocation(env);
261
+ const lines = [
262
+ "projectstore — project memory for coding agents: decisions, specs, epics and stories as plain markdown.",
263
+ "",
264
+ "Usage",
265
+ ` ${cmd} <verb> [options]`,
266
+ ` ${cmd} <verb> --help one verb's options and examples`,
267
+ ];
268
+ const seen = new Set();
269
+ const groups = HELP_GROUPS.map(([title, names]) => [title, names.map((n) => VERBS.find((v) => v.verb === n)).filter(Boolean)]);
270
+ for (const [, rows] of groups) for (const r of rows) seen.add(r.verb);
271
+ const other = VERBS.filter((v) => !seen.has(v.verb));
272
+ if (other.length) groups.push(["Other", other]);
273
+ for (const [title, rows] of groups) {
274
+ if (!rows.length) continue;
275
+ lines.push("", title);
276
+ for (const v of rows) lines.push(` ${v.verb.padEnd(12)} ${v.summary}`);
203
277
  }
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");
278
+ if (PLANNED_VERBS.length) lines.push("", `Planned: ${PLANNED_VERBS.map((v) => `${v.verb} (${v.lands})`).join(", ")}`);
279
+ lines.push(
280
+ "",
281
+ "Every verb",
282
+ " --project <dir> the project (default: the host session's project, else the current directory)",
283
+ " --json one envelope { schema_version, verb, project, ok, result } — for scripts and agents",
284
+ " --version the package version",
285
+ "",
286
+ "Tips",
287
+ " install, upgrade and uninstall show their plan first; `plan` prints the same plan and writes nothing.",
288
+ " At a terminal they ask before writing. Without one, --harness is the confirmation — there is no --yes.",
289
+ " Unattended in a terminal (a script, a Makefile): pass --json, set CI=1, or give stdin no terminal (`</dev/null`).",
290
+ " --verbose on those verbs adds every row's reasoning and the host's own notes.",
291
+ "",
292
+ `Harnesses ${harnessIds().join(", ")}`,
293
+ EXIT_CODES,
294
+ );
206
295
  return lines.join("\n");
207
296
  }
208
297
 
298
+ // One verb: what it does, how to call it, every option, examples.
299
+ export function verbHelp(row, env = process.env) {
300
+ const { shell, cmd } = invocation(env);
301
+ const fixed = Boolean(shell) && row.options.some((o) => o.name === "harness");
302
+ const opts = row.options.filter((o) => !(fixed && o.name === "harness"));
303
+ const { forms: list, about } = forms(row);
304
+ const lines = [`${cmd} ${row.verb} — ${about}`, "", "Usage"];
305
+ for (const f of list) lines.push(` ${cmd} ${row.verb}${f ? " " + f : ""}${opts.length ? " [options]" : ""}`);
306
+ if (opts.length) lines.push("", "Options", ...optionLines(opts));
307
+ if (fixed) lines.push("", `${shell} names the harness itself: without a terminal, the verb is its own confirmation.`);
308
+ else if (row.options.some((o) => o.name === "harness")) lines.push("", `Harnesses ${harnessIds().join(", ")}`);
309
+ const ex = EXAMPLES[row.verb] || [];
310
+ const h = fixed ? "" : " --harness <id>";
311
+ const rendered = ex.map((e) => e.split("{cmd}").join(cmd).split("{h}").join(h).split(" # "));
312
+ const col = Math.max(0, ...rendered.filter((r) => r.length > 1).map(([c]) => c.length));
313
+ if (ex.length) lines.push("", "Examples", ...rendered.map(([c, note]) => ` ${note ? `${c.padEnd(col)} # ${note}` : c}`));
314
+ lines.push("", EXIT_CODES);
315
+ return lines.join("\n");
316
+ }
317
+
318
+ // The nearest name within two edits — for a mistyped verb or option.
319
+ export function nearest(word, names) {
320
+ const d = (a, b) => {
321
+ const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
322
+ for (let j = 1; j <= b.length; j++) m[0][j] = j;
323
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
324
+ return m[a.length][b.length];
325
+ };
326
+ let best = null, score = 3;
327
+ for (const n of names) {
328
+ const k = n.startsWith(word) && word.length >= 3 ? 0 : d(word, n);
329
+ if (k < score) { best = n; score = k; }
330
+ }
331
+ return best;
332
+ }
333
+
209
334
  // ─── run ───────────────────────────────────────────────────────────────
210
335
 
211
336
  export async function run(argv, { env = process.env, cwd = process.cwd(), stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, ask = null } = {}) {
@@ -217,7 +342,7 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
217
342
  strict: true,
218
343
  options: {
219
344
  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" },
345
+ harness: { type: "string", multiple: true }, surface: { type: "string", multiple: true }, global: { type: "boolean" }, "no-register": { type: "boolean" }, verbose: { type: "boolean" },
221
346
  write: { type: "boolean" }, only: { type: "string" }, install: { type: "boolean" }, vault: { type: "boolean" },
222
347
  kind: { type: "string", multiple: true }, status: { type: "string" }, limit: { type: "string" }, "include-derived": { type: "boolean" }, "case-sensitive": { type: "boolean" },
223
348
  body: { type: "boolean" }, section: { type: "string" }, direction: { type: "string" }, depth: { type: "string" }, for: { type: "string" }, reverse: { type: "boolean" },
@@ -228,7 +353,14 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
228
353
  } catch (e) {
229
354
  // --json cannot be known before parsing; a raw scan is enough here.
230
355
  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`);
356
+ // Node's own wording for an unknown option explains "--" positionals; a
357
+ // person who typed --verbos wants the option they meant.
358
+ const unknown = e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ? /'(-{1,2}[^']+)'/.exec(e.message)?.[1] : null;
359
+ const row = VERBS.find((v) => v.verb === argv.find((a) => !a.startsWith("-")));
360
+ const names = [...(row ? row.options.map((o) => o.name) : VERBS.flatMap((v) => v.options.map((o) => o.name))), "project", "json", "help", "version"];
361
+ const hint = unknown ? nearest(unknown.replace(/^-+/, ""), [...new Set(names)]) : null;
362
+ const message = unknown ? `${row ? row.verb + " does not take" : "unknown option"} ${unknown}${hint ? ` — did you mean --${hint}?` : ""}` : e.message;
363
+ stderr.write(`${message}\nRun ${row ? `\`${row.verb} --help\`` : "--help"} for the options.\n`);
232
364
  return 2;
233
365
  }
234
366
  const { values, positionals } = parsed;
@@ -236,7 +368,7 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
236
368
  // a consumer (the MCP server, a script) always has something to parse.
237
369
  const fail = (verb, project, message, code, { help = false } = {}) => {
238
370
  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" : ""));
371
+ stderr.write(message + "\n" + (help ? usage(env) + "\n" : ""));
240
372
  return code;
241
373
  };
242
374
  // In-process reads resolve layouts and registries from THIS package, as the
@@ -244,11 +376,17 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
244
376
  // session's variable points at.
245
377
  pinPluginRoot(PACKAGE_ROOT);
246
378
  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; }
379
+ if (values.help && positionals.length) {
380
+ const row = VERBS.find((v) => v.verb === positionals[0]);
381
+ if (row) { stdout.write(verbHelp(row, env) + "\n"); return 0; }
382
+ }
383
+ if (values.help || !positionals.length) { (values.help ? stdout : stderr).write(usage(env) + "\n"); return values.help ? 0 : 2; }
248
384
  const verb = positionals[0];
249
385
  const row = VERBS.find((v) => v.verb === verb);
250
386
  if (!row) {
251
387
  const planned = PLANNED_VERBS.find((v) => v.verb === verb);
388
+ const guess = planned ? null : nearest(verb, VERBS.map((v) => v.verb));
389
+ if (guess) return fail(verb, null, `unknown verb: ${verb} — did you mean ${guess}?\nRun --help for every verb.`, 2);
252
390
  return fail(verb, null, (planned ? `${verb} lands with roadmap ${planned.lands}; not in this release.` : `unknown verb: ${verb}`), 2, { help: true });
253
391
  }
254
392
  // The options map is global (parseArgs), the rows are not: an option a row
@@ -257,7 +395,10 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
257
395
  const GLOBAL = new Set(["project", "json", "help", "version"]);
258
396
  const declared = new Set(row.options.map((o) => o.name));
259
397
  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 });
398
+ if (stray.length) {
399
+ const guess = nearest(stray[0], row.options.map((o) => o.name));
400
+ return fail(verb, null, `${verb} does not take --${stray[0]}${guess ? ` — did you mean --${guess}?` : ""}\nRun \`${verb} --help\` for the options.`, 2);
401
+ }
261
402
  const project = resolveProject({ project: values.project, env, cwd });
262
403
  const cfg = readConfigAt(project);
263
404
  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);
@@ -285,8 +426,9 @@ function ownEnv(env, project) {
285
426
  async function confirmWrite(question, { stdin, stdout, ask }) {
286
427
  if (ask) return /^y(es)?$/i.test(String(await ask(question)).trim());
287
428
  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(); }
429
+ // End of input is a no (term.mjs askLine), never a question left pending.
430
+ const answer = await term.askLine(question, stdin, stdout);
431
+ return answer !== null && /^y(es)?$/i.test(answer.trim());
290
432
  }
291
433
 
292
434
  // ─── verbs ─────────────────────────────────────────────────────────────
@@ -573,23 +715,28 @@ async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }
573
715
 
574
716
  async function runInstallVerb({ row, values, project, env, stdin, stdout, ask }) {
575
717
  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 };
718
+ 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, json: Boolean(values.json), verbose: Boolean(values.verbose) };
577
719
  if (row.verb === "plan") {
578
720
  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));
721
+ if (values.json) stdout.write(JSON.stringify(envelope("plan", project, p.ok && !p.incomplete, { ...p, items: p.items.map(ih.publicItem) }), null, 2) + "\n");
722
+ else {
723
+ const c = term.caps(stdout, env);
724
+ stdout.write(ih.renderPreview(p, { verbose: opts.verbose, verb: "plan", paint: term.painter(c), icon: (n) => term.icon(c, n), width: c.live ? c.width : 0 }));
725
+ }
580
726
  return p.ok && !p.incomplete ? 0 : 1;
581
727
  }
582
- const r = await ih.runVerb(row.verb, project, opts);
728
+ // Text mode hands runVerb the stream: the plan is printed before any
729
+ // question, then each step, then DONE (install spec contracts 9 and 18).
730
+ const r = await ih.runVerb(row.verb, project, values.json ? opts : { ...opts, out: stdout });
583
731
  // A registration the plan could not make (no host CLI) or a host command
584
732
  // that failed is exit 1 with the rest applied (install spec contract 4′).
585
733
  const ok = r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do");
586
734
  if (values.json) {
587
735
  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");
736
+ } else if (r.gate.why === "non-tty") {
737
+ stdout.write(`Nothing written: without a terminal, a bare ${row.verb} refuses. Name the harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`);
738
+ } else if (r.gate.why === "declined") {
739
+ stdout.write("Nothing written.\n");
593
740
  }
594
741
  return ok ? 0 : 1;
595
742
  }