@mindrian_os/cli 2.0.0-beta.35 → 2.0.0-beta.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mos",
3
3
  "description": "MindrianOS -- Your AI innovation co-founder. Larry thinks with you through PWS methodology, builds your Data Room as you explore, and chains frameworks intelligently. Install and go.",
4
- "version": "2.0.0-beta.35",
4
+ "version": "2.0.0-beta.37",
5
5
  "author": {
6
6
  "name": "Jonathan Sagir",
7
7
  "url": "https://mindrian-os.com"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## [2.0.0-beta.37] - 2026-09-11
2
+
3
+ ### Fixed - hooks.json no longer prints an unknown-keys warning at every session start (quick task 260911-juq)
4
+
5
+ - Claude Code 2.1.268 printed, on every session start on every surface that loads the
6
+ plugin manifest: `hooks.json: unknown keys "_mcpFirst198Migrated",
7
+ "_firstInstallRouterOrdering" ignored`. Both keys were our own build metadata, never
8
+ hook configuration, so the loader was correct to ignore them and correct to complain.
9
+ They now live in `data/hooks-markers.json`, and `hooks/hooks.json`'s top level is
10
+ exactly one key, `hooks`. No matcher, command, or timeout inside the hooks block
11
+ changed; the block is byte-identical to the pre-fix file.
12
+ - New guard test `tests/test-quick-260911-juq-hooks-json-top-level.cjs`, registered as
13
+ a leg of `bash tests/run-all-198.sh`, keeps both halves of this fix honest going
14
+ forward: the top level cannot silently grow a stray key again, and the sidecar path
15
+ cannot silently go dead and make the D-06 adapter budget vacuous.
16
+
17
+ ### Added - a per-install opaque header lets Theo bucket without learning who you are (quick task 260911-iko)
18
+
19
+ - Every Brain call now carries `x-theo-install-id`: a 32-character lowercase hex value
20
+ minted once from `crypto.randomBytes(16)`, stored at `~/.mindrian/theo-install-id.json`
21
+ (mode 0600) beside the pre-warm marker. It rides both of `callTool`'s wire requests
22
+ (the session initialize and the tools/call), and is simply omitted, never an error,
23
+ when it cannot be minted or read. This lets Theo tell one install calling twice from
24
+ two installs calling once, with nothing else about the install attached to it.
25
+ - `node scripts/doctor.cjs --reset-install-id` rotates the id on demand, prints exactly
26
+ `install id rotated`, and never prints the value. The doctor's Brain-smoke layer 0
27
+ also grows an `install_id_present` boolean, reported the same way: presence only,
28
+ never the value.
29
+ - Canon Part 8 position, stated once and reproduced in full at
30
+ `docs/THEO-INSTALL-ID.md`: the id is an opaque CSPRNG handle, never derived from
31
+ identity, never logged, never printed in full.
32
+
1
33
  ## [2.0.0-beta.35] - 2026-09-11
2
34
 
3
35
  ### Added - doctor sees the Tier 0 shadow, Theo's node floor stops rubber-stamping (quick task 260911-axz)
@@ -2,7 +2,7 @@
2
2
  name: doctor
3
3
  description: "Diagnose and optionally repair MindrianOS install: install-cache drift, sentinel gaps, active-room guard, surface-verification, ROOM.md drift, UI compliance, statusline visibility, Brain smoke, and a paste-ready command-registration bug report for Anthropic"
4
4
  help_jtbd: "Diagnose and optionally repair an off-feeling install."
5
- argument-hint: "[--fix] [--all] [--cascade-rooms] [--graph-derive-health] [--heal-room] [--verify-surface] [--room-md] [--ui-compliance] [--statusline-visibility] [--card-fire-health] [--install-state] [--stale-first-touch] [--deprecated-usage] [--brain-smoke] [--eureka-smoke] [--drift] [--report-registration-bug] [--acceptance] [--pre-tag] [--pre-flight] [--dogfood-acceptance] [--claims] [--check-rs-engine] [--post-update] [--bind-check] [--simulate-write] [--scan-commands] [--scan-scripts] [--light-npx] [--dry-run] [--json]"
5
+ argument-hint: "[--fix] [--all] [--cascade-rooms] [--graph-derive-health] [--heal-room] [--verify-surface] [--room-md] [--ui-compliance] [--statusline-visibility] [--card-fire-health] [--install-state] [--stale-first-touch] [--deprecated-usage] [--brain-smoke] [--eureka-smoke] [--drift] [--report-registration-bug] [--reset-install-id] [--acceptance] [--pre-tag] [--pre-flight] [--dogfood-acceptance] [--claims] [--check-rs-engine] [--post-update] [--bind-check] [--simulate-write] [--scan-commands] [--scan-scripts] [--light-npx] [--dry-run] [--json]"
6
6
  body_shape: E (Action Report)
7
7
  hitl_shape: "F.0"
8
8
  hitl_why: "It surfaces one diagnosed repair for a single approve-or-defer decision."
@@ -125,6 +125,7 @@ These siblings own their own exit contracts and are not part of the class-flag d
125
125
  - `--dogfood-acceptance` -> the Canon Part 6 dog-fooding acceptance pass.
126
126
  - `--claims` -> the claims-verification leg.
127
127
  - `--check-rs-engine` -> the reason-select engine probe (NOT part of `--all`).
128
+ - `--reset-install-id` -> rotates the opaque per-install bucket key the plugin sends to Theo, prints `install id rotated`, never prints the value, and always exits 0. Full contract: `docs/THEO-INSTALL-ID.md`.
128
129
  - `--post-update` -> the post-`claude plugin update` confirmation pass.
129
130
  - `--bind-check <roomDir>` -> a lightweight LOCAL room-health check run at BIND-TIME (never per-turn, never a Brain call). NEVER-BLOCK: an unhealthy room degrades to an advisory and STILL exits 0.
130
131
  - `--light-npx` -> the lighter npx-roundtrip variant for `--acceptance`.
@@ -0,0 +1,25 @@
1
+ {
2
+ "_mcpFirst198Migrated": {
3
+ "_note": "Phase 198-08 (SPEC-5, D-05/D-06) migration markers, extended by Phase 198-09 (D-05 final wave). Each entry names a hook script that now dispatches to lib/mcp/adapter-client.cjs behind MINDRIAN_MCP_FIRST ('cli' or 'all') instead of running its business logic locally; flag OFF (unset/empty, the default) keeps the pre-migration legacy behavior byte-identical (SPEC-7). This is metadata for tests/test-198-adapter-budget.test.cjs's import-audit + line-count budget (D-06) to enumerate -- it is NOT consumed by Claude Code's hook loader, which only reads the 'hooks' key in hooks/hooks.json. Migration order is D-05: statusline + SessionStart FIRST (198-08); Stop-gate enforcement LAST (198-09, this entry), only after server-side gate dedup + relevance existed (lib/mcp/gate-dedup.cjs, Task 1 of 198-09). scripts/check-card-fire.cjs is NOT listed here -- it stays a separate Stop hook entry that no-ops under the flag (deferring to on-stop's daemon-backed decision), never itself migrated to a thin adapter shape.",
4
+ "surfaces": [
5
+ {
6
+ "script": "scripts/statusline-mos-dispatch",
7
+ "tool": "status_read",
8
+ "note": "Statusline segment (spend/cap from day one). Flag ON wakes the daemon and queries status_read; flag OFF execs the legacy scripts/statusline-mos exactly as shipped."
9
+ },
10
+ {
11
+ "script": "scripts/sessionstart-coordinator.cjs",
12
+ "tool": "room_state_bound,status_read",
13
+ "note": "SessionStart binding + status segments. Flag ON wakes the daemon and queries room_state_bound + status_read (runThinAdapter); flag OFF runs the full 11-contributor runAll() exactly as shipped."
14
+ },
15
+ {
16
+ "script": "scripts/on-stop",
17
+ "tool": "stop_gate_check",
18
+ "note": "Stop-gate enforcement + business close-out (D-05 final wave). Flag ON wakes the daemon and queries stop_gate_check (lib/mcp/tools/stop-gate.cjs -> lib/mcp/stop-gate-handler.cjs -- gate-dedup + relevance gate the fire decision, the shipped check-card-fire.cjs predicate is the final authority, the daemon owns STATE.md/memory-lifecycle/minto-debouncer/folder-memory close-out); flag OFF runs the full legacy body exactly as shipped."
19
+ }
20
+ ]
21
+ },
22
+ "_firstInstallRouterOrdering": {
23
+ "_note": "Phase 267.2-06 (HOOK-08). scripts/first-install-router.cjs is registered in the UserPromptSubmit chain immediately AFTER scripts/mva-detect.cjs and before scripts/brain-derivation-drain.cjs. This is metadata for a human reader (and any future drift test) -- it is NOT consumed by Claude Code's hook loader, which only reads the 'hooks' key in hooks/hooks.json. ORDERING IS LOAD BEARING: the router consumes the pending MVA state that mva-detect.cjs writes on the same turn, so running before it would read a stale or absent state. Do not reorder these two entries without re-checking this dependency."
24
+ }
25
+ }
package/hooks/hooks.json CHANGED
@@ -1,27 +1,4 @@
1
1
  {
2
- "_mcpFirst198Migrated": {
3
- "_note": "Phase 198-08 (SPEC-5, D-05/D-06) migration markers, extended by Phase 198-09 (D-05 final wave). Each entry names a hook script that now dispatches to lib/mcp/adapter-client.cjs behind MINDRIAN_MCP_FIRST ('cli' or 'all') instead of running its business logic locally; flag OFF (unset/empty, the default) keeps the pre-migration legacy behavior byte-identical (SPEC-7). This is metadata for tests/test-198-adapter-budget.test.cjs's import-audit + line-count budget (D-06) to enumerate -- it is NOT consumed by Claude Code's hook loader, which only reads the 'hooks' key below. Migration order is D-05: statusline + SessionStart FIRST (198-08); Stop-gate enforcement LAST (198-09, this entry), only after server-side gate dedup + relevance existed (lib/mcp/gate-dedup.cjs, Task 1 of 198-09). scripts/check-card-fire.cjs is NOT listed here -- it stays a separate Stop hook entry that no-ops under the flag (deferring to on-stop's daemon-backed decision), never itself migrated to a thin adapter shape.",
4
- "surfaces": [
5
- {
6
- "script": "scripts/statusline-mos-dispatch",
7
- "tool": "status_read",
8
- "note": "Statusline segment (spend/cap from day one). Flag ON wakes the daemon and queries status_read; flag OFF execs the legacy scripts/statusline-mos exactly as shipped."
9
- },
10
- {
11
- "script": "scripts/sessionstart-coordinator.cjs",
12
- "tool": "room_state_bound,status_read",
13
- "note": "SessionStart binding + status segments. Flag ON wakes the daemon and queries room_state_bound + status_read (runThinAdapter); flag OFF runs the full 11-contributor runAll() exactly as shipped."
14
- },
15
- {
16
- "script": "scripts/on-stop",
17
- "tool": "stop_gate_check",
18
- "note": "Stop-gate enforcement + business close-out (D-05 final wave). Flag ON wakes the daemon and queries stop_gate_check (lib/mcp/tools/stop-gate.cjs -> lib/mcp/stop-gate-handler.cjs -- gate-dedup + relevance gate the fire decision, the shipped check-card-fire.cjs predicate is the final authority, the daemon owns STATE.md/memory-lifecycle/minto-debouncer/folder-memory close-out); flag OFF runs the full legacy body exactly as shipped."
19
- }
20
- ]
21
- },
22
- "_firstInstallRouterOrdering": {
23
- "_note": "Phase 267.2-06 (HOOK-08). scripts/first-install-router.cjs is registered in the UserPromptSubmit chain immediately AFTER scripts/mva-detect.cjs and before scripts/brain-derivation-drain.cjs. This is metadata for a human reader (and any future drift test) -- it is NOT consumed by Claude Code's hook loader, which only reads the 'hooks' key below. ORDERING IS LOAD BEARING: the router consumes the pending MVA state that mva-detect.cjs writes on the same turn, so running before it would read a stale or absent state. Do not reorder these two entries without re-checking this dependency."
24
- },
25
2
  "hooks": {
26
3
  "SessionStart": [
27
4
  {
@@ -305,6 +305,34 @@ function isAvailable() {
305
305
  return !!getApiKey();
306
306
  }
307
307
 
308
+ // Quick 260911-iko (D-04): the opaque per-install header, spread into both
309
+ // of callTool's wire requests. A per-process memo is correct here (mirrors
310
+ // the getApiKey() memo idiom above) and must be commented as such: the id
311
+ // only changes on reinstall or on a `doctor --reset-install-id` run, and
312
+ // both of those happen in a DIFFERENT process, so re-reading it on every
313
+ // call would buy nothing. Wrapped in its own try/catch so a missing or
314
+ // broken install-id module can never take a Brain call down -- the header
315
+ // is a bucket key, never a requirement (D-04: omitted, never an error).
316
+ //
317
+ // This helper never touches the Part 8 egress belt (classify() at
318
+ // callTool's egress-guard call below): that belt classifies `args`, and
319
+ // headers are not payload. The install id never enters `args` and must
320
+ // never be added to them.
321
+ let _memoizedInstallIdHeaders = null;
322
+ function _installIdHeaders() {
323
+ if (_memoizedInstallIdHeaders) return _memoizedInstallIdHeaders;
324
+ try {
325
+ const installIdMod = require('./install-id.cjs');
326
+ const id = installIdMod.getInstallId();
327
+ _memoizedInstallIdHeaders = (typeof id === 'string')
328
+ ? { [installIdMod.installIdHeaderName]: id }
329
+ : {};
330
+ } catch (_e) {
331
+ _memoizedInstallIdHeaders = {};
332
+ }
333
+ return _memoizedInstallIdHeaders;
334
+ }
335
+
308
336
  // ---------------------------------------------------------------------------
309
337
  // Phase 250-04 (HONEST-03, SEED-011 Option A) -- per-install silent
310
338
  // registration. Design doc: docs/BRAIN-IDENTITY-DESIGN.md.
@@ -350,6 +378,12 @@ async function _tryAutoRegister() {
350
378
 
351
379
  try {
352
380
  const installId = crypto.randomUUID();
381
+ // Quick 260911-iko: the x-theo-install-id header does NOT ride here.
382
+ // D-04 names callTool's two header blocks only; registration is a
383
+ // different endpoint with its own contract, and it runs before there is
384
+ // an established Brain session to bucket. Adding it here would be a
385
+ // change to Theo's registration surface, which D-06 says must be
386
+ // announced to Theo first -- a deliberate exclusion, not a miss.
353
387
  const res = await fetch(`${BRAIN_URL}/register`, {
354
388
  method: 'POST',
355
389
  signal: AbortSignal.timeout(BRAIN_REQUEST_TIMEOUT_MS),
@@ -456,6 +490,7 @@ async function _ensureSession(apiKey) {
456
490
  'Content-Type': 'application/json',
457
491
  'Accept': 'application/json, text/event-stream',
458
492
  'Authorization': `Bearer ${apiKey}`,
493
+ ..._installIdHeaders(),
459
494
  },
460
495
  body: JSON.stringify({
461
496
  jsonrpc: '2.0',
@@ -675,6 +710,7 @@ async function callTool(toolName, args) {
675
710
  'Content-Type': 'application/json',
676
711
  'Accept': 'application/json, text/event-stream',
677
712
  'Authorization': `Bearer ${key}`,
713
+ ..._installIdHeaders(),
678
714
  },
679
715
  body: JSON.stringify({
680
716
  jsonrpc: '2.0',
@@ -202,6 +202,14 @@ async function _runLayer(_name, fn) {
202
202
  // `instanceUri`, `quarantineCode`, and `serverAgent` are never copied
203
203
  // (T-axz-02).
204
204
  //
205
+ // (d) install_id_present (Quick 260911-iko, T-iko-02): whether the opaque
206
+ // per-install id (lib/core/install-id.cjs) can be read from the state
207
+ // dir. This row carries the BOOLEAN ONLY and NEVER the id value -- it
208
+ // reads through peekInstallId, which never mints, so running doctor
209
+ // can never create the id it is reporting on. A missing id is
210
+ // information, never a failure: it never changes the verdict below,
211
+ // exactly as the origin and health halves already do not.
212
+ //
205
213
  // Verdict: ok is false if and only if at least one mindrian-brain shadow
206
214
  // entry was found. The origin and health halves are information, never the
207
215
  // verdict, exactly as L6 treats canon/override as information separate from
@@ -221,6 +229,19 @@ async function _layer0(opts) {
221
229
 
222
230
  const payload = { resolved_origin: resolved_origin, is_theo: is_theo, override: override, shadows: shadows };
223
231
 
232
+ // Quick 260911-iko (D-03, T-iko-02): install_id_present, a BOOLEAN ONLY,
233
+ // never the value. Uses peekInstallId, NOT getInstallId, so a diagnostic
234
+ // run never mints the thing it is reporting on. A missing install id is
235
+ // information, never a failure -- this never changes the verdict below,
236
+ // exactly as the origin and health halves already are.
237
+ try {
238
+ const installIdFn = opts.mockInstallId
239
+ || (() => require('../install-id.cjs').peekInstallId());
240
+ payload.install_id_present = typeof installIdFn() === 'string';
241
+ } catch (_e) {
242
+ payload.install_id_present = false;
243
+ }
244
+
224
245
  const theoHealthFn = opts.mockTheoHealth
225
246
  || (async () => require('../brain-client.cjs').callTool('theo_health', {}));
226
247
  try {
@@ -533,6 +554,7 @@ async function _layer6(opts) {
533
554
  * mockQuery?: function,
534
555
  * mockScopedServers?: function,
535
556
  * mockTheoHealth?: function,
557
+ * mockInstallId?: function,
536
558
  * claudeConfigPath?: string,
537
559
  * projectDir?: string,
538
560
  * }} [opts]
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * Copyright (c) 2026 Mindrian. BSL 1.1.
6
+ *
7
+ * Quick 260911-iko (D-01 through D-08) -- the opaque per-install id Theo
8
+ * uses to bucket "one install calling twice" apart from "two installs
9
+ * calling once", without ever learning who the install belongs to.
10
+ *
11
+ * WHAT IT IS FOR: Theo needs a bucket key. Today it has none, and every
12
+ * substitute it could reach for instead (a key, a hostname, an account id)
13
+ * would be user data crossing the Brain boundary, which Canon Part 8
14
+ * forbids. A 128-bit coin flip minted locally and sent as a header is the
15
+ * one answer that gives Theo the bucketing and gives the user nothing to
16
+ * leak.
17
+ *
18
+ * WHY A CSPRNG VALUE IS A GENERIC HANDLE, NOT USER DATA (D-07): the value
19
+ * is `crypto.randomBytes(16)`, 16 bytes straight out of the platform CSPRNG
20
+ * with NO INPUT at all. There is no function from the user, the machine,
21
+ * the account, the room, the path, the hostname, or the Brain key to this
22
+ * value, so there is nothing to invert. A hash of an identifier would still
23
+ * BE that identifier wearing a hat: the same user on two installs would
24
+ * hash to the same bucket, and anyone holding the identifier could confirm
25
+ * a match. A random 128-bit value cannot do either. It carries exactly one
26
+ * bit of meaning: "the caller that sent this header before is the caller
27
+ * sending it now."
28
+ *
29
+ * THE EXPLICIT FORBIDDEN LIST -- this module must NEVER derive the id from:
30
+ * - os.hostname()
31
+ * - os.userInfo() / os.userInfo().username
32
+ * - process.env.USER / process.env.USERNAME / process.env.LOGNAME
33
+ * - process.cwd()
34
+ * - a home directory path
35
+ * - a MAC address or machine id
36
+ * - an account id or room name
37
+ * - the Brain key (process.env.MINDRIAN_BRAIN_KEY, resolve-brain-key.cjs)
38
+ * - a hash of any of the above (crypto's createHash)
39
+ * tests/test-339-install-id-header.cjs arm 9 scans THIS FILE with comments
40
+ * stripped and fails the suite if any of those tokens appears outside this
41
+ * prose -- so this comment is safe and LOAD-BEARING, and must stay.
42
+ *
43
+ * NEVER LOGGED, NEVER PRINTED IN FULL (D-03): doctor reports presence only,
44
+ * and on rotation the word "rotated" -- never the value. The id itself is
45
+ * the user's own to read from their own file.
46
+ *
47
+ * Posture copied deliberately from lib/core/brain-prewarm.cjs: this module
48
+ * NEVER throws to its caller and NEVER writes to stdout, because it can run
49
+ * inside an MCP stdio process where a stray stdout byte corrupts the
50
+ * JSON-RPC transport. A single stderr line, guarded by MINDRIAN_DEBUG, is
51
+ * permitted, and it prints the FILE PATH or an error message only, never
52
+ * the id value.
53
+ *
54
+ * CJS only, no new dependencies: require only fs, path, os, crypto.
55
+ * No em-dashes (hyphens only).
56
+ */
57
+
58
+ const fs = require('fs');
59
+ const path = require('path');
60
+ const os = require('os');
61
+ const crypto = require('crypto');
62
+
63
+ const INSTALL_ID_HEADER_NAME = 'x-theo-install-id';
64
+ const ID_SHAPE_RE = /^[a-f0-9]{32}$/;
65
+ const FILE_NAME = 'theo-install-id.json';
66
+
67
+ const debugLog = (msg) => {
68
+ if (!process.env.MINDRIAN_DEBUG) return;
69
+ try {
70
+ process.stderr.write('[install-id] ' + msg + '\n');
71
+ } catch (_e) {
72
+ // swallow -- this function must never throw
73
+ }
74
+ };
75
+
76
+ /**
77
+ * installIdPath(homeDir) -- the on-disk location of the id file.
78
+ *
79
+ * Deliberately duplicates the SAME resolution expression as
80
+ * lib/core/brain-prewarm.cjs::markerPath rather than extracting a shared
81
+ * helper -- two call sites is below the threshold where coupling two
82
+ * never-throws modules beats a duplicated two-line expression. Safety net:
83
+ * tests/test-339-install-id-header.cjs arm 10 asserts both modules resolve
84
+ * to the same directory (the drift guard). Extraction trigger: a THIRD
85
+ * call site.
86
+ *
87
+ * @param {string} [homeDir] defaults to MINDRIAN_HOME or ~/.mindrian
88
+ * @returns {string}
89
+ */
90
+ function installIdPath(homeDir) {
91
+ const home = homeDir || process.env.MINDRIAN_HOME || path.join(os.homedir(), '.mindrian');
92
+ return path.join(home, FILE_NAME);
93
+ }
94
+
95
+ /**
96
+ * Read the id file and return the id string, or null on any failure, any
97
+ * shape mismatch, or a fresh homeDir with no file yet. Never mints, never
98
+ * writes, never throws.
99
+ * @param {string} filePath
100
+ * @returns {string|null}
101
+ */
102
+ function _readValidId(filePath) {
103
+ try {
104
+ const raw = fs.readFileSync(filePath, 'utf8');
105
+ const parsed = JSON.parse(raw);
106
+ if (parsed && typeof parsed.id === 'string' && ID_SHAPE_RE.test(parsed.id)) {
107
+ return parsed.id;
108
+ }
109
+ return null;
110
+ } catch (_e) {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * peekInstallId({ homeDir }) -- read-only. Returns the id when the file
117
+ * holds a valid one, otherwise null. NEVER mints, NEVER writes, NEVER
118
+ * throws. This exists so the doctor can report presence without a
119
+ * diagnostic run silently creating the thing it is diagnosing.
120
+ * @param {{homeDir?: string}} [opts]
121
+ * @returns {string|null}
122
+ */
123
+ function peekInstallId(opts) {
124
+ const o = opts || {};
125
+ try {
126
+ return _readValidId(installIdPath(o.homeDir));
127
+ } catch (_e) {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Atomically write { id, minted_at } to installIdPath(homeDir), mode 0600.
134
+ * Every fs operation is wrapped; any failure returns false and leaves no
135
+ * temp file behind.
136
+ * @param {string} homeDir
137
+ * @param {string} id
138
+ * @returns {boolean}
139
+ */
140
+ function _atomicWrite(homeDir, id) {
141
+ const finalPath = installIdPath(homeDir);
142
+ const dir = path.dirname(finalPath);
143
+ const tmpPath = path.join(
144
+ dir,
145
+ FILE_NAME + '.tmp-' + process.pid + '-' + crypto.randomBytes(3).toString('hex')
146
+ );
147
+ const body = { id: id, minted_at: new Date().toISOString() };
148
+ const json = JSON.stringify(body);
149
+ try {
150
+ fs.mkdirSync(dir, { recursive: true });
151
+ fs.writeFileSync(tmpPath, json, { encoding: 'utf8', mode: 0o600 });
152
+ fs.renameSync(tmpPath, finalPath);
153
+ if (process.platform !== 'win32') {
154
+ try {
155
+ fs.chmodSync(finalPath, 0o600);
156
+ } catch (_e) {
157
+ // belt only -- the write-time mode already applied it.
158
+ }
159
+ }
160
+ debugLog('id written: ' + finalPath);
161
+ return true;
162
+ } catch (e) {
163
+ debugLog('id write failed: ' + (e && e.message ? e.message : String(e)));
164
+ try {
165
+ fs.unlinkSync(tmpPath);
166
+ } catch (_e) {
167
+ // no temp file to clean up, or already gone.
168
+ }
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * getInstallId({ homeDir }) -- mint-once. peekInstallId first; on a hit
175
+ * return it. On a miss, mint a fresh 32-hex id from crypto.randomBytes(16),
176
+ * write it atomically, then RE-CHECK for a concurrent winner: two processes
177
+ * making their first Brain call at the same instant must converge on ONE
178
+ * bucket, not split it. Every failure returns null rather than throwing.
179
+ *
180
+ * Returning null rather than an unpersisted in-memory id on a write
181
+ * failure is deliberate: a volatile id would send a different value on
182
+ * every process and quietly break the one-install-one-id property the
183
+ * whole header exists to provide.
184
+ *
185
+ * @param {{homeDir?: string}} [opts]
186
+ * @returns {string|null}
187
+ */
188
+ function getInstallId(opts) {
189
+ const o = opts || {};
190
+ const existing = peekInstallId(o);
191
+ if (existing) return existing;
192
+
193
+ const minted = crypto.randomBytes(16).toString('hex');
194
+ const wrote = _atomicWrite(o.homeDir, minted);
195
+ if (!wrote) {
196
+ // Concurrent-winner re-check even on our own write failure: another
197
+ // process may have won the race while we were failing.
198
+ return peekInstallId(o);
199
+ }
200
+
201
+ // Concurrent-winner re-check: if another process's mint landed between
202
+ // our write and this read, defer to it so both processes converge on one
203
+ // bucket rather than splitting into two.
204
+ const afterWrite = peekInstallId(o);
205
+ if (afterWrite && afterWrite !== minted) {
206
+ return afterWrite;
207
+ }
208
+ return afterWrite || minted;
209
+ }
210
+
211
+ /**
212
+ * resetInstallId({ homeDir }) -- mints unconditionally and REPLACES,
213
+ * skipping the concurrent-winner re-check (rotation must win over an
214
+ * existing file by definition). Same atomic write, same mode, same
215
+ * never-throws contract.
216
+ * @param {{homeDir?: string}} [opts]
217
+ * @returns {string|null}
218
+ */
219
+ function resetInstallId(opts) {
220
+ const o = opts || {};
221
+ const minted = crypto.randomBytes(16).toString('hex');
222
+ const wrote = _atomicWrite(o.homeDir, minted);
223
+ return wrote ? minted : null;
224
+ }
225
+
226
+ module.exports = {
227
+ installIdHeaderName: INSTALL_ID_HEADER_NAME,
228
+ installIdPath: installIdPath,
229
+ peekInstallId: peekInstallId,
230
+ getInstallId: getInstallId,
231
+ resetInstallId: resetInstallId,
232
+ };
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
2
  // Phase 198-08 (SPEC-5, D-06) -- hook-adapter-audit: the measured
3
3
  // "adapter-only" budget for MIGRATED hook scripts (enumerated from
4
- // hooks/hooks.json's _mcpFirst198Migrated marker, Task 1). Two checks:
4
+ // data/hooks-markers.json's _mcpFirst198Migrated marker, moved there by
5
+ // quick task 260911-juq because the Claude Code hook loader warns on
6
+ // unknown top-level keys in hooks/hooks.json). Two checks:
5
7
  // 1. IMPORT AUDIT -- a migrated hook script's own require() calls (after
6
8
  // comment-stripping, so a header/doc comment naming lib/core does not
7
9
  // self-invalidate the gate) never reach into lib/core, lib/workflow, or
@@ -14,10 +16,10 @@
14
16
  // cannot silently re-fatten a thin adapter back into a business-logic
15
17
  // script without this test failing.
16
18
  //
17
- // Scope: ONLY the surfaces hooks.json's own migration marker names (D-05:
18
- // statusline + SessionStart THIS plan; Stop-gate scripts are Plan 09's
19
- // concern and are never enumerated here -- enumeration comes from the
20
- // marker, not a hand-maintained list, so Plan 09 adding its own marker
19
+ // Scope: ONLY the surfaces data/hooks-markers.json's own migration marker
20
+ // names (D-05: statusline + SessionStart THIS plan; Stop-gate scripts are
21
+ // Plan 09's concern and are never enumerated here -- enumeration comes from
22
+ // the marker, not a hand-maintained list, so Plan 09 adding its own marker
21
23
  // entries later does not require touching this file).
22
24
  //
23
25
  // No em-dashes. CJS only.
@@ -26,7 +28,7 @@ const fs = require('node:fs');
26
28
  const path = require('node:path');
27
29
 
28
30
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
29
- const HOOKS_JSON_PATH = path.join(REPO_ROOT, 'hooks', 'hooks.json');
31
+ const HOOKS_MARKERS_PATH = path.join(REPO_ROOT, 'data', 'hooks-markers.json');
30
32
 
31
33
  // Measured baseline + a small margin (D-06: "set the budget from the thin
32
34
  // post-migration line counts with a small margin; record the exact numbers
@@ -68,15 +70,17 @@ const LINE_BUDGETS = {
68
70
  const FORBIDDEN_IMPORT_PATTERN = /require\(.*lib\/core|require\(.*lib\/workflow|require\(.*lib\/memory/;
69
71
 
70
72
  /**
71
- * migratedSurfaces() -- read hooks.json's _mcpFirst198Migrated.surfaces
72
- * marker (Task 1) and return the list of migrated script paths (repo-root
73
+ * migratedSurfaces() -- read data/hooks-markers.json's
74
+ * _mcpFirst198Migrated.surfaces marker (Task 1; moved out of hooks/hooks.json
75
+ * by quick task 260911-juq so the hook loader stops warning on unknown
76
+ * top-level keys) and return the list of migrated script paths (repo-root
73
77
  * relative). Never throws; a missing/malformed marker returns [].
74
78
  *
75
79
  * @returns {string[]}
76
80
  */
77
81
  function migratedSurfaces() {
78
82
  try {
79
- const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf8');
83
+ const raw = fs.readFileSync(HOOKS_MARKERS_PATH, 'utf8');
80
84
  const parsed = JSON.parse(raw);
81
85
  const marker = parsed && parsed._mcpFirst198Migrated;
82
86
  const surfaces = marker && Array.isArray(marker.surfaces) ? marker.surfaces : [];
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@mindrian_os/cli",
3
- "version": "2.0.0-beta.35",
3
+ "version": "2.0.0-beta.37",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@mindrian_os/cli",
9
- "version": "2.0.0-beta.35",
9
+ "version": "2.0.0-beta.37",
10
10
  "license": "BSL-1.1",
11
11
  "dependencies": {
12
12
  "@ig3/markdown-it-wikilinks": "^1.0.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindrian_os/cli",
3
- "version": "2.0.0-beta.35",
3
+ "version": "2.0.0-beta.37",
4
4
  "description": "Install MindrianOS into Claude Code with one command -- `npx @mindrian_os/cli`. Ships the MindrianOS plugin (Larry + PWS methodology + Data Room) plus a setup/diagnostics CLI (install/doctor/update).",
5
5
  "scripts": {
6
6
  "mcp": "node bin/mindrian-mcp-server.cjs",
@@ -255,6 +255,12 @@ function parseArgs(argv) {
255
255
  // reads through the read-only navigation door + a LOCAL queue write; zero
256
256
  // network surface (Canon Part 8).
257
257
  graphDeriveHealth: false,
258
+ // Quick 260911-iko (D-05): --reset-install-id is a SIBLING flag, like
259
+ // --report-registration-bug, NOT a class flag: it is deliberately NOT
260
+ // added to the --all activation block below, and it carries its own
261
+ // always-exit-0 contract (rotation either succeeds or the state dir is
262
+ // not writable; neither case is a doctor-drift finding).
263
+ resetInstallId: false,
258
264
  };
259
265
  for (const arg of argv) {
260
266
  if (arg === '--fix') flags.fix = true;
@@ -286,6 +292,10 @@ function parseArgs(argv) {
286
292
  // the --all activation block below (same exclusion rationale as
287
293
  // --check-rs-engine: --all is class A-M drift detection, not escalation).
288
294
  else if (arg === '--report-registration-bug') flags.reportRegistrationBug = true;
295
+ // Quick 260911-iko (D-05): --reset-install-id rotates the opaque
296
+ // per-install bucket key. SIBLING flag, own exit-0 contract, see the
297
+ // parseArgs default comment above.
298
+ else if (arg === '--reset-install-id') flags.resetInstallId = true;
289
299
  else if (arg === '--post-update') flags.postUpdate = true;
290
300
  else if (arg === '--dogfood-acceptance') flags.dogfoodAcceptance = true;
291
301
  else if (arg === '--claims') flags.claims = true;
@@ -461,6 +471,11 @@ Environment readiness probes (Phase 127.2 Plan 03 -- separate from class flags):
461
471
  host-side core bug. stdout only; --json for the machine shape.
462
472
  Exit 0 whenever the report assembles (even offline); non-zero
463
473
  only if the assembler itself throws. NOT in --all.
474
+ --reset-install-id (Quick task 260911-iko) rotates the opaque per-install bucket
475
+ key the plugin sends to Theo as the x-theo-install-id header.
476
+ Mints a fresh id, replaces the state-dir file, prints exactly
477
+ "install id rotated", never prints the value, and always exits
478
+ 0 (even when the state dir is not writable). NOT in --all.
464
479
  --post-update (Phase 127.2 Plan 04 Instance #7) atomically activate
465
480
  freshly-landed cache-staging bytes via scripts/post-update-
466
481
  activation.cjs (delegates to --fix pipeline + writes the
@@ -3143,6 +3158,26 @@ function main() {
3143
3158
  return;
3144
3159
  }
3145
3160
 
3161
+ // Quick 260911-iko (D-05): --reset-install-id dispatch. Dispatched BEFORE
3162
+ // the class-flag block and BEFORE --acceptance, same position/shape as
3163
+ // --bind-check above: own dispatch, own always-exit-0 contract. Never
3164
+ // prints the id value, not even on the failure path (D-03).
3165
+ if (flags.resetInstallId) {
3166
+ let rotated = null;
3167
+ try {
3168
+ rotated = require(path.join(__dirname, '..', 'lib', 'core', 'install-id.cjs')).resetInstallId();
3169
+ } catch (_e) {
3170
+ rotated = null;
3171
+ }
3172
+ if (typeof rotated === 'string') {
3173
+ console.log('install id rotated');
3174
+ } else {
3175
+ console.log('install id rotation failed (state dir not writable)');
3176
+ }
3177
+ process.exit(0);
3178
+ return;
3179
+ }
3180
+
3146
3181
  // Phase 123 Plan-04: release-gate runner. --acceptance has its own exit-
3147
3182
  // code contract (0 = all points passed; 1 = any point failed); HARD ABORT
3148
3183
  // -- no --allow override (per CONTEXT D-16: release infra is the one gate
@@ -3,7 +3,7 @@ name: doctor
3
3
  description: "Diagnose and optionally repair MindrianOS install: install-cache drift, sentinel gaps, active-room guard, surface-verification, ROOM.md drift, UI compliance, statusline visibility, Brain smoke, and a paste-ready command-registration bug report for Anthropic"
4
4
  license: BSL-1.1. See LICENSE for complete terms (Business Source License 1.1, Change Date 2030-04-16 to Apache License 2.0).
5
5
  help_jtbd: "Diagnose and optionally repair an off-feeling install."
6
- argument-hint: "[--fix] [--all] [--cascade-rooms] [--graph-derive-health] [--heal-room] [--verify-surface] [--room-md] [--ui-compliance] [--statusline-visibility] [--card-fire-health] [--install-state] [--stale-first-touch] [--deprecated-usage] [--brain-smoke] [--eureka-smoke] [--drift] [--report-registration-bug] [--acceptance] [--pre-tag] [--pre-flight] [--dogfood-acceptance] [--claims] [--check-rs-engine] [--post-update] [--bind-check] [--simulate-write] [--scan-commands] [--scan-scripts] [--light-npx] [--dry-run] [--json]"
6
+ argument-hint: "[--fix] [--all] [--cascade-rooms] [--graph-derive-health] [--heal-room] [--verify-surface] [--room-md] [--ui-compliance] [--statusline-visibility] [--card-fire-health] [--install-state] [--stale-first-touch] [--deprecated-usage] [--brain-smoke] [--eureka-smoke] [--drift] [--report-registration-bug] [--reset-install-id] [--acceptance] [--pre-tag] [--pre-flight] [--dogfood-acceptance] [--claims] [--check-rs-engine] [--post-update] [--bind-check] [--simulate-write] [--scan-commands] [--scan-scripts] [--light-npx] [--dry-run] [--json]"
7
7
  body_shape: E (Action Report)
8
8
  hitl_shape: "F.0"
9
9
  hitl_why: "It surfaces one diagnosed repair for a single approve-or-defer decision."
@@ -123,6 +123,7 @@ These siblings own their own exit contracts and are not part of the class-flag d
123
123
  - `--dogfood-acceptance` -> the Canon Part 6 dog-fooding acceptance pass.
124
124
  - `--claims` -> the claims-verification leg.
125
125
  - `--check-rs-engine` -> the reason-select engine probe (NOT part of `--all`).
126
+ - `--reset-install-id` -> rotates the opaque per-install bucket key the plugin sends to Theo, prints `install id rotated`, never prints the value, and always exits 0. Full contract: `docs/THEO-INSTALL-ID.md`.
126
127
  - `--post-update` -> the post-`claude plugin update` confirmation pass.
127
128
  - `--bind-check <roomDir>` -> a lightweight LOCAL room-health check run at BIND-TIME (never per-turn, never a Brain call). NEVER-BLOCK: an unhealthy room degrades to an advisory and STILL exits 0.
128
129
  - `--light-npx` -> the lighter npx-roundtrip variant for `--acceptance`.