projectstore-claude 0.30.7 β†’ 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.
@@ -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.7",
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.7",
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
- > Not agent memory, not a per-feature spec pipeline. Memory helps one agent recall; a feature spec plans one change. ProjectStore 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.
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
  [![release](https://img.shields.io/github/v/release/SmartAndPoint/ProjectStore?label=release)](https://github.com/SmartAndPoint/ProjectStore/releases) [![license](https://img.shields.io/github/license/SmartAndPoint/ProjectStore?label=license)](./LICENSE) [![Star on GitHub](https://img.shields.io/badge/%E2%AD%90-star_us-yellow?logo=github)](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 "$PWD"
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:
@@ -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/` while it draws; it starts no program and makes no request.
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.
@@ -37,8 +37,8 @@ You are running a peer review on a projectstore artifact.
37
37
 
38
38
  ## Project context
39
39
 
40
- <vault README excerpt>
41
- <folder README excerpt>
40
+ <the vault's README excerpt>
41
+ <the folder's README excerpt>
42
42
 
43
43
  ## Structural checklist
44
44
 
@@ -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 zero cross-session and zero vault reads** (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).
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
- model-facing channel: it accepts a top-level `systemMessage` and nothing else,
91
- so for its whole life the hook assembled a rich packet, printed a red *"Hook
92
- JSON output validation failed"*, and reached nobody. Worse, the invalid field
93
- sank the whole object, so the one line PreCompact *does* support never landed
94
- either.
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
- PreCompact [...pre-compact.mjs] completed successfully: {
102
- "systemMessage": "projectstore: compacting β€” vault myapp-vault,
103
- in flight: `epics/WEB-101/epic.md`,
104
- orientation and this session's recent files return
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": true,
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 (hooks/pre-compact.mjs; vault story: PreCompact survival packet targets a channel PreCompact does not have). Only top-level decision/reason and systemMessage exist."
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": {
@@ -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 e of entries) {
201
- let kind = e.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}/${e.name}`, { resolve: true })).kind } catch { kind = 'other' }
204
+ try { kind = (await $.fs.stat(`${dir}/${entry.name}`, { resolve: true })).kind } catch { kind = 'other' }
205
205
  }
206
- seen.push(`${dir}/${e.name}:${kind}:${e.mtimeMs}`)
207
- out.push({ ...e, kind })
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 next = { sig, counts: foldStages(statuses) as BandCounts, items }
244
- b.stories.set(epicDir, next)
245
- return next
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
- const name = heartbeatFile(sid)
317
- if (!name) return false
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}/${SESSIONS}/${name}`, JSON.stringify(body) + '\n')
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, next: BandView | null) {
345
- const key = JSON.stringify(next)
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, () => next)
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(next => (current() ? show($, b, next) : undefined))
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 channel: `systemMessage`, the line the user sees in their /compact
7
- // output. PreCompact has no `additionalContext` β€” the runtime rejects the whole
8
- // object when one is present, taking `systemMessage` down with it, so for its
9
- // entire history this hook assembled a packet, printed a red validation error,
10
- // and reached nobody. Agent-facing continuity after a compaction is
11
- // SessionStart's job, on `source: "compact"` (spec contract 19).
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(systemMessage) {
40
- process.stdout.write(JSON.stringify({ systemMessage }) + "\n", () => process.exit(0));
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.7",
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",
@@ -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 (semver.org's own grammar): a `dist-tags.latest` that is
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(
@@ -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
@@ -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 (https://no-color.org) turns
41
- // colour off when set to anything non-empty, and TERM=dumb means a terminal
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 zero vault reads β€” plus the
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore-claude",
3
- "version": "0.30.7",
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.7"
40
+ "projectstore": "=0.30.8"
41
41
  },
42
42
  "bundleDependencies": [
43
43
  "projectstore"