projectstore-claude 0.30.6 โ 0.30.8
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.
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/README.md +5 -6
- package/node_modules/projectstore/commands/review.md +2 -2
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +15 -12
- package/node_modules/projectstore/harnesses/claude-code.json +6 -27
- package/node_modules/projectstore/harnesses/codex.json +3 -2
- package/node_modules/projectstore/hooks/band.tsx +17 -15
- package/node_modules/projectstore/hooks/pre-compact.mjs +25 -8
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/configure-menu.mjs +9 -7
- package/node_modules/projectstore/scripts/configure.mjs +0 -1
- package/node_modules/projectstore/scripts/convergence.mjs +2 -4
- package/node_modules/projectstore/scripts/doctor.mjs +2 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +5 -1
- package/node_modules/projectstore/scripts/harness.mjs +32 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +1 -1
- package/node_modules/projectstore/scripts/lib.mjs +2 -0
- package/node_modules/projectstore/scripts/model-spec.mjs +6 -5
- package/node_modules/projectstore/scripts/models.mjs +22 -112
- package/node_modules/projectstore/scripts/term.mjs +3 -3
- package/node_modules/projectstore/scripts/touch-session.mjs +1 -1
- package/node_modules/projectstore/scripts/version-guard.mjs +9 -1
- package/package.json +2 -2
|
@@ -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.30.
|
|
15
|
+
"version": "0.30.8",
|
|
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.30.
|
|
4
|
+
"version": "0.30.8",
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ProjectStore
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> ProjectStore is a method and a framework for building apps and services of any size with AI agents. It keeps the project's record โ what was decided, what is in flight, what was checked โ as reviewed markdown in git, readable by any agent and anyone with the repo.
|
|
4
4
|
|
|
5
5
|
[](https://github.com/SmartAndPoint/ProjectStore/releases) [](./LICENSE) [](https://github.com/SmartAndPoint/ProjectStore/stargazers)
|
|
6
6
|
|
|
@@ -183,7 +183,7 @@ npx projectstore codemap --for scripts/lib.mjs
|
|
|
183
183
|
The same eight reads are an MCP server. The plugin registers it through its own `.mcp.json`, so a Claude Code session has `status`, `search`, `get_artifact`, `neighbors`, `lineage`, `code_refs`, `orientation` and `doctor` as tools with no shell; every tool result is the CLI's `--json` for the same arguments. Elsewhere:
|
|
184
184
|
|
|
185
185
|
```
|
|
186
|
-
npx projectstore mcp --project
|
|
186
|
+
npx projectstore mcp --project .
|
|
187
187
|
```
|
|
188
188
|
|
|
189
189
|
Binding, too โ naming the vault is the confirmation, and changing it needs `--rebind`. `init` creates a whole vault in one step, with no `mkdir` first: the directory, its git repository, the layout's folders and their README indexes; `scaffold --write` adds the folders to a vault that was bound without them:
|
|
@@ -223,7 +223,7 @@ npx projectstore configure
|
|
|
223
223
|
|
|
224
224
|
At a terminal it opens a settings menu that works like a monitor's: โ and โ move, โ opens a group or a setting in a frame beside it, space chooses, โ goes back, and Enter shows the plan. A second Enter writes it. Under the frames, a few lines say what the row under the cursor means. The settings:
|
|
225
225
|
|
|
226
|
-
- each agent's model and effort, per harness. Claude Code offers its aliases: `fable`, `opus`, `sonnet` and `haiku`. Codex offers its own list of models, with its descriptions
|
|
226
|
+
- each agent's model and effort, per harness. Claude Code offers its aliases: `fable`, `opus`, `sonnet` and `haiku`. Codex offers its own list of models, with its descriptions, kept for a day in projectstore's user cache; "refresh the list" asks Codex again. Each agent's effort sits under its model: Claude Code's levels, `low` to `max`, or the levels that Codex model lists. With none set, a Claude Code agent runs at its frontmatter's level (`max`, the clerk `low`) and a Codex one at its model's own default;
|
|
227
227
|
- the status line and the band above the prompt (Claude Code);
|
|
228
228
|
- the vault's template language, the default author and tags;
|
|
229
229
|
- the vault's spec policy and lifecycle gates.
|
|
@@ -393,7 +393,7 @@ The deep dive โ real session files, measured payloads, how every mechanism wor
|
|
|
393
393
|
|
|
394
394
|
## What ProjectStore runs, writes, sends and fetches
|
|
395
395
|
|
|
396
|
-
ProjectStore has no server, no account and no telemetry. It collects nothing about you, your project or your vault. Its own code makes
|
|
396
|
+
ProjectStore has no server, no account and no telemetry. It collects nothing about you, your project or your vault, and it reads no credential from your environment. Its own code makes two requests, the ones under "What it sends and fetches" below, and each one is made by a command you run, never by a hook. One program it runs makes a request of its own: Codex's model command, which refreshes Codex's catalog with your Codex login. This list covers everything in the package, and a [test](https://github.com/SmartAndPoint/ProjectStore/blob/main/tests/disclosure.test.mjs) fails when a shipped module that starts a program or makes a request is not accounted for here.
|
|
397
397
|
|
|
398
398
|
### What it runs
|
|
399
399
|
|
|
@@ -401,7 +401,7 @@ ProjectStore has no server, no account and no telemetry. It collects nothing abo
|
|
|
401
401
|
- **git**, on this machine only. ProjectStore never commits, pushes, pulls or fetches. The SessionStart hook runs `git rev-parse` in a project that is not bound, to see whether it is a worktree of a bound checkout; no other hook starts a program. Bind, init and setup run `git rev-parse --show-toplevel` to find the repository and `git init` to give a new vault its own repository. They run `git config --get user.name` to propose the default author; the binding keeps it, and each new artifact names it as its author. Doctor runs `git check-ignore` and `git ls-files` to check what git ignores and tracks, and `git diff --name-only` and `git log` to see what has changed. The reviewer's list of the files a story touched comes from `git diff` and `git log` too. The reviewer and archaeologist agents read the project's git history (`git status` and `git diff` or `git log` among others) through the agent's own tools, as any command the agent runs.
|
|
402
402
|
- **The agent CLIs, `claude` and `codex`.** Setup, install, upgrade and uninstall run their plugin commands, such as `plugin marketplace add` and `plugin install` to register ProjectStore or `plugin uninstall` to remove it. Each one runs after you confirm the plan, which shows it verbatim. The model menu of `configure` also runs `codex debug models` to read Codex's model list, which Codex refreshes over the network with its own login.
|
|
403
403
|
- **npm**, from setup, install and upgrade only. They run `npm config get registry` to learn which registry your npm uses. When they set up Codex, they also run `npm install` with `--ignore-scripts` to fetch the `projectstore-codex` package at the running version into a folder in ProjectStore's cache, because the Codex plugin ships in that package. No install script of that package runs.
|
|
404
|
-
- **The band above the prompt** (Claude Code) is a module that runs inside Claude Code's own process, not in Node. It reads the binding, the session's pointer and its epic's files, and writes a heartbeat under `.projectstore/state/sessions
|
|
404
|
+
- **The band above the prompt** (Claude Code) is a module that runs inside Claude Code's own process, not in Node. It reads the binding, the session's pointer and its epic's files, and writes a heartbeat, `.band-<session id>.json` under `.projectstore/state/sessions/`, while it draws; it starts no program and makes no request. It watches agent starts (`agent.spawn`), each agent's requests and tool calls, and each turn's end, only to show a ProjectStore agent's row. It passes each of these on unchanged, and it changes no agent, permission or setting.
|
|
405
405
|
- **Your own status line.** When ProjectStore's status line is on and your project's `.claude/settings.json` or your user `settings.json` already names a status line command, ProjectStore runs that command through your shell on every render, passes it the status data Claude Code gives a status line on its standard input, and shows what it prints under its own line.
|
|
406
406
|
|
|
407
407
|
The package also ships `scripts/version-guard.mjs`, which runs `npm pack --dry-run` as the release's own check. The plugin never runs it.
|
|
@@ -420,7 +420,6 @@ ProjectStore never changes Claude Code's or Codex's permission settings.
|
|
|
420
420
|
|
|
421
421
|
- **The npm registry**, `https://registry.npmjs.org/` or the registry your npm is set to. One `GET <registry>/projectstore` request with no credentials and a 2-second limit asks for the newest release. Setup, install and upgrade make it when they run from npm (npx or a global install) or from a git checkout. They never make it from the copy that Claude Code or Codex loads, and never in a dry run. The answer is kept for 24 hours, and `PROJECTSTORE_NO_UPDATE_CHECK=1` turns the request off.
|
|
422
422
|
- **The Codex package.** The `npm install` above fetches `projectstore-codex` from the registry your npm is set to, with npm's own settings, only when setup, install or upgrade sets up Codex.
|
|
423
|
-
- **Anthropic's model list**, `https://api.anthropic.com/v1/models`, or the same path under `$ANTHROPIC_BASE_URL` when that is set. Only the model menu of `configure` at a terminal sends it, only when `ANTHROPIC_API_KEY` is set, and only when the model list is missing or older than a day, or you ask to refresh it. The request carries your key in its `x-api-key` header and nothing else of yours. Only the model names, labels and families of the answer are kept, for a day. Without the variable nothing is sent, and the menu offers Claude Code's aliases.
|
|
424
423
|
|
|
425
424
|
ProjectStore's own code sends nothing else: the hooks, the MCP server, the band and the status line open no connection. Two programs it runs may connect on their own. When the model menu reads Codex's list, `codex debug models` refreshes Codex's catalog with your Codex login. The status line command you had before is yours, and it runs as you set it.
|
|
426
425
|
|
|
@@ -5,7 +5,7 @@ argument-hint: "on | off | status"
|
|
|
5
5
|
|
|
6
6
|
You are toggling the projectstore status line for THIS project. When enabled, `install-harness.mjs` points the project's `.claude/settings.local.json` at a launcher it writes at `.projectstore/state/claude-code/statusline.mjs`. That path carries no version, so it never goes stale: the launcher resolves the *currently installed* plugin on every render, and an update is reflected on the spot instead of one restart later. (A dev checkout or `--plugin-dir` root is wired straight to its `scripts/statusline.mjs` โ those paths have no version to drift.) The line renders `[PS#<version>] ๐ <epic> โบ <story> (<status>)` **composed above** any existing HUD (e.g. oh-my-claudecode), never replacing it. While this session's band above the prompt is drawing (`/projectstore:bar`), the line gives way to it โ your base HUD stays โ and it comes back within 30 s of the band stopping.
|
|
7
7
|
|
|
8
|
-
Resolution is **per-session with
|
|
8
|
+
Resolution is **per-session, with no cross-session read and no read of the vault** (ADR-006): the ๐ segment comes from this session's pointer (`.projectstore/state/sessions/<session_id>.json`, maintained by the tool hooks โ a write's patch after the write โ by `story close`, and at session start; a done, closed or parked story leaves the segment); a fresh session shows an explicit localized cold-start line ("No epic or story in this session yet" / ยซะญะฟะธะบ ะธ ััะพัะธ ะตัั ะฝะต ะฒ ัะฐะฑะพัะต ะฒ ััะพะน ัะตััะธะธยป) โ never a silent blank; a corrupt pointer shows an error-marked string. Strings localize via `templates/<lang>/strings.json` (en fallback). The version badge is controlled by `statusline.show_version` (default true).
|
|
9
9
|
|
|
10
10
|
This command runs `configure --set statusline=on|off` (the configure spec, contracts 6 and 9): install's own items record `statusline.enabled` and write or remove the `settings.local.json` entry and the launcher, in one plan. The command never writes `projectstore.json` itself. The SessionStart hook only refreshes an entry that is already ours and removes it when the flag is off โ it never creates one.
|
|
11
11
|
|
|
@@ -87,25 +87,28 @@ Edits made through the shell are not seen; a Read of the story sets the pointer.
|
|
|
87
87
|
|
|
88
88
|
Continuity across a compaction is delivered by **SessionStart**, not by
|
|
89
89
|
PreCompact โ and that is a correction, not a design flourish. PreCompact has no
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
`additionalContext`, so for its early life the hook assembled a rich packet,
|
|
91
|
+
printed a red *"Hook JSON output validation failed"*, and reached nobody. Its
|
|
92
|
+
`systemMessage` is no channel either: Claude Code discards that field on
|
|
93
|
+
PreCompact. What it does with a PreCompact hook is echo the hook's raw stdout in
|
|
94
|
+
the `/compact` output.
|
|
95
95
|
|
|
96
96
|
So the two hooks now split the work by what each can actually do.
|
|
97
97
|
|
|
98
|
-
PreCompact prints one line, for you:
|
|
98
|
+
PreCompact prints one plain line, for you:
|
|
99
99
|
|
|
100
100
|
```
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
at session start"
|
|
106
|
-
}
|
|
101
|
+
โฟ Compacted (ctrl+o to see full summary)
|
|
102
|
+
PreCompact [...pre-compact.mjs] completed successfully: projectstore:
|
|
103
|
+
compacting โ vault myapp-vault, in flight: `epics/WEB-101/epic.md`,
|
|
104
|
+
orientation and this session's recent files return at session start
|
|
107
105
|
```
|
|
108
106
|
|
|
107
|
+
On a manual `/compact` the model reads that line too, after the summary. On an
|
|
108
|
+
automatic compaction it is shown to no one. (On Codex, where none of this is
|
|
109
|
+
measured yet, the hook keeps printing its line as a `{"systemMessage": โฆ}`
|
|
110
|
+
object.)
|
|
111
|
+
|
|
109
112
|
That last clause is conditional on purpose. It appears only when the compaction
|
|
110
113
|
is **manual** and `auto_inject` is on โ the two things the hook can check at the
|
|
111
114
|
moment it speaks. On an automatic compaction the plugin makes no claim that
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
"_root_placeholder_literal_comment": "true = the harness passes the placeholder through as literal text and the installer must substitute an absolute path; false = the harness expands it. Claude Code expands ${CLAUDE_PLUGIN_ROOT}, so false. Measured value for Codex comes from the spike."
|
|
116
116
|
},
|
|
117
117
|
"output_channels": {
|
|
118
|
-
"_comment": "Per-event output channels (generation spec, contract 17). `fields` are the hookSpecificOutput keys the event honours; `system_message` is the top-level systemMessage; `decision` names the decision object where one exists; an optional `input_fields` lists the stdin keys; Stop's `carrier` is the shape the entry reminder's Stop carrier prints (additionalContext | block | null) โ our choice where both are honoured. `evidence` is `measured` only where this repository's own hooks observe the fact, `documented` where the Claude Code hooks reference says so and we never emit it.",
|
|
118
|
+
"_comment": "Per-event output channels (generation spec, contract 17). `fields` are the hookSpecificOutput keys the event honours; `system_message` is the top-level systemMessage; `decision` names the decision object where one exists; an optional `input_fields` lists the stdin keys; Stop's `carrier` is the shape the entry reminder's Stop carrier prints (additionalContext | block | null) โ our choice where both are honoured; PreCompact's `carrier` is how hooks/pre-compact.mjs prints its line (stdout | null, null keeping the {\"systemMessage\"} object). `system_message` is true where the field is honoured, false where it is measured discarded, null where unmeasured. `evidence` is `measured` only where this repository's own hooks observe the fact, `documented` where the Claude Code hooks reference says so and we never emit it.",
|
|
119
119
|
"SessionStart": {
|
|
120
120
|
"fields": [
|
|
121
121
|
"additionalContext"
|
|
@@ -195,10 +195,11 @@
|
|
|
195
195
|
},
|
|
196
196
|
"PreCompact": {
|
|
197
197
|
"fields": [],
|
|
198
|
-
"system_message":
|
|
198
|
+
"system_message": false,
|
|
199
199
|
"decision": null,
|
|
200
|
+
"carrier": "stdout",
|
|
200
201
|
"evidence": "measured",
|
|
201
|
-
"note": "Measured NEGATIVELY: an output object containing additionalContext is rejected whole, taking systemMessage down with it (
|
|
202
|
+
"note": "Measured NEGATIVELY: an output object containing additionalContext is rejected whole, taking systemMessage down with it (vault story: PreCompact survival packet targets a channel PreCompact does not have). A top-level `decision: \"block\"` exists (documented; never emitted here). The systemMessage field is discarded (hooks reference; measured on 2.1.312, 2026-10-28): what carries the line is the hook's raw stdout, echoed in the /compact output as `PreCompact [<command>] completed successfully: <stdout>` (the same form was seen on 2026-08-18). On a manual compaction the user sees it and the model reads it as <local-command-stdout> after the summary; on an automatic one nothing is shown, and the compacting model appears to receive it (nothing relies on that). stderr reaches no one. Vault story: On Claude Code PreCompact speaks plain text, because the host echoes its stdout and discards systemMessage."
|
|
202
203
|
}
|
|
203
204
|
},
|
|
204
205
|
"surfaces": {
|
|
@@ -649,30 +650,8 @@
|
|
|
649
650
|
"description": "Follows the newest Haiku model: Claude Code resolves the alias when it starts the agent."
|
|
650
651
|
}
|
|
651
652
|
],
|
|
652
|
-
"static_reason": "The enum above, in the order the menu lists it.
|
|
653
|
-
"catalogs": [
|
|
654
|
-
{
|
|
655
|
-
"kind": "http",
|
|
656
|
-
"source": "the Anthropic API",
|
|
657
|
-
"caption": "Anthropic API",
|
|
658
|
-
"url": "https://api.anthropic.com/v1/models?limit=1000",
|
|
659
|
-
"base_env": "ANTHROPIC_BASE_URL",
|
|
660
|
-
"path": "/v1/models?limit=1000",
|
|
661
|
-
"key_env": "ANTHROPIC_API_KEY",
|
|
662
|
-
"key_header": "x-api-key",
|
|
663
|
-
"headers": { "anthropic-version": "2023-06-01" },
|
|
664
|
-
"fields": {
|
|
665
|
-
"list": "data",
|
|
666
|
-
"id": "id",
|
|
667
|
-
"label": "display_name",
|
|
668
|
-
"family": "line",
|
|
669
|
-
"order": "created_at",
|
|
670
|
-
"order_desc": true,
|
|
671
|
-
"keep_when": { "lifecycle": "active" }
|
|
672
|
-
},
|
|
673
|
-
"reason": "Measured 2026-10-13: 14 models in 0.5โ0.7 s, each with id, display_name, created_at, line (fable, opus, sonnet, haiku) and lifecycle. `line` is the family an alias follows, so the newest active entry of a line is the best local account of what that alias starts; Claude Code resolves the alias itself. Read only when a model list opens with a stale cache, or on refresh, and only with ANTHROPIC_API_KEY set; ANTHROPIC_BASE_URL, when set, is the base the key belongs to."
|
|
674
|
-
}
|
|
675
|
-
]
|
|
653
|
+
"static_reason": "The enum above, in the order the menu lists it. It is the whole list: Anthropic's model list takes a key, and ProjectStore reads no credential from the environment (the 0.30.7 story, PS-DIRECTORY), so no catalog is declared and the menu asks nothing. A Claude Code release that adds an alias is named with `--set`, with the warning, until this list learns it.",
|
|
654
|
+
"catalogs": []
|
|
676
655
|
}
|
|
677
656
|
}
|
|
678
657
|
}
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"_root_placeholder_literal_comment": "true: Codex substitutes nothing in a hook command string. It does not need to โ the hook process receives PLUGIN_ROOT in its environment (measured). The generator therefore renders an absolute interpreter and an absolute script path; a bare `node` is a PATH gamble."
|
|
73
73
|
},
|
|
74
74
|
"output_channels": {
|
|
75
|
-
"_comment": "Per-event channels (generation spec, contract 17, as amended 2026-10-15). `fields` are the hookSpecificOutput keys the event honours โ the output side, as on every manifest; `input_fields` are the stdin keys it delivers, captured from live firings (759 on 2026-09-07/08; Run 4 of *Codex hook contract measured in a live session*, codex-cli 0.162.1, 2026-10-15, traces t1โt7). `decision` names the decision object where one exists; Stop's `carrier` is the shape the entry reminder's Stop carrier prints (additionalContext | block | null). `system_message` stays null: documented, not measured. `evidence` is about the output side.",
|
|
75
|
+
"_comment": "Per-event channels (generation spec, contract 17, as amended 2026-10-15). `fields` are the hookSpecificOutput keys the event honours โ the output side, as on every manifest; `input_fields` are the stdin keys it delivers, captured from live firings (759 on 2026-09-07/08; Run 4 of *Codex hook contract measured in a live session*, codex-cli 0.162.1, 2026-10-15, traces t1โt7). `decision` names the decision object where one exists; Stop's `carrier` is the shape the entry reminder's Stop carrier prints (additionalContext | block | null); PreCompact's `carrier` is how hooks/pre-compact.mjs prints its line (stdout | null). `system_message` stays null: documented, not measured. `evidence` is about the output side.",
|
|
76
76
|
"SessionStart": {
|
|
77
77
|
"input_fields": [
|
|
78
78
|
"session_id",
|
|
@@ -185,8 +185,9 @@
|
|
|
185
185
|
"fields": [],
|
|
186
186
|
"system_message": null,
|
|
187
187
|
"decision": null,
|
|
188
|
+
"carrier": null,
|
|
188
189
|
"evidence": "documented",
|
|
189
|
-
"note": "trigger is `auto` on an automatic compaction, and PostCompact follows (Run 4, t2:5โ6). No permission_mode. Whether a systemMessage reaches anyone on Codex is unmeasured."
|
|
190
|
+
"note": "trigger is `auto` on an automatic compaction, and PostCompact follows (Run 4, t2:5โ6). No permission_mode. Whether a systemMessage reaches anyone on Codex is unmeasured, so no carrier is set and hooks/pre-compact.mjs keeps its {\"systemMessage\"} object here."
|
|
190
191
|
}
|
|
191
192
|
},
|
|
192
193
|
"surfaces": {
|
|
@@ -197,14 +197,14 @@ async function storiesOf($: EngineInterface, b: Band, epicDir: string): Promise<
|
|
|
197
197
|
list: async (dir: string) => {
|
|
198
198
|
const entries = await $.fs.list(dir)
|
|
199
199
|
const out = []
|
|
200
|
-
for (const
|
|
201
|
-
let kind =
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
let kind = entry.kind
|
|
202
202
|
if (kind === 'other') {
|
|
203
203
|
// A symbolic link: the board follows it, so the band does too.
|
|
204
|
-
try { kind = (await $.fs.stat(`${dir}/${
|
|
204
|
+
try { kind = (await $.fs.stat(`${dir}/${entry.name}`, { resolve: true })).kind } catch { kind = 'other' }
|
|
205
205
|
}
|
|
206
|
-
seen.push(`${dir}/${
|
|
207
|
-
out.push({ ...
|
|
206
|
+
seen.push(`${dir}/${entry.name}:${kind}:${entry.mtimeMs}`)
|
|
207
|
+
out.push({ ...entry, kind })
|
|
208
208
|
}
|
|
209
209
|
return out
|
|
210
210
|
},
|
|
@@ -240,9 +240,9 @@ async function storiesOf($: EngineInterface, b: Band, epicDir: string): Promise<
|
|
|
240
240
|
})
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
|
-
const
|
|
244
|
-
b.stories.set(epicDir,
|
|
245
|
-
return
|
|
243
|
+
const fresh = { sig, counts: foldStages(statuses) as BandCounts, items }
|
|
244
|
+
b.stories.set(epicDir, fresh)
|
|
245
|
+
return fresh
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
// This session's story, from its file (contract 8): the item the pointer's
|
|
@@ -313,10 +313,12 @@ async function scan($: EngineInterface, b: Band, root: string, sid: string | nul
|
|
|
313
313
|
// โโ The clock: scan, heartbeat, prune (contract 10) โโโโโโโโโโโโโโโโโโโ
|
|
314
314
|
|
|
315
315
|
async function writeHeartbeat($: EngineInterface, root: string, sid: string | null, body: unknown): Promise<boolean> {
|
|
316
|
-
|
|
317
|
-
|
|
316
|
+
// An id that cannot name a file writes nothing. The path is spelled out,
|
|
317
|
+
// folder and name, so the directory's scan can read where the band writes;
|
|
318
|
+
// a node test holds it to layoutPaths() and heartbeatFile().
|
|
319
|
+
if (!heartbeatFile(sid)) return false
|
|
318
320
|
try {
|
|
319
|
-
await $.fs.write(`${root}
|
|
321
|
+
await $.fs.write(`${root}/.projectstore/state/sessions/.band-${sid}.json`, JSON.stringify(body) + '\n')
|
|
320
322
|
return true
|
|
321
323
|
} catch {
|
|
322
324
|
// A heartbeat missed: the status line keeps its own line, and the next
|
|
@@ -341,11 +343,11 @@ async function prune($: EngineInterface, now: number) {
|
|
|
341
343
|
if (live.length !== list.length) await update($, crew, () => live)
|
|
342
344
|
}
|
|
343
345
|
|
|
344
|
-
async function show($: EngineInterface, b: Band,
|
|
345
|
-
const key = JSON.stringify(
|
|
346
|
+
async function show($: EngineInterface, b: Band, shown: BandView | null) {
|
|
347
|
+
const key = JSON.stringify(shown)
|
|
346
348
|
if (key === b.shownKey) return
|
|
347
349
|
b.shownKey = key
|
|
348
|
-
await update($, view, () =>
|
|
350
|
+
await update($, view, () => shown)
|
|
349
351
|
}
|
|
350
352
|
|
|
351
353
|
async function tick($: EngineInterface, b: Band) {
|
|
@@ -362,7 +364,7 @@ async function tick($: EngineInterface, b: Band) {
|
|
|
362
364
|
b.scan = { gen, startedAt: now }
|
|
363
365
|
const current = () => b.scan?.gen === gen
|
|
364
366
|
const scanning = scan($, b, root, sid)
|
|
365
|
-
.then(
|
|
367
|
+
.then(found => (current() ? show($, b, found) : undefined))
|
|
366
368
|
.catch(() => undefined)
|
|
367
369
|
.finally(() => {
|
|
368
370
|
if (current()) b.scan = null
|
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Fires before context compaction (both manual /compact and automatic).
|
|
5
5
|
//
|
|
6
|
-
// ONE
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
6
|
+
// ONE line, through the identified harness's PreCompact carrier (spec
|
|
7
|
+
// contract 22, as amended 2026-10-28). On Claude Code that is plain stdout: the
|
|
8
|
+
// host discards a PreCompact `systemMessage` and echoes the hook's raw stdout in
|
|
9
|
+
// the /compact output instead, where the user sees it and, on a manual
|
|
10
|
+
// compaction, the model reads it after the summary. An automatic compaction
|
|
11
|
+
// shows it to no one. Elsewhere โ nothing identified, or nothing measured โ the
|
|
12
|
+
// line keeps the {"systemMessage"} object it has always had.
|
|
13
|
+
//
|
|
14
|
+
// PreCompact has no `additionalContext`: the runtime rejects the whole object
|
|
15
|
+
// when one is present, so for its early history this hook assembled a packet,
|
|
16
|
+
// printed a red validation error, and reached nobody. Agent-facing continuity
|
|
17
|
+
// after a compaction is SessionStart's job, on `source: "compact"` (spec
|
|
18
|
+
// contract 19).
|
|
12
19
|
//
|
|
13
20
|
// What this hook may CLAIM is narrower than what it knows, and deliberately so:
|
|
14
21
|
// - naming the in-flight artifact is always safe โ it is a fact about the log
|
|
@@ -32,12 +39,22 @@ import {
|
|
|
32
39
|
resolveInFlightArtifact,
|
|
33
40
|
pathCell,
|
|
34
41
|
commandForm,
|
|
42
|
+
preCompactCarrier,
|
|
35
43
|
} from "../scripts/lib.mjs";
|
|
36
44
|
|
|
45
|
+
// The file's one writer to stdout, for the success path and the catch-all
|
|
46
|
+
// alike. A plain line stays one line: the object form escaped a line break in a
|
|
47
|
+
// vault name or an error message for free, the text form has to fold it.
|
|
48
|
+
//
|
|
37
49
|
// Exits after the flush, never before: process.exit does not drain a pending
|
|
38
50
|
// pipe write, and the budgeted read below can still be outstanding.
|
|
39
|
-
function emit(
|
|
40
|
-
|
|
51
|
+
function emit(line) {
|
|
52
|
+
let carrier = null;
|
|
53
|
+
try {
|
|
54
|
+
carrier = preCompactCarrier();
|
|
55
|
+
} catch {}
|
|
56
|
+
const out = carrier === "stdout" ? line.replace(/[\r\n]+/g, " ") : JSON.stringify({ systemMessage: line });
|
|
57
|
+
process.stdout.write(out + "\n", () => process.exit(0));
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
// Contract 13's budget applies here too. The session file lives inside the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.8",
|
|
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",
|
|
@@ -444,16 +444,17 @@ function effortOptions(es, display, item, name, isDefault, levels, model, d, men
|
|
|
444
444
|
return opts;
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
-
// The values frame's footer: the list's source and age, the
|
|
448
|
-
// asked, or why nothing can be asked (the ADR, decisions 3 and 6).
|
|
447
|
+
// The values frame's footer: the list's source and age, the catalog being
|
|
448
|
+
// asked, or why nothing can be asked (the ADR, decisions 3 and 6). An alias
|
|
449
|
+
// harness with no list says that its aliases are all it offers.
|
|
449
450
|
function caption(cat, now) {
|
|
450
451
|
const k = cat.known;
|
|
451
452
|
if (!k) return null;
|
|
452
453
|
const name = k.caption || k.live?.caption || "";
|
|
453
|
-
if (cat.busy) return { text: `asking ${k.live?.
|
|
454
|
+
if (cat.busy) return { text: `asking ${k.live?.caption || name}โฆ`, busy: true };
|
|
454
455
|
if (cat.error) return { text: `${k.live?.caption || name} ยท ${cat.error}` };
|
|
455
456
|
if (k.at) return { text: `${name} ยท checked ${checkedWhen(k.at, now())}` };
|
|
456
|
-
if (k.
|
|
457
|
+
if (k.spawnTakes === "alias") return { text: k.why ? `aliases only ยท ${k.why}` : "aliases only" };
|
|
457
458
|
return k.why ? { text: k.why } : null;
|
|
458
459
|
}
|
|
459
460
|
|
|
@@ -503,7 +504,9 @@ function modelOptions(h, display, item, cat, { isDefault, defaultNow, menu, now,
|
|
|
503
504
|
section: opts.length ? undefined : "",
|
|
504
505
|
description: k?.hidden?.has(v)
|
|
505
506
|
? `${display} hides it from its own picker. It is kept as it is.`
|
|
506
|
-
:
|
|
507
|
+
: k?.spawnTakes === "alias"
|
|
508
|
+
? `Not one of ${display}'s aliases. It is kept as it is.`
|
|
509
|
+
: `Not in ${source ? `${source}'s list` : "any list"}. It is kept as it is.`,
|
|
507
510
|
});
|
|
508
511
|
// No free text where the spawn takes aliases: every other name fails at
|
|
509
512
|
// spawn, and --set is the way to name one (the ADR, decision 5).
|
|
@@ -524,7 +527,7 @@ function modelOptions(h, display, item, cat, { isDefault, defaultNow, menu, now,
|
|
|
524
527
|
description: cat.busy
|
|
525
528
|
? `Asking ${liveSource} for its list now.`
|
|
526
529
|
: !k?.live
|
|
527
|
-
? `Nothing to ask: ${k?.why || "this harness declares no list to read"}.`
|
|
530
|
+
? `Nothing to ask: ${k?.why || (k.spawnTakes === "alias" ? `${display}'s list is its aliases` : "this harness declares no list to read")}.`
|
|
528
531
|
: cat.error
|
|
529
532
|
? `The last ask failed: ${cat.error}. The list is as it was.`
|
|
530
533
|
: `Asks ${liveSource} for its list now.${k.at ? ` Last checked ${checkedWhen(k.at, now())}.` : ""} A list is kept for a day, so opening it never waits on the network.`,
|
|
@@ -612,7 +615,6 @@ export async function runMenu(ctx) {
|
|
|
612
615
|
home,
|
|
613
616
|
now,
|
|
614
617
|
signal: ac.signal,
|
|
615
|
-
...(ctx.fetch ? { fetch: ctx.fetch } : {}),
|
|
616
618
|
...(ctx.spawn ? { spawn: ctx.spawn } : {}),
|
|
617
619
|
...(ctx.timers ? { timers: ctx.timers } : {}),
|
|
618
620
|
}).then((r) => {
|
|
@@ -72,8 +72,6 @@ import { registrationPaths } from "./surfaces.mjs";
|
|
|
72
72
|
// (rule 9's command-form rule). The shells bundle it; the notice and the stop
|
|
73
73
|
// name the package that runs, never a shell.
|
|
74
74
|
export const PACKAGE = "projectstore";
|
|
75
|
-
// What the read uses when the user's registry cannot be resolved.
|
|
76
|
-
export const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
|
|
77
75
|
// The abbreviated packument: what `npm install` itself reads (8 728 bytes
|
|
78
76
|
// against 50 896 for the full document, measured 2026-10-09).
|
|
79
77
|
export const ACCEPT = "application/vnd.npm.install-v1+json";
|
|
@@ -90,8 +88,8 @@ export const MIN_READ_MS = 50;
|
|
|
90
88
|
export const FRESH_MS = 24 * 60 * 60 * 1000;
|
|
91
89
|
export const NO_CHECK_ENV = "PROJECTSTORE_NO_UPDATE_CHECK";
|
|
92
90
|
|
|
93
|
-
// Strict semver 2.0 (
|
|
94
|
-
// anything else counts as missing โ never printed, never compared.
|
|
91
|
+
// Strict semver 2.0 (the specification's own grammar): a `dist-tags.latest`
|
|
92
|
+
// that is anything else counts as missing โ never printed, never compared.
|
|
95
93
|
const NUM = "(?:0|[1-9]\\d*)";
|
|
96
94
|
const PRE = `(?:${NUM}|\\d*[A-Za-z-][0-9A-Za-z-]*)`;
|
|
97
95
|
const SEMVER = new RegExp(
|
|
@@ -109,8 +109,8 @@ import {
|
|
|
109
109
|
hostRegistry,
|
|
110
110
|
autoUpdateDefault,
|
|
111
111
|
} from "./harness.mjs";
|
|
112
|
-
// The pure reader, never models.mjs: that file
|
|
113
|
-
// SessionStart hook imports this one (the portability suite's hook fence).
|
|
112
|
+
// The pure reader, never models.mjs: that file starts a catalog command, and
|
|
113
|
+
// the SessionStart hook imports this one (the portability suite's hook fence).
|
|
114
114
|
import { effortSpec } from "./model-spec.mjs";
|
|
115
115
|
|
|
116
116
|
// A remedy used to interpolate the surface's harness variable here. It cannot:
|
|
@@ -214,8 +214,12 @@ export function npmError(stdout, stderr) {
|
|
|
214
214
|
|
|
215
215
|
const seconds = (ms) => `${Number((ms / 1000).toFixed(ms % 1000 ? 1 : 0))} s`;
|
|
216
216
|
|
|
217
|
-
// npm's own registry: the one README's registry-mirror advice names
|
|
217
|
+
// npm's own registry: the one README's registry-mirror advice names, and the
|
|
218
|
+
// plugin's one spelling of it.
|
|
218
219
|
export const PUBLIC_REGISTRY = "https://registry.npmjs.org";
|
|
220
|
+
// npm's default: what the installer's registry read (readLatest) and its
|
|
221
|
+
// registry comparison use when the user's own registry cannot be resolved.
|
|
222
|
+
export const DEFAULT_REGISTRY = `${PUBLIC_REGISTRY}/`;
|
|
219
223
|
|
|
220
224
|
// A registry that has no such shell may be a mirror that has not synced it:
|
|
221
225
|
// the run once more, from npm's public registry, in README's form โ the
|
|
@@ -473,6 +473,38 @@ export function stopCarrierProblem(manifest) {
|
|
|
473
473
|
return null;
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
+
// โโโ The PreCompact carrier (generation spec, contract 17; skeleton spec,
|
|
477
|
+
// contract 22, both as amended 2026-10-28) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
478
|
+
//
|
|
479
|
+
// How hooks/pre-compact.mjs prints its one line. Claude Code discards a
|
|
480
|
+
// PreCompact systemMessage and echoes the hook's raw stdout in the /compact
|
|
481
|
+
// output instead (measured 2026-10-28), so there the line goes out as plain
|
|
482
|
+
// text. Null โ nothing identified, or no carrier measured โ keeps the
|
|
483
|
+
// {"systemMessage"} object the hook has always printed: a plain line on a host
|
|
484
|
+
// whose schema refuses it would be a failed hook, and an unchanged object is
|
|
485
|
+
// not. The same identified-only rule as stopCarrier, for the same reason.
|
|
486
|
+
export const PRECOMPACT_CARRIERS = Object.freeze(["stdout"]);
|
|
487
|
+
export function preCompactCarrier(env = process.env, dir = MANIFEST_DIR) {
|
|
488
|
+
const id = identifiedHarnessId(env, dir);
|
|
489
|
+
const carrier = id ? loadHarness(id, dir)?.output_channels?.PreCompact?.carrier : null;
|
|
490
|
+
return PRECOMPACT_CARRIERS.includes(carrier) ? carrier : null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// What is wrong with a manifest's PreCompact row, or null. The key is present
|
|
494
|
+
// on every row (null when nothing is measured), and a carrier is only ever
|
|
495
|
+
// chosen from a measured row.
|
|
496
|
+
export function preCompactCarrierProblem(manifest) {
|
|
497
|
+
const row = manifest?.output_channels?.PreCompact;
|
|
498
|
+
if (row === null || row === undefined) return null;
|
|
499
|
+
if (typeof row !== "object") return "output_channels.PreCompact is not an object";
|
|
500
|
+
if (!("carrier" in row)) return "output_channels.PreCompact has no carrier (null when none is measured)";
|
|
501
|
+
if (![...PRECOMPACT_CARRIERS, null].includes(row.carrier))
|
|
502
|
+
return `output_channels.PreCompact.carrier ${JSON.stringify(row.carrier)} is not one of ${PRECOMPACT_CARRIERS.join(", ")} or null`;
|
|
503
|
+
if (row.carrier !== null && row.evidence !== "measured")
|
|
504
|
+
return "output_channels.PreCompact.carrier is set on a row that is not measured";
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
|
|
476
508
|
// โโโ Who a message is for (generation spec, contract 18) โโโโโโโโโโโโโโโ
|
|
477
509
|
//
|
|
478
510
|
// The core prints for ONE harness at a time, and a command it names must be
|
|
@@ -159,6 +159,7 @@ import {
|
|
|
159
159
|
fetchRefusal,
|
|
160
160
|
fetchRunId,
|
|
161
161
|
sweepFetchRuns,
|
|
162
|
+
DEFAULT_REGISTRY,
|
|
162
163
|
} from "./fetch-shell.mjs";
|
|
163
164
|
import { HOST_BUDGET_MS, startFailure, runHost } from "./run-host.mjs";
|
|
164
165
|
import {
|
|
@@ -191,7 +192,6 @@ import {
|
|
|
191
192
|
terminalRun,
|
|
192
193
|
copyBehind,
|
|
193
194
|
behindAdvice,
|
|
194
|
-
DEFAULT_REGISTRY,
|
|
195
195
|
NOTICE_BUDGET_MS,
|
|
196
196
|
READ_TIMEOUT_MS,
|
|
197
197
|
MIN_READ_MS,
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
whichOnPath,
|
|
55
55
|
hostSessionId,
|
|
56
56
|
stopCarrier,
|
|
57
|
+
preCompactCarrier,
|
|
57
58
|
hostRegistry,
|
|
58
59
|
autoUpdateDefault,
|
|
59
60
|
} from "./harness.mjs";
|
|
@@ -194,6 +195,7 @@ export {
|
|
|
194
195
|
speakingHarness,
|
|
195
196
|
whichOnPath,
|
|
196
197
|
stopCarrier,
|
|
198
|
+
preCompactCarrier,
|
|
197
199
|
};
|
|
198
200
|
|
|
199
201
|
// A hook's payload carries the `cwd` of the session that fired it, and on a
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Pure readers, split from models.mjs (which re-exports them) so a hook can
|
|
8
8
|
// read the manifest without loading the module that reads a live catalog.
|
|
9
|
-
// models.mjs
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
9
|
+
// models.mjs starts a harness's catalog command as a subprocess, and reads
|
|
10
|
+
// no credential from the environment (the 0.30.7 story, PS-DIRECTORY);
|
|
11
|
+
// doctor, which the SessionStart hook imports, needs only effortSpec. The
|
|
12
|
+
// portability suite fences every hook's imports off any module with a fetch
|
|
13
|
+
// call, and holds models.mjs out of SessionStart's: the story "The MCP
|
|
14
|
+
// server's entry reaches no install verbโฆ", PS-DIRECTORY, decision 4.
|
|
14
15
|
// This file imports nothing: keep it that way.
|
|
15
16
|
|
|
16
17
|
export function modelSpec(manifest) {
|
|
@@ -31,9 +31,11 @@
|
|
|
31
31
|
// effortProblem(manifest) โ what is wrong with the block, or null
|
|
32
32
|
//
|
|
33
33
|
// Local reads are synchronous and cheap: the manifest, a `file` catalog, and
|
|
34
|
-
// projectstore's own cache. A `command`
|
|
35
|
-
//
|
|
36
|
-
//
|
|
34
|
+
// projectstore's own cache. A `command` catalog is read only by readLive,
|
|
35
|
+
// which a menu starts in the background. There is no `http` kind: this module
|
|
36
|
+
// makes no request and reads no credential from the environment (the 0.30.7
|
|
37
|
+
// story, PS-DIRECTORY). env, home, now, spawn and timers are parameters, so
|
|
38
|
+
// no test reads the machine.
|
|
37
39
|
|
|
38
40
|
import { spawn as spawnChild } from "node:child_process";
|
|
39
41
|
import { mkdirSync, readFileSync, statSync } from "node:fs";
|
|
@@ -44,8 +46,8 @@ import { writeFileAtomic } from "./lib.mjs";
|
|
|
44
46
|
import { modelSpec, effortSpec } from "./model-spec.mjs";
|
|
45
47
|
|
|
46
48
|
// The two block readers live in model-spec.mjs, which imports nothing, so a
|
|
47
|
-
// hook's imports reach them without reaching this file's
|
|
48
|
-
// every caller that reads the catalog too keeps one import.
|
|
49
|
+
// hook's imports reach them without reaching this file's subprocess.
|
|
50
|
+
// Re-exported: every caller that reads the catalog too keeps one import.
|
|
49
51
|
export { modelSpec, effortSpec };
|
|
50
52
|
|
|
51
53
|
// A known list is fresh for a day (decision 3); a live read is bounded by 3 s.
|
|
@@ -162,6 +164,10 @@ export function modelCachePath(manifest, { env = process.env, home = homedir() }
|
|
|
162
164
|
return cachePaths({ env, home }).models(manifest?.id);
|
|
163
165
|
}
|
|
164
166
|
|
|
167
|
+
// The cache is read only when a catalog the manifest declares names its
|
|
168
|
+
// source: a cache an older release wrote from a catalog this one no longer
|
|
169
|
+
// declares is ignored, never deleted, since opening a list writes nothing
|
|
170
|
+
// (the ADR, decision 3; the 0.30.7 story, PS-DIRECTORY).
|
|
165
171
|
function readCache(manifest, opts) {
|
|
166
172
|
const p = modelCachePath(manifest, opts);
|
|
167
173
|
if (!p) return null;
|
|
@@ -169,6 +175,9 @@ function readCache(manifest, opts) {
|
|
|
169
175
|
const c = JSON.parse(readFileSync(p, "utf8"));
|
|
170
176
|
const t = timeOf(c?.at);
|
|
171
177
|
if (t === null || !Array.isArray(c.models)) return null;
|
|
178
|
+
const source = typeof c.source === "string" ? c.source : "";
|
|
179
|
+
const from = (modelSpec(manifest)?.catalogs || []).find((x) => x.source === source);
|
|
180
|
+
if (!from) return null;
|
|
172
181
|
// Cleaned again: the cache is a file anyone can edit.
|
|
173
182
|
const entries = c.models
|
|
174
183
|
.filter((x) => x && typeof x.id === "string" && clean(x.id))
|
|
@@ -180,9 +189,7 @@ function readCache(manifest, opts) {
|
|
|
180
189
|
hidden: Boolean(x.hidden),
|
|
181
190
|
...cachedLevels(x),
|
|
182
191
|
}));
|
|
183
|
-
|
|
184
|
-
const from = (modelSpec(manifest)?.catalogs || []).find((x) => x.source === source);
|
|
185
|
-
return { at: t, source, caption: from?.caption || source, entries };
|
|
192
|
+
return { at: t, source, caption: from.caption || source, entries };
|
|
186
193
|
} catch {
|
|
187
194
|
return null;
|
|
188
195
|
}
|
|
@@ -206,33 +213,11 @@ function readFileCatalog(manifest, spec, catalog, opts) {
|
|
|
206
213
|
}
|
|
207
214
|
}
|
|
208
215
|
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
// decision 2). Never any other host.
|
|
212
|
-
function urlOf(catalog, env) {
|
|
213
|
-
const base = catalog.base_env && env?.[catalog.base_env] ? String(env[catalog.base_env]).replace(/\/+$/, "") : null;
|
|
214
|
-
return base && catalog.path ? `${base}${catalog.path}` : catalog.url;
|
|
215
|
-
}
|
|
216
|
-
function hostOf(catalog, env) {
|
|
217
|
-
try {
|
|
218
|
-
return new URL(urlOf(catalog, env)).host;
|
|
219
|
-
} catch {
|
|
220
|
-
return null;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// The catalog a refresh asks: the first `command` or `http` one that can run
|
|
225
|
-
// here โ its key variable set, or its bin on PATH โ or null, with why not.
|
|
216
|
+
// The catalog a refresh asks: the first `command` one whose bin is on PATH,
|
|
217
|
+
// or null, with why not.
|
|
226
218
|
function liveCatalog(spec, env) {
|
|
227
219
|
let why = null;
|
|
228
220
|
for (const c of spec.catalogs || []) {
|
|
229
|
-
if (c.kind === "http") {
|
|
230
|
-
if (c.key_env && !(env && env[c.key_env])) {
|
|
231
|
-
why = why || `no ${c.key_env}`;
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
234
|
-
return { catalog: c, why: null, host: hostOf(c, env) };
|
|
235
|
-
}
|
|
236
221
|
if (c.kind === "command") {
|
|
237
222
|
const bin = Array.isArray(c.argv) ? c.argv[0] : null;
|
|
238
223
|
if (!bin || !whichOnPath(bin, env || {})) {
|
|
@@ -315,7 +300,6 @@ export function knownModels(manifest, { env = process.env, home = homedir(), now
|
|
|
315
300
|
source: live.catalog.source || "",
|
|
316
301
|
caption: live.catalog.caption || live.catalog.source || "",
|
|
317
302
|
kind: live.catalog.kind,
|
|
318
|
-
...(live.host ? { host: live.host } : {}),
|
|
319
303
|
}
|
|
320
304
|
: null,
|
|
321
305
|
why: live.why,
|
|
@@ -409,7 +393,10 @@ export function modelsProblem(manifest) {
|
|
|
409
393
|
if (!Array.isArray(spec.catalogs)) return "catalogs is not a list";
|
|
410
394
|
for (const c of spec.catalogs) {
|
|
411
395
|
const fields = fieldsOf(spec, c);
|
|
412
|
-
|
|
396
|
+
// The kind 0.30.6 read a key from the environment with: refused by name
|
|
397
|
+
// (the 0.30.7 story, PS-DIRECTORY).
|
|
398
|
+
if (c?.kind === "http") return "an http catalog is not read: a catalog is a file or a command";
|
|
399
|
+
if (!["file", "command"].includes(c?.kind)) return `a catalog's kind is ${c?.kind}`;
|
|
413
400
|
if (typeof c.source !== "string" || !c.source) return `a ${c.kind} catalog names no source`;
|
|
414
401
|
if (!fields.list || !fields.id) return `the ${c.source} catalog names no list or id field`;
|
|
415
402
|
if (typeof c.reason !== "string" || !c.reason) return `the ${c.source} catalog carries no reason`;
|
|
@@ -417,12 +404,6 @@ export function modelsProblem(manifest) {
|
|
|
417
404
|
return `the ${c.source} file catalog's path is not under the harness home`;
|
|
418
405
|
if (c.kind === "command" && (!Array.isArray(c.argv) || !c.argv.length || c.argv.some((a) => typeof a !== "string")))
|
|
419
406
|
return `the ${c.source} command catalog has no argv`;
|
|
420
|
-
if (c.kind === "http") {
|
|
421
|
-
if (!/^https:\/\//.test(String(c.url))) return `the ${c.source} http catalog's url is not https`;
|
|
422
|
-
if (!c.key_env || !c.key_header) return `the ${c.source} http catalog names no key variable and header`;
|
|
423
|
-
if (c.base_env && !String(c.path || "").startsWith("/"))
|
|
424
|
-
return `the ${c.source} http catalog's path is not absolute`;
|
|
425
|
-
}
|
|
426
407
|
}
|
|
427
408
|
return null;
|
|
428
409
|
}
|
|
@@ -461,72 +442,6 @@ export function effortProblem(manifest) {
|
|
|
461
442
|
return null;
|
|
462
443
|
}
|
|
463
444
|
|
|
464
|
-
// One bounded fetch, settled by a timer whatever the request does after: the
|
|
465
|
-
// pattern of convergence.mjs's request(). The key goes in its header and
|
|
466
|
-
// nowhere else; nothing of the request is kept.
|
|
467
|
-
function readHttp(catalog, { env, fetch, timers, timeoutMs, signal }) {
|
|
468
|
-
return new Promise((settle) => {
|
|
469
|
-
if (typeof fetch !== "function") return settle({ error: "no fetch in this Node" });
|
|
470
|
-
let done = false,
|
|
471
|
-
timer = null;
|
|
472
|
-
const ac = typeof AbortController === "function" ? new AbortController() : null;
|
|
473
|
-
const finish = (r) => {
|
|
474
|
-
if (done) return;
|
|
475
|
-
done = true;
|
|
476
|
-
if (timer !== null)
|
|
477
|
-
try {
|
|
478
|
-
timers.clearTimeout(timer);
|
|
479
|
-
} catch {}
|
|
480
|
-
settle(r);
|
|
481
|
-
};
|
|
482
|
-
timer = timers.setTimeout(() => {
|
|
483
|
-
timer = null;
|
|
484
|
-
try {
|
|
485
|
-
ac?.abort();
|
|
486
|
-
} catch {}
|
|
487
|
-
finish({ error: `no answer within ${timeoutMs / 1000} s` });
|
|
488
|
-
}, timeoutMs);
|
|
489
|
-
try {
|
|
490
|
-
timer?.unref?.();
|
|
491
|
-
} catch {}
|
|
492
|
-
signal?.addEventListener?.("abort", () => {
|
|
493
|
-
try {
|
|
494
|
-
ac?.abort();
|
|
495
|
-
} catch {}
|
|
496
|
-
finish({ error: "stopped" });
|
|
497
|
-
});
|
|
498
|
-
const url = urlOf(catalog, env);
|
|
499
|
-
const headers = { accept: "application/json", ...(catalog.headers || {}) };
|
|
500
|
-
if (catalog.key_env && catalog.key_header) headers[catalog.key_header] = String(env?.[catalog.key_env] || "");
|
|
501
|
-
Promise.resolve()
|
|
502
|
-
.then(() => fetch(url, { headers, ...(ac ? { signal: ac.signal } : {}) }))
|
|
503
|
-
.then(async (res) => {
|
|
504
|
-
if (!res || !res.ok) {
|
|
505
|
-
try {
|
|
506
|
-
Promise.resolve(res?.body?.cancel?.()).catch(() => {});
|
|
507
|
-
} catch {}
|
|
508
|
-
const status = res?.status ?? "error";
|
|
509
|
-
return finish({
|
|
510
|
-
error: status === 401 || status === 403 ? `the key was refused (HTTP ${status})` : `HTTP ${status}`,
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
const size = Number(res.headers?.get?.("content-length"));
|
|
514
|
-
if (Number.isFinite(size) && size > MAX_BYTES) {
|
|
515
|
-
try {
|
|
516
|
-
Promise.resolve(res?.body?.cancel?.()).catch(() => {});
|
|
517
|
-
} catch {}
|
|
518
|
-
return finish({ error: "the answer is too large" });
|
|
519
|
-
}
|
|
520
|
-
try {
|
|
521
|
-
finish({ body: await res.json() });
|
|
522
|
-
} catch {
|
|
523
|
-
finish({ error: "the answer is not JSON" });
|
|
524
|
-
}
|
|
525
|
-
})
|
|
526
|
-
.catch((e) => finish({ error: String(e?.cause?.code || e?.code || e?.message || "the request failed") }));
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
|
|
530
445
|
// One bounded run of a catalog command: its stdout as JSON. Asynchronous, so
|
|
531
446
|
// a menu keeps drawing while it runs; killed at the bound.
|
|
532
447
|
function readCommand(catalog, { env, spawn, timers, timeoutMs, signal }) {
|
|
@@ -604,8 +519,6 @@ export async function readLive(
|
|
|
604
519
|
env = process.env,
|
|
605
520
|
home = homedir(),
|
|
606
521
|
now = Date.now,
|
|
607
|
-
// eslint-disable-next-line n/no-unsupported-features/node-builtins -- the global fetch is experimental at Node 20 and stable from 21; without it the read answers "no fetch in this Node"
|
|
608
|
-
fetch = globalThis.fetch,
|
|
609
522
|
spawn = spawnChild,
|
|
610
523
|
timers = globalThis,
|
|
611
524
|
timeoutMs = LIVE_TIMEOUT_MS,
|
|
@@ -618,10 +531,7 @@ export async function readLive(
|
|
|
618
531
|
if (!catalog) return { error: why || "no catalog to ask" };
|
|
619
532
|
let r;
|
|
620
533
|
try {
|
|
621
|
-
r =
|
|
622
|
-
catalog.kind === "http"
|
|
623
|
-
? await readHttp(catalog, { env, fetch, timers, timeoutMs, signal })
|
|
624
|
-
: await readCommand(catalog, { env, spawn, timers, timeoutMs, signal });
|
|
534
|
+
r = await readCommand(catalog, { env, spawn, timers, timeoutMs, signal });
|
|
625
535
|
} catch (e) {
|
|
626
536
|
r = { error: String(e?.message || e) };
|
|
627
537
|
}
|
|
@@ -37,9 +37,9 @@ import { createInterface, emitKeypressEvents } from "node:readline";
|
|
|
37
37
|
|
|
38
38
|
// FORCE_COLOR decides when it is set, as Node's own tty reads it: "", "1",
|
|
39
39
|
// "2", "3" or "true" turn colour on even without a terminal, anything else
|
|
40
|
-
// ("0", "false") turns it off. Otherwise NO_COLOR
|
|
41
|
-
//
|
|
42
|
-
// that understands no escapes.
|
|
40
|
+
// ("0", "false") turns it off. Otherwise NO_COLOR turns colour off when set
|
|
41
|
+
// to anything non-empty, as the NO_COLOR convention has it, and TERM=dumb means
|
|
42
|
+
// a terminal that understands no escapes.
|
|
43
43
|
//
|
|
44
44
|
// Glyphs are a switch of their own (presentation spec contract 2): ASCII
|
|
45
45
|
// under TERM=dumb or PROJECTSTORE_ASCII set non-empty, Unicode otherwise, in
|
|
@@ -80,7 +80,7 @@ import {
|
|
|
80
80
|
const NUDGE_INTERVAL_MS = 10 * 60 * 1000;
|
|
81
81
|
|
|
82
82
|
// Per-session active pointer (ADR-006) โ denormalized titles/status captured
|
|
83
|
-
// after the write so the statusline renders with
|
|
83
|
+
// after the write so the statusline renders with no read of the vault โ plus the
|
|
84
84
|
// raw-edit nudge (PS-IMPROVE story-003): throttled, never blocking. Both in
|
|
85
85
|
// patchPointer below.
|
|
86
86
|
// The session-name offer (ADR: the settled-anchor offer). Returns a line or
|
|
@@ -298,6 +298,14 @@ export function unshippedTopLevel(entries, packlist) {
|
|
|
298
298
|
// The packlist is the second release-time invariant: what npm would actually
|
|
299
299
|
// ship. Stored as sorted paths only โ sizes churn on every content edit and
|
|
300
300
|
// would make the fixture unreviewable.
|
|
301
|
+
// The one package in `npm pack --json`'s answer: an array of one entry up to
|
|
302
|
+
// npm 11, an object keyed by the package's name from npm 12.
|
|
303
|
+
export function packEntry(answer) {
|
|
304
|
+
const [pkg] = Array.isArray(answer) ? answer : Object.values(answer ?? {});
|
|
305
|
+
if (!pkg || !Array.isArray(pkg.files)) throw new Error("no package entry with a file list");
|
|
306
|
+
return pkg;
|
|
307
|
+
}
|
|
308
|
+
|
|
301
309
|
export function currentPacklist(root = ROOT) {
|
|
302
310
|
const r = spawnSync("npm", ["pack", "--dry-run", "--json"], {
|
|
303
311
|
cwd: root,
|
|
@@ -306,7 +314,7 @@ export function currentPacklist(root = ROOT) {
|
|
|
306
314
|
});
|
|
307
315
|
if (r.status !== 0) return { error: `npm pack failed: ${r.stderr?.trim()}` };
|
|
308
316
|
try {
|
|
309
|
-
const
|
|
317
|
+
const pkg = packEntry(JSON.parse(r.stdout));
|
|
310
318
|
return { files: pkg.files.map((f) => f.path).sort() };
|
|
311
319
|
} catch (e) {
|
|
312
320
|
return { error: `npm pack output unparseable: ${e.message}` };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore-claude",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.8",
|
|
4
4
|
"description": "projectstore for Claude Code: the core pinned and bundled, the harness fixed. A release artifact; the way in is npx projectstore setup",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"projectstore",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"README.md"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"projectstore": "=0.30.
|
|
40
|
+
"projectstore": "=0.30.8"
|
|
41
41
|
},
|
|
42
42
|
"bundleDependencies": [
|
|
43
43
|
"projectstore"
|