@noir-ai/cli 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import {
2
2
  DEFAULT_UPDATE_CONFIG,
3
3
  buildUpdateNotice,
4
4
  runAsyncUpdateCheck
5
- } from "./chunk-TPNJEN4B.js";
5
+ } from "./chunk-CQU2INJH.js";
6
6
  import {
7
7
  EXIT,
8
8
  NoirCliError,
@@ -10,6 +10,7 @@ import {
10
10
  badge,
11
11
  c,
12
12
  definitionList,
13
+ displayWidth,
13
14
  error,
14
15
  fail,
15
16
  failAndExit,
@@ -23,31 +24,33 @@ import {
23
24
  spinner,
24
25
  success,
25
26
  table,
27
+ terminalWidth,
26
28
  tip,
29
+ truncateToWidth,
27
30
  useColor,
28
31
  warn
29
- } from "./chunk-KETMM3BA.js";
32
+ } from "./chunk-KVJIPKVE.js";
30
33
 
31
34
  // src/commands/status.ts
32
- import { loadProjectInfo as loadProjectInfo14, NOIR_VERSION as NOIR_VERSION5 } from "@noir-ai/core";
35
+ import { loadProjectInfo as loadProjectInfo15, NOIR_VERSION as NOIR_VERSION5 } from "@noir-ai/core";
33
36
 
34
37
  // src/daemon-client.ts
35
- import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
38
+ import { Client, StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from "@modelcontextprotocol/client";
36
39
  import { ContextEngine, createEmbedFn, resolveEmbedderConfig } from "@noir-ai/context";
37
40
  import {
38
- loadProjectInfo as loadProjectInfo13,
41
+ loadProjectInfo as loadProjectInfo14,
39
42
  NOIR_VERSION as NOIR_VERSION4,
40
43
  readWorkspaceMarker as readWorkspaceMarker2,
41
44
  workspaceStoreDbPath
42
45
  } from "@noir-ai/core";
43
46
  import {
44
47
  ensureDaemonRunning as ensureDaemonRunning3,
45
- pidAlive as pidAlive4,
46
- readDaemonToken as readDaemonToken2,
48
+ pidAlive as pidAlive5,
49
+ readDaemonToken as readDaemonToken3,
47
50
  readProjectDaemonRecord as readProjectDaemonRecord3,
48
- readWorkspaceDaemonRecord as readWorkspaceDaemonRecord2,
51
+ readWorkspaceDaemonRecord as readWorkspaceDaemonRecord3,
49
52
  resolveGateConfig,
50
- tokenPath
53
+ tokenPath as tokenPath2
51
54
  } from "@noir-ai/daemon";
52
55
  import { createMemoryEngine } from "@noir-ai/memory";
53
56
  import { openStore } from "@noir-ai/store";
@@ -226,12 +229,12 @@ function renderStatus(data, opts) {
226
229
  }
227
230
 
228
231
  // src/commands/daemon.ts
229
- import { loadProjectInfo as loadProjectInfo2 } from "@noir-ai/core";
232
+ import { loadProjectInfo as loadProjectInfo3 } from "@noir-ai/core";
230
233
  import {
231
234
  clearProjectDaemonRecord,
232
235
  ensureDaemonRunning,
233
- pidAlive as pidAlive2,
234
- readDaemonToken,
236
+ pidAlive as pidAlive3,
237
+ readDaemonToken as readDaemonToken2,
235
238
  readProjectDaemonRecord,
236
239
  spawnDetachedDaemon
237
240
  } from "@noir-ai/daemon";
@@ -243,7 +246,7 @@ import {
243
246
  ensureWorkspaceRegistry,
244
247
  isValidWorkspaceName,
245
248
  isWorkspaceMember,
246
- loadProjectInfo,
249
+ loadProjectInfo as loadProjectInfo2,
247
250
  readWorkspaceMarker,
248
251
  readWorkspaceRegistry,
249
252
  removeWorkspaceMember,
@@ -254,19 +257,228 @@ import {
254
257
  import {
255
258
  clearWorkspaceDaemonRecord,
256
259
  ensureWorkspaceDaemonRunning,
257
- pidAlive,
258
- readWorkspaceDaemonRecord,
260
+ pidAlive as pidAlive2,
261
+ readWorkspaceDaemonRecord as readWorkspaceDaemonRecord2,
259
262
  spawnDetachedWorkspaceDaemon
260
263
  } from "@noir-ai/daemon";
261
264
 
265
+ // src/workspace-bridge.ts
266
+ import {
267
+ ReadBuffer,
268
+ StreamableHTTPClientTransport,
269
+ serializeMessage
270
+ } from "@modelcontextprotocol/client";
271
+ import { loadProjectInfo } from "@noir-ai/core";
272
+ import { pidAlive, readDaemonToken, readWorkspaceDaemonRecord, tokenPath } from "@noir-ai/daemon";
273
+ var HEALTH_PROBE_TIMEOUT_MS = 1500;
274
+ async function resolveWorkspaceDaemon(name, root) {
275
+ const record = readWorkspaceDaemonRecord(name);
276
+ if (record === null) {
277
+ return { error: `no daemon recorded for workspace ${name}` };
278
+ }
279
+ const health = await verifyWorkspaceDaemon(record, name);
280
+ if (health.kind === "foreign") {
281
+ return {
282
+ error: `workspace ${name} is recorded on port ${record.port}, but the daemon answering there serves workspace ${health.workspace}`
283
+ };
284
+ }
285
+ if (health.kind === "silent") {
286
+ return { error: `record exists but the daemon is not answering (pid ${record.pid})` };
287
+ }
288
+ const token = readDaemonToken(name);
289
+ if (token === null) {
290
+ return {
291
+ error: `workspace ${name} daemon is answering but its token is unreadable at ${tokenPath(name)}`
292
+ };
293
+ }
294
+ let callerProjectId;
295
+ try {
296
+ callerProjectId = loadProjectInfo(root).id;
297
+ } catch {
298
+ return {
299
+ error: `no project identity in ${root} \u2014 run \`noir init\` before serving a workspace from here`
300
+ };
301
+ }
302
+ return { url: `http://127.0.0.1:${record.port}/mcp?p=${callerProjectId}`, token };
303
+ }
304
+ async function verifyWorkspaceDaemon(record, name) {
305
+ if (!pidAlive(record.pid)) return { kind: "silent" };
306
+ return probeHealth(record.port, name, record.pid);
307
+ }
308
+ async function probeHealth(port, name, pid) {
309
+ try {
310
+ const response = await fetch(`http://127.0.0.1:${port}/health`, {
311
+ signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS)
312
+ });
313
+ if (!response.ok) return { kind: "silent" };
314
+ const body = await response.json().catch(() => null);
315
+ if (body === null) return { kind: "silent" };
316
+ if (typeof body.workspace === "string" && body.workspace !== name) {
317
+ return { kind: "foreign", workspace: body.workspace };
318
+ }
319
+ if (body.ok !== true || body.pid !== pid) return { kind: "silent" };
320
+ return { kind: "healthy" };
321
+ } catch {
322
+ return { kind: "silent" };
323
+ }
324
+ }
325
+ async function bridgeStdioToWorkspace(name, root) {
326
+ const resolved = await resolveWorkspaceDaemon(name, root);
327
+ if ("error" in resolved) {
328
+ fail(EXIT.DAEMON_DOWN, resolved.error);
329
+ }
330
+ const host = new StdioBridgeTransport(process.stdin, process.stdout);
331
+ const daemon = new StreamableHTTPClientTransport(new URL(resolved.url), {
332
+ requestInit: { headers: { Authorization: `Bearer ${resolved.token}` } }
333
+ });
334
+ try {
335
+ await relay(host, daemon);
336
+ } finally {
337
+ await daemon.close().catch(() => {
338
+ });
339
+ await host.close().catch(() => {
340
+ });
341
+ }
342
+ }
343
+ async function relay(host, daemon) {
344
+ await new Promise((resolve2) => {
345
+ let settled = false;
346
+ let closing = false;
347
+ const settle = () => {
348
+ if (settled) return;
349
+ settled = true;
350
+ closing = true;
351
+ resolve2();
352
+ };
353
+ const closeDaemon = () => {
354
+ closing = true;
355
+ void daemon.close().catch(() => {
356
+ });
357
+ };
358
+ host.onmessage = (message) => {
359
+ void daemon.send(message).catch(closeDaemon);
360
+ };
361
+ host.onclose = () => {
362
+ closeDaemon();
363
+ settle();
364
+ };
365
+ host.onerror = () => {
366
+ closeDaemon();
367
+ settle();
368
+ };
369
+ daemon.onmessage = (message) => {
370
+ void host.send(message).catch(() => {
371
+ });
372
+ };
373
+ daemon.onclose = () => {
374
+ void host.close().catch(() => {
375
+ });
376
+ settle();
377
+ };
378
+ daemon.onerror = (error2) => {
379
+ if (closing) return;
380
+ process.stderr.write(
381
+ `noir: workspace bridge: ${error2 instanceof Error ? error2.message : String(error2)}
382
+ `
383
+ );
384
+ };
385
+ const failStart = (error2) => {
386
+ process.stderr.write(
387
+ `noir: workspace bridge: ${error2 instanceof Error ? error2.message : String(error2)}
388
+ `
389
+ );
390
+ host.onerror?.(error2);
391
+ closeDaemon();
392
+ settle();
393
+ };
394
+ void daemon.start().catch(failStart);
395
+ void host.start().catch(failStart);
396
+ });
397
+ }
398
+ var StdioBridgeTransport = class {
399
+ constructor(stdin, stdout) {
400
+ this.stdin = stdin;
401
+ this.stdout = stdout;
402
+ }
403
+ stdin;
404
+ stdout;
405
+ onclose;
406
+ onerror;
407
+ onmessage;
408
+ buffer = new ReadBuffer();
409
+ started = false;
410
+ closed = false;
411
+ async start() {
412
+ if (this.started) throw new Error("the stdio bridge transport is already started");
413
+ this.started = true;
414
+ this.stdin.on("data", this.onData);
415
+ this.stdin.on("error", this.onStdinFault);
416
+ this.stdin.on("end", this.onStdinEnd);
417
+ this.stdout.on("error", this.onStdoutFault);
418
+ }
419
+ send(message) {
420
+ if (this.closed) return Promise.reject(new Error("the stdio bridge transport is closed"));
421
+ return new Promise((resolve2, reject) => {
422
+ if (this.stdout.write(serializeMessage(message))) {
423
+ resolve2();
424
+ return;
425
+ }
426
+ const onDrain = () => {
427
+ this.stdout.off("error", onWriteError);
428
+ resolve2();
429
+ };
430
+ const onWriteError = (error2) => {
431
+ this.stdout.off("drain", onDrain);
432
+ reject(error2);
433
+ };
434
+ this.stdout.once("drain", onDrain);
435
+ this.stdout.once("error", onWriteError);
436
+ });
437
+ }
438
+ async close() {
439
+ if (this.closed) return;
440
+ this.closed = true;
441
+ this.stdin.off("data", this.onData);
442
+ this.stdin.off("error", this.onStdinFault);
443
+ this.stdin.off("end", this.onStdinEnd);
444
+ this.stdout.off("error", this.onStdoutFault);
445
+ this.buffer.clear();
446
+ if (this.stdin.listenerCount("data") === 0) this.stdin.pause();
447
+ this.onclose?.();
448
+ }
449
+ onData = (chunk) => {
450
+ try {
451
+ this.buffer.append(chunk);
452
+ for (; ; ) {
453
+ const message = this.buffer.readMessage();
454
+ if (message === null) return;
455
+ this.onmessage?.(message);
456
+ }
457
+ } catch (error2) {
458
+ this.onerror?.(toError(error2));
459
+ void this.close();
460
+ }
461
+ };
462
+ /** The host closed its end of the pipe — the session is over. */
463
+ onStdinEnd = () => {
464
+ void this.close();
465
+ };
466
+ onStdinFault = (error2) => {
467
+ this.onerror?.(error2);
468
+ void this.close();
469
+ };
470
+ onStdoutFault = (error2) => {
471
+ this.onerror?.(error2);
472
+ };
473
+ };
474
+ function toError(value) {
475
+ return value instanceof Error ? value : new Error(String(value));
476
+ }
477
+
262
478
  // src/workspace-mcp.ts
263
479
  import { existsSync, readFileSync } from "fs";
264
- import { join } from "path";
265
- import { resolveAdapter } from "@noir-ai/adapters";
480
+ import { mcpConfigPathFor, noirStdioArgs, TRANSPORT_KEYS } from "@noir-ai/adapters";
266
481
  import { atomicWriteFile, resolveNoirCommand } from "@noir-ai/core";
267
- function mcpPathFor(root, host) {
268
- return resolveAdapter(host).mcpConfigPath?.({ root }) ?? join(root, ".mcp.json");
269
- }
270
482
  function readJson(path) {
271
483
  if (!existsSync(path)) return null;
272
484
  return JSON.parse(readFileSync(path, "utf8"));
@@ -300,39 +512,50 @@ function isNoirEmitted(mcpServers) {
300
512
  if (typeof noir.command === "string") return true;
301
513
  return noir.type === "http";
302
514
  }
303
- function writeWorkspaceHttpEntry(root, host, url, projectId, opts) {
304
- const path = mcpPathFor(root, host);
515
+ function noirEntry(mcpServers) {
516
+ const noir = mcpServers?.noir;
517
+ if (typeof noir !== "object" || noir === null || Array.isArray(noir)) return {};
518
+ return noir;
519
+ }
520
+ function writeNoirTransport(root, host, transport, opts) {
521
+ const path = mcpConfigPathFor(host, root);
305
522
  const existing = loadExisting(path, opts);
523
+ const kept = Object.fromEntries(
524
+ Object.entries(noirEntry(existing?.mcpServers)).filter(([key]) => !TRANSPORT_KEYS.has(key))
525
+ );
306
526
  const next = {
307
527
  ...existing ?? {},
308
528
  mcpServers: {
309
529
  ...existing?.mcpServers ?? {},
310
- noir: { type: "http", url: `${url}?p=${projectId}` }
530
+ noir: { ...transport, ...kept }
311
531
  }
312
532
  };
313
533
  atomicWriteFile(path, `${JSON.stringify(next, null, 2)}
314
534
  `);
315
535
  }
536
+ function writeWorkspaceEntry(root, host, name, opts) {
537
+ writeNoirTransport(
538
+ root,
539
+ host,
540
+ { command: resolveNoirCommand(), args: noirStdioArgs(name) },
541
+ opts
542
+ );
543
+ }
316
544
  function writeStdioEntry(root, host, opts) {
317
- const path = mcpPathFor(root, host);
318
- const existing = loadExisting(path, opts);
319
- const noir = { command: resolveNoirCommand(), args: ["mcp", "serve", "--stdio"] };
320
- const next = { ...existing ?? {}, mcpServers: { ...existing?.mcpServers ?? {}, noir } };
321
- atomicWriteFile(path, `${JSON.stringify(next, null, 2)}
322
- `);
545
+ writeNoirTransport(root, host, { command: resolveNoirCommand(), args: noirStdioArgs() }, opts);
323
546
  }
324
547
 
325
548
  // src/commands/workspace.ts
326
549
  function loadProjectOrFail(opts) {
327
550
  try {
328
- return loadProjectInfo(process.cwd());
551
+ return loadProjectInfo2(process.cwd());
329
552
  } catch {
330
553
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
331
554
  }
332
555
  }
333
- function finishJoin(project, name, url, opts) {
556
+ function finishJoin(project, name, opts) {
334
557
  writeWorkspaceMarker(project.root, name);
335
- writeWorkspaceHttpEntry(project.root, project.config.host, url, project.id, opts);
558
+ writeWorkspaceEntry(project.root, project.config.host, name, opts);
336
559
  }
337
560
  async function daemonStartWorkspace(opts) {
338
561
  const project = loadProjectOrFail(opts);
@@ -365,7 +588,7 @@ async function daemonStartWorkspace(opts) {
365
588
  }
366
589
  if (opts.detach === true) {
367
590
  const spawned = await spawnDetachedWorkspaceDaemon({ name: opts.name, project });
368
- finishJoin(project, opts.name, `http://127.0.0.1:${spawned.port}/mcp`, opts);
591
+ finishJoin(project, opts.name, opts);
369
592
  if (opts.json === true) {
370
593
  process.stdout.write(
371
594
  `${JSON.stringify({ ok: true, data: { mode: "detached", pid: spawned.pid, port: spawned.port } })}
@@ -384,7 +607,7 @@ async function daemonStartWorkspace(opts) {
384
607
  project,
385
608
  idleTimeoutSec: project.config.workspace.idleTimeoutSec
386
609
  });
387
- finishJoin(project, opts.name, ensured.url, opts);
610
+ finishJoin(project, opts.name, opts);
388
611
  if (opts.json === true) {
389
612
  process.stdout.write(
390
613
  `${JSON.stringify({ ok: true, data: { url: ensured.url, port: ensured.port, reused: !ensured.started } })}
@@ -414,7 +637,7 @@ async function daemonJoin(opts) {
414
637
  idleTimeoutSec: project.config.workspace.idleTimeoutSec
415
638
  });
416
639
  upsertWorkspaceMember(reg, { projectId: project.id, root: project.root, joinedAt: Date.now() });
417
- finishJoin(project, opts.name, ensured.url, opts);
640
+ finishJoin(project, opts.name, opts);
418
641
  if (opts.json === true) {
419
642
  process.stdout.write(
420
643
  `${JSON.stringify({ ok: true, data: { joined: true, url: ensured.url, port: ensured.port } })}
@@ -431,19 +654,20 @@ async function workspaceList(opts) {
431
654
  } catch {
432
655
  names = [];
433
656
  }
434
- const rows = names.map((n) => {
435
- const reg = readWorkspaceRegistry(n);
436
- return {
437
- name: n,
438
- members: reg?.members.length ?? 0,
439
- // A record file whose pid is dead (daemon killed without cleanup) must not
440
- // read as "running" — mirror the pidAlive liveness `workspace status` uses.
441
- running: (() => {
442
- const rec = readWorkspaceDaemonRecord(n);
443
- return rec !== null && pidAlive(rec.pid);
444
- })()
445
- };
446
- });
657
+ const rows = await Promise.all(
658
+ names.map(async (n) => {
659
+ const reg = readWorkspaceRegistry(n);
660
+ const rec = readWorkspaceDaemonRecord2(n);
661
+ const health = rec === null ? null : await verifyWorkspaceDaemon(rec, n);
662
+ const running = health?.kind === "healthy";
663
+ return {
664
+ name: n,
665
+ members: reg?.members.length ?? 0,
666
+ running,
667
+ ...rec !== null && !running ? { stale: true } : {}
668
+ };
669
+ })
670
+ );
447
671
  if (opts.json === true) {
448
672
  process.stdout.write(`${JSON.stringify({ ok: true, data: { workspaces: rows } })}
449
673
  `);
@@ -454,7 +678,8 @@ async function workspaceList(opts) {
454
678
  return;
455
679
  }
456
680
  for (const r of rows) {
457
- log(`${r.name} \u2014 ${r.members} member(s)${r.running ? ", running" : ""}`, opts);
681
+ const state = r.running ? ", running" : r.stale ? ", not running (stale record)" : "";
682
+ log(`${r.name} \u2014 ${r.members} member(s)${state}`, opts);
458
683
  }
459
684
  }
460
685
  async function workspaceStatus(opts) {
@@ -466,23 +691,23 @@ async function workspaceStatus(opts) {
466
691
  fail(EXIT.USAGE, `invalid workspace name ${JSON.stringify(name)}.`, opts);
467
692
  }
468
693
  const reg = readWorkspaceRegistry(name);
469
- const rec = readWorkspaceDaemonRecord(name);
470
- const running = rec !== null && pidAlive(rec.pid);
694
+ const rec = readWorkspaceDaemonRecord2(name);
695
+ const health = rec === null ? null : await verifyWorkspaceDaemon(rec, name);
696
+ const running = health?.kind === "healthy";
471
697
  const data = {
472
698
  name,
473
699
  members: reg?.members.map((m) => m.projectId) ?? [],
474
700
  running,
475
- ...rec ? { pid: rec.pid, port: rec.port } : {}
701
+ ...rec ? { pid: rec.pid, port: rec.port } : {},
702
+ ...rec !== null && !running ? { stale: true } : {}
476
703
  };
477
704
  if (opts.json === true) {
478
705
  process.stdout.write(`${JSON.stringify({ ok: true, data })}
479
706
  `);
480
707
  return;
481
708
  }
482
- log(
483
- `workspace ${name}: ${running ? `running (pid ${rec?.pid}, port ${rec?.port})` : "not running"}`,
484
- opts
485
- );
709
+ const state = running ? `running (pid ${rec?.pid}, port ${rec?.port})` : rec !== null ? `not running (stale record for pid ${rec.pid})` : "not running";
710
+ log(`workspace ${name}: ${state}`, opts);
486
711
  for (const m of data.members) log(` member: ${m}`, opts);
487
712
  }
488
713
  async function workspaceLeave(opts) {
@@ -512,8 +737,8 @@ async function workspaceStop(opts) {
512
737
  if (!isValidWorkspaceName(name)) {
513
738
  fail(EXIT.USAGE, `invalid workspace name ${JSON.stringify(name)}.`, opts);
514
739
  }
515
- const rec = readWorkspaceDaemonRecord(name);
516
- if (rec === null || !pidAlive(rec.pid)) {
740
+ const rec = readWorkspaceDaemonRecord2(name);
741
+ if (rec === null || !pidAlive2(rec.pid)) {
517
742
  if (opts.json === true) {
518
743
  process.stdout.write(
519
744
  `${JSON.stringify({ ok: true, data: { running: false, stopped: false } })}
@@ -524,6 +749,10 @@ async function workspaceStop(opts) {
524
749
  info("workspace daemon is not running.", opts);
525
750
  return;
526
751
  }
752
+ const health = await verifyWorkspaceDaemon(rec, name);
753
+ if (health.kind !== "healthy") {
754
+ fail(EXIT.ERROR, stopRefusal(name, rec, health), opts);
755
+ }
527
756
  try {
528
757
  process.kill(rec.pid, "SIGTERM");
529
758
  } catch {
@@ -538,6 +767,12 @@ async function workspaceStop(opts) {
538
767
  }
539
768
  info(`stopped workspace daemon (pid ${rec.pid}).`, opts);
540
769
  }
770
+ function stopRefusal(name, rec, health) {
771
+ if (health.kind === "foreign") {
772
+ return `refusing to stop workspace ${name}: the daemon answering on port ${rec.port} serves workspace ${health.workspace}, so the recorded pid ${rec.pid} is not this workspace's daemon.`;
773
+ }
774
+ return `refusing to stop workspace ${name}: the process recorded for it (pid ${rec.pid}) does not answer /health as this workspace, so it may be an unrelated process.`;
775
+ }
541
776
 
542
777
  // src/commands/daemon.ts
543
778
  var MODE = "foreground";
@@ -549,7 +784,7 @@ function formatUptime(sec) {
549
784
  }
550
785
  function resolveCallerProjectId() {
551
786
  try {
552
- return loadProjectInfo2(process.cwd()).id;
787
+ return loadProjectInfo3(process.cwd()).id;
553
788
  } catch {
554
789
  return void 0;
555
790
  }
@@ -581,7 +816,7 @@ async function daemonStart(opts) {
581
816
  }
582
817
  let project;
583
818
  try {
584
- project = loadProjectInfo2(process.cwd());
819
+ project = loadProjectInfo3(process.cwd());
585
820
  } catch {
586
821
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
587
822
  }
@@ -613,7 +848,7 @@ async function daemonStart(opts) {
613
848
  }
614
849
  if (opts.detach === true) {
615
850
  const existing = readProjectDaemonRecord(project.id);
616
- if (existing && pidAlive2(existing.pid) && await isHealthy(existing.port, existing.pid, project.id)) {
851
+ if (existing && pidAlive3(existing.pid) && await isHealthy(existing.port, existing.pid, project.id)) {
617
852
  if (opts.json === true) {
618
853
  process.stdout.write(
619
854
  `${JSON.stringify({ ok: true, data: { mode: "detached", reused: true } })}
@@ -723,7 +958,7 @@ async function daemonStop(opts) {
723
958
  const ds = spinner(`Stopping daemon (pid ${rec.pid})...`, opts).start();
724
959
  let signalled = false;
725
960
  let errMsg2;
726
- const healthy = pidAlive2(rec.pid) && await isHealthy(rec.port, rec.pid, callerProject);
961
+ const healthy = pidAlive3(rec.pid) && await isHealthy(rec.port, rec.pid, callerProject);
727
962
  if (healthy) {
728
963
  try {
729
964
  process.kill(rec.pid, "SIGTERM");
@@ -765,7 +1000,7 @@ async function daemonStatus(opts) {
765
1000
  if (!rec) {
766
1001
  fail(EXIT.DAEMON_DOWN, "Noir daemon is not running (start with `noir daemon start`).", opts);
767
1002
  }
768
- if (!pidAlive2(rec.pid)) {
1003
+ if (!pidAlive3(rec.pid)) {
769
1004
  clearProjectDaemonRecord(callerProject);
770
1005
  fail(EXIT.DAEMON_DOWN, "Noir daemon is not running (stale record removed).", opts);
771
1006
  }
@@ -827,14 +1062,14 @@ async function daemonToken(opts) {
827
1062
  opts
828
1063
  );
829
1064
  }
830
- if (!pidAlive2(rec.pid) || !await isHealthy(rec.port, rec.pid, callerProject)) {
1065
+ if (!pidAlive3(rec.pid) || !await isHealthy(rec.port, rec.pid, callerProject)) {
831
1066
  fail(
832
1067
  EXIT.DAEMON_DOWN,
833
1068
  "Noir daemon is not running (stale record \u2014 start it with `noir daemon start`).",
834
1069
  opts
835
1070
  );
836
1071
  }
837
- const token = readDaemonToken(callerProject);
1072
+ const token = readDaemonToken2(callerProject);
838
1073
  if (token === null) {
839
1074
  fail(
840
1075
  EXIT.DAEMON_DOWN,
@@ -857,15 +1092,17 @@ async function daemonRestart(opts) {
857
1092
 
858
1093
  // src/commands/doctor.ts
859
1094
  import { spawnSync } from "child_process";
860
- import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
1095
+ import { existsSync as existsSync2, readdirSync as readdirSync3, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
861
1096
  import { dirname, join as join2, relative } from "path";
862
1097
  import { fileURLToPath } from "url";
863
- import { resolveAdapter as resolveAdapter2 } from "@noir-ai/adapters";
1098
+ import { mcpConfigPathFor as mcpConfigPathFor2, resolveAdapter } from "@noir-ai/adapters";
864
1099
  import {
865
1100
  detectActiveMethod,
1101
+ ensureOwnerOnly,
1102
+ ensureOwnerOnlyDir,
866
1103
  latestVersionFromCache,
867
1104
  loadNoirEnv,
868
- loadProjectInfo as loadProjectInfo3,
1105
+ loadProjectInfo as loadProjectInfo4,
869
1106
  NOIR_VERSION,
870
1107
  paths,
871
1108
  readInstallRecord,
@@ -878,8 +1115,167 @@ import {
878
1115
  readScaffoldVersion,
879
1116
  render
880
1117
  } from "@noir-ai/create";
881
- import { pidAlive as pidAlive3, readProjectDaemonRecord as readProjectDaemonRecord2 } from "@noir-ai/daemon";
1118
+ import { pidAlive as pidAlive4, readProjectDaemonRecord as readProjectDaemonRecord2 } from "@noir-ai/daemon";
882
1119
  import { resolveModelConfig } from "@noir-ai/model";
1120
+
1121
+ // src/hygiene-scan.ts
1122
+ import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
1123
+ import { join } from "path";
1124
+ import { checkHygiene } from "@noir-ai/skills";
1125
+ var HYGIENE_FINDING_CAP = 5;
1126
+ var HYGIENE_MAX_FILE_BYTES = 512 * 1024;
1127
+ var HYGIENE_MAX_FILES = 2e3;
1128
+ var HYGIENE_CODE_EXTENSIONS = /\.(?:ts|tsx|js|jsx|mjs|cjs|sh)$/i;
1129
+ var HYGIENE_MARKDOWN_EXTENSIONS = /\.(?:md|mdx)$/i;
1130
+ var HYGIENE_SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage"]);
1131
+ var HYGIENE_EXCLUDED_DIRS = /* @__PURE__ */ new Set(["docs/internal", "docs/decisions", "docs/roadmap"]);
1132
+ var HYGIENE_EXCLUDED_FILES = /* @__PURE__ */ new Set(["CHANGELOG.md"]);
1133
+ function readDirEntries(dir) {
1134
+ try {
1135
+ return readdirSync2(dir, { withFileTypes: true });
1136
+ } catch {
1137
+ return [];
1138
+ }
1139
+ }
1140
+ function isDirectory(path) {
1141
+ try {
1142
+ return statSync(path).isDirectory();
1143
+ } catch {
1144
+ return false;
1145
+ }
1146
+ }
1147
+ function hygieneKindOf(path) {
1148
+ if (HYGIENE_MARKDOWN_EXTENSIONS.test(path)) return "markdown";
1149
+ if (HYGIENE_CODE_EXTENSIONS.test(path)) return "code";
1150
+ return null;
1151
+ }
1152
+ function hasScannableLayout(root) {
1153
+ if (isDirectory(join(root, "docs")) || isDirectory(join(root, "scripts"))) return true;
1154
+ for (const entry of readDirEntries(join(root, "packages"))) {
1155
+ if (!entry.isDirectory()) continue;
1156
+ if (isDirectory(join(root, "packages", entry.name, "src"))) return true;
1157
+ if (isDirectory(join(root, "packages", entry.name, "test"))) return true;
1158
+ }
1159
+ return false;
1160
+ }
1161
+ function addHygieneFile(rel, walk) {
1162
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
1163
+ if (name.startsWith(".") || HYGIENE_EXCLUDED_FILES.has(name)) return;
1164
+ const kind = hygieneKindOf(rel);
1165
+ if (kind === null) return;
1166
+ if (walk.files.length >= HYGIENE_MAX_FILES) {
1167
+ walk.truncated = true;
1168
+ return;
1169
+ }
1170
+ walk.files.push({ path: rel, kind });
1171
+ }
1172
+ function walkScannable(root, relDir, skip, walk) {
1173
+ if (walk.truncated) return;
1174
+ for (const entry of readDirEntries(join(root, relDir))) {
1175
+ if (walk.truncated) return;
1176
+ const rel = `${relDir}/${entry.name}`;
1177
+ if (entry.isDirectory()) {
1178
+ if (HYGIENE_SKIP_DIRS.has(entry.name) || entry.name.startsWith(".") || skip.has(rel)) {
1179
+ continue;
1180
+ }
1181
+ walkScannable(root, rel, skip, walk);
1182
+ } else if (entry.isFile()) {
1183
+ addHygieneFile(rel, walk);
1184
+ }
1185
+ }
1186
+ }
1187
+ var NO_SKIP_DIRS = /* @__PURE__ */ new Set();
1188
+ function collectHygieneFiles(root) {
1189
+ const walk = { files: [], truncated: false };
1190
+ for (const entry of readDirEntries(root)) {
1191
+ if (entry.isFile()) addHygieneFile(entry.name, walk);
1192
+ }
1193
+ walkScannable(root, "docs", HYGIENE_EXCLUDED_DIRS, walk);
1194
+ walkScannable(root, ".claude/skills", NO_SKIP_DIRS, walk);
1195
+ for (const entry of readDirEntries(join(root, "packages"))) {
1196
+ if (!entry.isDirectory()) continue;
1197
+ walkScannable(root, `packages/${entry.name}/src`, NO_SKIP_DIRS, walk);
1198
+ walkScannable(root, `packages/${entry.name}/test`, NO_SKIP_DIRS, walk);
1199
+ }
1200
+ walkScannable(root, "scripts", NO_SKIP_DIRS, walk);
1201
+ walk.files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1202
+ return walk;
1203
+ }
1204
+ function hygieneOmissions(result) {
1205
+ const notes = [];
1206
+ if (result.skipped > 0) {
1207
+ notes.push(
1208
+ `${result.skipped} file${result.skipped === 1 ? "" : "s"} over ${HYGIENE_MAX_FILE_BYTES / 1024} KiB skipped`
1209
+ );
1210
+ }
1211
+ if (result.truncated) notes.push(`stopped at ${HYGIENE_MAX_FILES} files`);
1212
+ return notes.length > 0 ? ` (${notes.join("; ")})` : "";
1213
+ }
1214
+ function hygieneCounts(result) {
1215
+ const omitted = hygieneOmissions(result);
1216
+ if (result.scanned === 0) {
1217
+ return `nothing to scan (no packages/*/src, packages/*/test, scripts/ or documents found)${omitted}`;
1218
+ }
1219
+ if (result.findings.length === 0) {
1220
+ return `clean \u2014 ${result.scanned} file${result.scanned === 1 ? "" : "s"} scanned${omitted}`;
1221
+ }
1222
+ const files = new Set(result.findings.map((f) => f.path)).size;
1223
+ return `${result.fail} fail, ${result.warn} warn in ${files} file${files === 1 ? "" : "s"}${omitted}`;
1224
+ }
1225
+ function hygieneDetail(result) {
1226
+ const counts = hygieneCounts(result);
1227
+ if (result.scanned === 0 || result.findings.length === 0) return counts;
1228
+ const named = result.findings.slice(0, HYGIENE_FINDING_CAP);
1229
+ const hidden = result.findings.length - named.length;
1230
+ const where = named.map((f) => `${f.path}:${f.line} ${f.id}`).join("; ");
1231
+ return `${counts} \u2014 ${where}${hidden > 0 ? ` (+${hidden} more)` : ""}`;
1232
+ }
1233
+ function scanOutputHygiene(root) {
1234
+ const empty = {
1235
+ scanned: 0,
1236
+ skipped: 0,
1237
+ truncated: false,
1238
+ findings: [],
1239
+ fail: 0,
1240
+ warn: 0
1241
+ };
1242
+ if (!hasScannableLayout(root)) return empty;
1243
+ const walk = collectHygieneFiles(root);
1244
+ const findings = [];
1245
+ let scanned = 0;
1246
+ let skipped = 0;
1247
+ for (const file of walk.files) {
1248
+ const abs = join(root, file.path);
1249
+ try {
1250
+ if (statSync(abs).size > HYGIENE_MAX_FILE_BYTES) {
1251
+ skipped++;
1252
+ continue;
1253
+ }
1254
+ const text = readFileSync2(abs, "utf8");
1255
+ scanned++;
1256
+ for (const found of checkHygiene(text, file.kind)) {
1257
+ findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });
1258
+ }
1259
+ } catch {
1260
+ }
1261
+ }
1262
+ findings.sort((a, b) => {
1263
+ if (a.tier !== b.tier) return a.tier === "fail" ? -1 : 1;
1264
+ if (a.path !== b.path) return a.path < b.path ? -1 : 1;
1265
+ return a.line - b.line;
1266
+ });
1267
+ const fail2 = findings.filter((f) => f.tier === "fail").length;
1268
+ return {
1269
+ scanned,
1270
+ skipped,
1271
+ truncated: walk.truncated,
1272
+ findings,
1273
+ fail: fail2,
1274
+ warn: findings.length - fail2
1275
+ };
1276
+ }
1277
+
1278
+ // src/commands/doctor.ts
883
1279
  async function probeOnnx() {
884
1280
  try {
885
1281
  const spec = "onnxruntime-node";
@@ -894,7 +1290,7 @@ function describeOnnx(o) {
894
1290
  if (/Cannot find (module|package)/i.test(o.reason)) {
895
1291
  return "not resolvable from CLI probe (best-effort)";
896
1292
  }
897
- const r = o.reason.length > 60 ? `${o.reason.slice(0, 59)}\u2026` : o.reason;
1293
+ const r = truncateToWidth(o.reason, 60);
898
1294
  return `probe failed: ${r}`;
899
1295
  }
900
1296
  function fmtUptime(sec) {
@@ -926,7 +1322,7 @@ function checkConfig(root) {
926
1322
  };
927
1323
  }
928
1324
  try {
929
- const project = loadProjectInfo3(root);
1325
+ const project = loadProjectInfo4(root);
930
1326
  return {
931
1327
  project,
932
1328
  result: {
@@ -960,7 +1356,7 @@ async function checkDaemon(checks, project) {
960
1356
  });
961
1357
  return;
962
1358
  }
963
- if (!pidAlive3(rec.pid)) {
1359
+ if (!pidAlive4(rec.pid)) {
964
1360
  checks.push({
965
1361
  name: "daemon",
966
1362
  status: "warn",
@@ -1044,11 +1440,21 @@ async function checkStore(checks, project, root) {
1044
1440
  }
1045
1441
  }
1046
1442
  var ENV_REFUSAL_PREFIX = ".noir/.env: refusing to load";
1443
+ function envModeCause(st) {
1444
+ return st.ctimeMs > st.mtimeMs ? "changed" : "legacy";
1445
+ }
1446
+ function octalMode(mode) {
1447
+ return mode.toString(8).padStart(4, "0");
1448
+ }
1449
+ function describeEnvMode(cause, mode) {
1450
+ const lead = `is ${octalMode(mode)} \u2014 accessible to other accounts; run \`noir sync\` (or \`chmod 600 .noir/.env\`) to make it owner-only.`;
1451
+ return cause === "legacy" ? `${lead} Nothing has changed its mode since the file was written, so the file predates the owner-only contract (an older Noir seeded it, a clone delivered it, it was created by hand, or it was saved by a tool that replaces the file) \u2014 this is not a change you made.` : `${lead} Its mode was changed after the file was written (a \`chmod\`, a \`chown\`, or a restore that re-applied metadata), so no older Noir left it this way.`;
1452
+ }
1047
1453
  function checkNoirEnv(checks, root, loaded) {
1048
1454
  const path = join2(paths.noirDir(root), ".env");
1049
1455
  let st;
1050
1456
  try {
1051
- st = statSync(path);
1457
+ st = statSync2(path);
1052
1458
  } catch {
1053
1459
  return;
1054
1460
  }
@@ -1066,8 +1472,8 @@ function checkNoirEnv(checks, root, loaded) {
1066
1472
  tooOpen ? {
1067
1473
  name: "noir-env",
1068
1474
  status: "warn",
1069
- detail: `${path} is ${mode.toString(8)} \u2014 readable by others; run chmod 600`
1070
- } : { name: "noir-env", status: "ok", detail: `${path} permissions ok (${mode.toString(8)})` }
1475
+ detail: `${path} ${describeEnvMode(envModeCause(st), mode)}`
1476
+ } : { name: "noir-env", status: "ok", detail: `${path} permissions ok (${octalMode(mode)})` }
1071
1477
  );
1072
1478
  for (const [key, source] of Object.entries(loaded.sources)) {
1073
1479
  if (source !== "file") continue;
@@ -1167,7 +1573,7 @@ function checkSeedDrift(checks, root) {
1167
1573
  const seedPath = join2(root, ".noir", ".env.example");
1168
1574
  let bytes;
1169
1575
  try {
1170
- bytes = readFileSync2(seedPath, "utf8");
1576
+ bytes = readFileSync3(seedPath, "utf8");
1171
1577
  } catch {
1172
1578
  return;
1173
1579
  }
@@ -1185,6 +1591,14 @@ function checkRulesMdBudget(checks, root, project) {
1185
1591
  checks.push({ name: "rules budget", status: "warn", detail: "skipped \u2014 not initialized" });
1186
1592
  return null;
1187
1593
  }
1594
+ if (project.config.rules.enabled === false) {
1595
+ checks.push({
1596
+ name: "rules budget",
1597
+ status: "ok",
1598
+ detail: "disabled \u2014 rules.enabled is false in .noir/config.yml (RULES.md is not measured)"
1599
+ });
1600
+ return null;
1601
+ }
1188
1602
  const rulesPath = paths.rulesMd(root);
1189
1603
  if (!existsSync2(rulesPath)) {
1190
1604
  checks.push({
@@ -1196,7 +1610,7 @@ function checkRulesMdBudget(checks, root, project) {
1196
1610
  }
1197
1611
  let content;
1198
1612
  try {
1199
- content = readFileSync2(rulesPath, "utf8");
1613
+ content = readFileSync3(rulesPath, "utf8");
1200
1614
  } catch (e) {
1201
1615
  checks.push({
1202
1616
  name: "rules budget",
@@ -1240,7 +1654,7 @@ function checkHostArtifacts(checks, root, project) {
1240
1654
  return null;
1241
1655
  }
1242
1656
  const host = project.config.host;
1243
- const adapter = resolveAdapter2(host);
1657
+ const adapter = resolveAdapter(host);
1244
1658
  const ectx = { root };
1245
1659
  const expected = [];
1246
1660
  const emitsAgentsMd = host === "agents-md" || host === "cursor" || host === "opencode";
@@ -1249,7 +1663,7 @@ function checkHostArtifacts(checks, root, project) {
1249
1663
  }
1250
1664
  if (host === "claude") expected.push("CLAUDE.md");
1251
1665
  else if (host === "gemini") expected.push("GEMINI.md");
1252
- expected.push(relOr(adapter.mcpConfigPath?.(ectx) ?? ".mcp.json", root));
1666
+ expected.push(relOr(mcpConfigPathFor2(host, root), root));
1253
1667
  const missing = expected.filter((p) => !existsSync2(joinRoot(root, p)));
1254
1668
  const status2 = missing.length === 0 ? "ok" : "warn";
1255
1669
  const detail = missing.length === 0 ? `host=${host} \xB7 all ${expected.length} primary artifacts present` : `host=${host} \xB7 missing ${missing.join(", ")} (run \`noir sync\` to restore)`;
@@ -1276,6 +1690,15 @@ function checkNestedNoir(checks, root) {
1276
1690
  });
1277
1691
  return { detected, paths: found };
1278
1692
  }
1693
+ function checkOutputHygiene(checks, root) {
1694
+ const result = scanOutputHygiene(root);
1695
+ checks.push({
1696
+ name: "output hygiene",
1697
+ status: result.fail > 0 ? "fail" : result.warn > 0 ? "warn" : "ok",
1698
+ detail: hygieneDetail(result)
1699
+ });
1700
+ return result;
1701
+ }
1279
1702
  function collectDedupCandidates(root) {
1280
1703
  const rels = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".noir/rules/RULES.md"];
1281
1704
  const out = [];
@@ -1284,7 +1707,7 @@ function collectDedupCandidates(root) {
1284
1707
  if (!existsSync2(abs)) continue;
1285
1708
  let text;
1286
1709
  try {
1287
- text = readFileSync2(abs, "utf8");
1710
+ text = readFileSync3(abs, "utf8");
1288
1711
  } catch {
1289
1712
  continue;
1290
1713
  }
@@ -1344,7 +1767,7 @@ function resolveWorkspacePackagesDir() {
1344
1767
  const pkgPath = join2(here, "package.json");
1345
1768
  if (existsSync2(pkgPath)) {
1346
1769
  try {
1347
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf8"));
1770
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
1348
1771
  if (pkg.name === "@noir-ai/cli") {
1349
1772
  const packagesDir = dirname(here);
1350
1773
  if (existsSync2(join2(dirname(packagesDir), "pnpm-workspace.yaml"))) {
@@ -1393,7 +1816,7 @@ function checkPublish(checks, packagesDir, opts = {}) {
1393
1816
  }
1394
1817
  let entries = [];
1395
1818
  try {
1396
- entries = readdirSync2(packagesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1819
+ entries = readdirSync3(packagesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1397
1820
  } catch (e) {
1398
1821
  checks.push({
1399
1822
  name: "publish",
@@ -1410,7 +1833,7 @@ function checkPublish(checks, packagesDir, opts = {}) {
1410
1833
  if (!existsSync2(pkgPath)) continue;
1411
1834
  let pkg;
1412
1835
  try {
1413
- pkg = JSON.parse(readFileSync2(pkgPath, "utf8"));
1836
+ pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
1414
1837
  } catch (e) {
1415
1838
  issues.push(`${name}/package.json: unparseable (${msg(e)})`);
1416
1839
  checked++;
@@ -1489,6 +1912,17 @@ function renderHuman(payload, opts) {
1489
1912
  ["Check", "Status", "Detail"],
1490
1913
  opts
1491
1914
  );
1915
+ if (payload.fixes) {
1916
+ const healed = payload.fixes.filter((f) => f.outcome === "healed");
1917
+ if (healed.length > 0) {
1918
+ log(
1919
+ `fixed ${healed.length} permission${healed.length === 1 ? "" : "s"}: ${healed.map((f) => f.path).join(", ")}`,
1920
+ opts
1921
+ );
1922
+ } else {
1923
+ log("nothing to fix \u2014 permissions already owner-only", opts);
1924
+ }
1925
+ }
1492
1926
  const { ok, warn: warnN, fail: fail2 } = payload.summary;
1493
1927
  if (fail2 > 0) {
1494
1928
  error(
@@ -1501,12 +1935,24 @@ function renderHuman(payload, opts) {
1501
1935
  success(`all ${ok} check${ok === 1 ? "" : "s"} passed`, opts);
1502
1936
  }
1503
1937
  }
1938
+ function fixOwnerOnlyPermissions(root, project) {
1939
+ const fixes = [
1940
+ { path: ".noir/.env", outcome: ensureOwnerOnly(join2(paths.noirDir(root), ".env")) }
1941
+ ];
1942
+ if (project) {
1943
+ const dbRel = `.noir/store/${project.id}.db`;
1944
+ fixes.push({ path: dbRel, outcome: ensureOwnerOnly(join2(root, dbRel)) });
1945
+ fixes.push({ path: ".noir/store", outcome: ensureOwnerOnlyDir(paths.storeDir(root)) });
1946
+ }
1947
+ return fixes;
1948
+ }
1504
1949
  async function doctor(opts = {}) {
1505
1950
  const root = process.cwd();
1506
1951
  const checks = [];
1507
1952
  await checkRuntime(checks);
1508
1953
  const { project, result: configResult } = checkConfig(root);
1509
1954
  checks.push(configResult);
1955
+ const fixes = opts.fix === true ? fixOwnerOnlyPermissions(root, project) : void 0;
1510
1956
  await checkDaemon(checks, project);
1511
1957
  const { vecOk } = await checkNativeDeps(checks);
1512
1958
  await checkStore(checks, project, root);
@@ -1519,6 +1965,7 @@ async function doctor(opts = {}) {
1519
1965
  const rules = checkRulesMdBudget(checks, root, project);
1520
1966
  const host = checkHostArtifacts(checks, root, project);
1521
1967
  checkNestedNoir(checks, root);
1968
+ checkOutputHygiene(checks, root);
1522
1969
  if (opts.dedup === true) {
1523
1970
  await checkSemanticDupDoctor(checks, root, project);
1524
1971
  }
@@ -1542,7 +1989,8 @@ async function doctor(opts = {}) {
1542
1989
  method: installCheck.method,
1543
1990
  installedVersion: installCheck.installedVersion,
1544
1991
  latestKnown: installCheck.latestKnown
1545
- }
1992
+ },
1993
+ ...fixes ? { fixes } : {}
1546
1994
  };
1547
1995
  if (opts.json === true) {
1548
1996
  process.stdout.write(`${JSON.stringify({ ok: true, data: payload })}
@@ -1559,7 +2007,7 @@ async function doctor(opts = {}) {
1559
2007
  }
1560
2008
 
1561
2009
  // src/commands/env.ts
1562
- import { loadNoirEnv as loadNoirEnv2, loadProjectInfo as loadProjectInfo4 } from "@noir-ai/core";
2010
+ import { loadNoirEnv as loadNoirEnv2, loadProjectInfo as loadProjectInfo5 } from "@noir-ai/core";
1563
2011
  import { resolveModelConfig as resolveModelConfig2 } from "@noir-ai/model";
1564
2012
  var CURATED_AMBIENT_KEYS = [
1565
2013
  "CLICKUP_API_TOKEN",
@@ -1580,11 +2028,11 @@ function captureAmbientEnv(env2 = process.env) {
1580
2028
  var SHORT_VALUE_MAX = 8;
1581
2029
  function redactShape(value) {
1582
2030
  if (value.length <= SHORT_VALUE_MAX) return `\u2026(${value.length})`;
1583
- return `${value.slice(0, 3)}\u2026(${value.length})`;
2031
+ return `${truncateToWidth(value, 3, "")}\u2026(${value.length})`;
1584
2032
  }
1585
2033
  function configApiKeyEnvNames(root) {
1586
2034
  try {
1587
- const project = loadProjectInfo4(root);
2035
+ const project = loadProjectInfo5(root);
1588
2036
  const names = /* @__PURE__ */ new Set();
1589
2037
  for (const provider of Object.values(resolveModelConfig2(project.config.model).providers)) {
1590
2038
  if (provider.apiKeyEnv !== void 0 && provider.apiKeyEnv.length > 0) {
@@ -1655,14 +2103,14 @@ import { join as join3 } from "path";
1655
2103
  import {
1656
2104
  defaultHandoffBlock,
1657
2105
  hostLaunchDirective,
1658
- resolveAdapter as resolveAdapter3
2106
+ resolveAdapter as resolveAdapter2
1659
2107
  } from "@noir-ai/adapters";
1660
- import { loadProjectInfo as loadProjectInfo6, NOIR_DIR, resolveArtifactPath } from "@noir-ai/core";
2108
+ import { loadProjectInfo as loadProjectInfo7, NOIR_DIR, resolveArtifactPath } from "@noir-ai/core";
1661
2109
 
1662
2110
  // src/commands/task.ts
1663
2111
  import { spawnSync as spawnSync2 } from "child_process";
1664
2112
  import { writeFileSync } from "fs";
1665
- import { loadProjectInfo as loadProjectInfo5, nextArtifactSequence, sha256Hex } from "@noir-ai/core";
2113
+ import { loadProjectInfo as loadProjectInfo6, nextArtifactSequence, sha256Hex } from "@noir-ai/core";
1666
2114
  import {
1667
2115
  PHASES,
1668
2116
  TASK_CLASSES,
@@ -1686,7 +2134,7 @@ function skillFor(phase) {
1686
2134
  }
1687
2135
  function writeDoneArtifacts(opts, taskId) {
1688
2136
  try {
1689
- const project = loadProjectInfo5(process.cwd());
2137
+ const project = loadProjectInfo6(process.cwd());
1690
2138
  const conflict = { conflictPolicy: "preserve", interactive: false };
1691
2139
  writeChangelogStub(project.root, `- ${taskId}: completed (verify gate passed)`, conflict);
1692
2140
  const nextN = nextArtifactSequence(project.root, "adr");
@@ -2010,7 +2458,7 @@ function runCheck(cmd) {
2010
2458
  async function taskVerify(opts) {
2011
2459
  let project;
2012
2460
  try {
2013
- project = loadProjectInfo5(process.cwd());
2461
+ project = loadProjectInfo6(process.cwd());
2014
2462
  } catch {
2015
2463
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
2016
2464
  }
@@ -2116,7 +2564,7 @@ async function taskResearch(opts) {
2116
2564
  return;
2117
2565
  }
2118
2566
  renderStatusRow(s, opts);
2119
- info("research findings are recorded via `noir task research record` (list + record)", opts);
2567
+ info("this is a status view; record findings with `noir task research-record`", opts);
2120
2568
  }
2121
2569
  async function taskResearchRecord(opts) {
2122
2570
  const TYPES = ["assumption", "discovery", "decision", "grounding-fact"];
@@ -2140,10 +2588,7 @@ async function taskResearchRecord(opts) {
2140
2588
  `);
2141
2589
  return;
2142
2590
  }
2143
- success(
2144
- `research recorded \u2192 ${opts.type}: ${opts.text.slice(0, 80)}${opts.text.length > 80 ? "\u2026" : ""}`,
2145
- opts
2146
- );
2591
+ success(`research recorded \u2192 ${opts.type}: ${truncateToWidth(opts.text, 80)}`, opts);
2147
2592
  }
2148
2593
  async function taskDecompose(opts) {
2149
2594
  const cap = opts.capability.trim();
@@ -2290,7 +2735,7 @@ function toHostPayload(p) {
2290
2735
  }
2291
2736
  function resolveDirective(root, p) {
2292
2737
  const ctx = { root };
2293
- const adapter = resolveAdapter3(p.host);
2738
+ const adapter = resolveAdapter2(p.host);
2294
2739
  const hostPayload = toHostPayload(p);
2295
2740
  if (typeof adapter.emitHandoff === "function") {
2296
2741
  return adapter.emitHandoff(ctx, hostPayload);
@@ -2333,7 +2778,7 @@ async function probeOnly(opts) {
2333
2778
  async function handoff(opts) {
2334
2779
  let project;
2335
2780
  try {
2336
- project = loadProjectInfo6(process.cwd());
2781
+ project = loadProjectInfo7(process.cwd());
2337
2782
  } catch {
2338
2783
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
2339
2784
  }
@@ -2382,7 +2827,7 @@ import { hostLaunchDirective as hostLaunchDirective2 } from "@noir-ai/adapters";
2382
2827
  import {
2383
2828
  detectActiveMethod as detectActiveMethod2,
2384
2829
  latestVersionFromCache as latestVersionFromCache2,
2385
- loadProjectInfo as loadProjectInfo7,
2830
+ loadProjectInfo as loadProjectInfo8,
2386
2831
  NOIR_VERSION as NOIR_VERSION2,
2387
2832
  readInstallRecord as readInstallRecord2,
2388
2833
  readUpdateCache as readUpdateCache2
@@ -2400,10 +2845,12 @@ var NOIR_BLOCK = [
2400
2845
  ];
2401
2846
  var NOIR_GRADIENT = gradient("#2c5282", "#3b82f6", "#7dd3fc");
2402
2847
  var NOIR_TAGLINE = "discipline, context, and memory layer for agentic CLIs";
2848
+ var BLOCK_WIDTH = NOIR_BLOCK.reduce((max, line) => Math.max(max, displayWidth(line)), 0);
2849
+ var BLOCK_MIN_WIDTH = BLOCK_WIDTH + 17;
2403
2850
  function renderBanner(opts = {}) {
2404
2851
  const width = opts.width ?? process.stdout.columns ?? 80;
2405
2852
  const colorOn = opts.color !== false && useColor();
2406
- if (width < 50) {
2853
+ if (width < BLOCK_MIN_WIDTH) {
2407
2854
  return colorOn ? `${c.accent("\u25C6")} noir` : "\u25C6 noir";
2408
2855
  }
2409
2856
  const block = NOIR_BLOCK.join("\n");
@@ -2710,7 +3157,7 @@ async function resolveSections(commands) {
2710
3157
  // src/commands/home.ts
2711
3158
  function tryProject() {
2712
3159
  try {
2713
- const info2 = loadProjectInfo7(process.cwd());
3160
+ const info2 = loadProjectInfo8(process.cwd());
2714
3161
  return { id: info2.id, name: info2.name, host: info2.config.host };
2715
3162
  } catch {
2716
3163
  return null;
@@ -2874,7 +3321,7 @@ async function runMenu(opts, deps) {
2874
3321
  env: process.env,
2875
3322
  configUpdate: (() => {
2876
3323
  try {
2877
- const info2 = loadProjectInfo7(process.cwd());
3324
+ const info2 = loadProjectInfo8(process.cwd());
2878
3325
  return info2.config.update ?? DEFAULT_UPDATE_CONFIG;
2879
3326
  } catch {
2880
3327
  return DEFAULT_UPDATE_CONFIG;
@@ -2923,13 +3370,13 @@ ${COMMANDS_HINT}
2923
3370
  }
2924
3371
 
2925
3372
  // src/commands/memory.ts
2926
- import { readFileSync as readFileSync3 } from "fs";
2927
- import { loadProjectInfo as loadProjectInfo8 } from "@noir-ai/core";
3373
+ import { readFileSync as readFileSync4 } from "fs";
3374
+ import { loadProjectInfo as loadProjectInfo9 } from "@noir-ai/core";
2928
3375
  function resolveWorkspace() {
2929
3376
  const routing = workspaceRoutingTarget(process.cwd());
2930
3377
  if (routing === null) return null;
2931
3378
  try {
2932
- return { routing, project: loadProjectInfo8(process.cwd()) };
3379
+ return { routing, project: loadProjectInfo9(process.cwd()) };
2933
3380
  } catch {
2934
3381
  return null;
2935
3382
  }
@@ -3141,14 +3588,14 @@ async function resolveCaptureContent(opts) {
3141
3588
  if (typeof opts.content === "string" && opts.content.length > 0) return opts.content;
3142
3589
  if (typeof opts.file === "string" && opts.file.length > 0) {
3143
3590
  try {
3144
- return readFileSync3(opts.file, "utf8");
3591
+ return readFileSync4(opts.file, "utf8");
3145
3592
  } catch {
3146
3593
  fail(EXIT.USAGE, `memory capture could not read --file ${opts.file}`, opts);
3147
3594
  }
3148
3595
  }
3149
3596
  if (!process.stdin.isTTY) {
3150
3597
  try {
3151
- return readFileSync3(0, "utf8");
3598
+ return readFileSync4(0, "utf8");
3152
3599
  } catch {
3153
3600
  fail(
3154
3601
  EXIT.USAGE,
@@ -3334,7 +3781,7 @@ function describe(step, who) {
3334
3781
  const m = {
3335
3782
  preflight: "check clean tree, on develop, HEAD pushed, gate green, no version collision",
3336
3783
  bump: "run bump-version.mjs + update CHANGELOG + roadmap docs",
3337
- gate: "re-run the full gate (lint/build/typecheck/test/docs:validate)",
3784
+ gate: "re-run the full six-step gate (lint/build/typecheck/test/docs:validate/hygiene:gate)",
3338
3785
  commit: "commit chore(release) + push develop",
3339
3786
  "ci-develop": "wait for CI on develop \u2192 green",
3340
3787
  "beta-tag": "pnpm release:tag \u2192 beta tag + push",
@@ -3394,11 +3841,14 @@ async function release(opts) {
3394
3841
  });
3395
3842
  success(`bump: \u2713 version ${version}`, opts);
3396
3843
  info(`[3/13] gate: ${describe("gate", "noir")}`, opts);
3397
- execSync("pnpm lint && pnpm build && pnpm typecheck && pnpm test && pnpm docs:validate", {
3398
- encoding: "utf8",
3399
- stdio: "inherit"
3400
- });
3401
- success("gate: \u2713 full gate green", opts);
3844
+ execSync(
3845
+ "pnpm lint && pnpm build && pnpm typecheck && pnpm test && pnpm docs:validate && pnpm hygiene:gate",
3846
+ {
3847
+ encoding: "utf8",
3848
+ stdio: "inherit"
3849
+ }
3850
+ );
3851
+ success("gate: \u2713 all six gates green", opts);
3402
3852
  info(`[4/13] commit/push: ${describe("commit", "noir")}`, opts);
3403
3853
  spawnSync3("git", ["add", "-A"], { encoding: "utf8", stdio: "inherit" });
3404
3854
  spawnSync3("git", ["commit", "-m", `chore(release): v${version} + docs sync`], {
@@ -4206,10 +4656,10 @@ var RunInterrupt = class {
4206
4656
  };
4207
4657
 
4208
4658
  // src/run-profiles.ts
4209
- import { isDeniedEnvKey, loadProjectInfo as loadProjectInfo9 } from "@noir-ai/core";
4659
+ import { isDeniedEnvKey, loadProjectInfo as loadProjectInfo10 } from "@noir-ai/core";
4210
4660
  function loadRunConfig(root) {
4211
4661
  try {
4212
- return loadProjectInfo9(root).config;
4662
+ return loadProjectInfo10(root).config;
4213
4663
  } catch {
4214
4664
  return null;
4215
4665
  }
@@ -4356,6 +4806,7 @@ var RunStatusLine = class {
4356
4806
  if (this.animated && this.sharesCursor && this.onRow) {
4357
4807
  this.write("\n");
4358
4808
  this.onRow = false;
4809
+ this.rendered = "";
4359
4810
  }
4360
4811
  this.stdoutAtRowStart = chunk.endsWith("\n");
4361
4812
  }
@@ -4386,19 +4837,45 @@ var RunStatusLine = class {
4386
4837
  return;
4387
4838
  }
4388
4839
  if (!this.onRow || !this.cursorFree()) return;
4389
- this.write("\r\x1B[K");
4840
+ this.write(this.erase(this.rendered));
4390
4841
  this.rendered = "";
4391
4842
  this.onRow = false;
4392
4843
  }
4393
4844
  /** Rewrite the current row in place, skipping no-op rewrites and unsafe moments. */
4394
4845
  draw() {
4395
4846
  if (!this.animated || !this.cursorFree()) return;
4396
- const text = this.render();
4847
+ const text = truncateToWidth(this.render(), terminalWidth());
4397
4848
  if (text === this.rendered) return;
4398
- this.write(`\r\x1B[K${text}`);
4849
+ this.write(`${this.erase(this.rendered)}${text}`);
4399
4850
  this.rendered = text;
4400
4851
  this.onRow = true;
4401
4852
  }
4853
+ /**
4854
+ * The escape sequence that clears the status text currently on screen and
4855
+ * leaves the cursor at the start of the row it began on — where the next
4856
+ * render writes. An empty `previous` still clears the row, so a line that has
4857
+ * drawn nothing yet starts from a known-blank one.
4858
+ *
4859
+ * The extent is measured against the terminal width as it is NOW, not as it
4860
+ * was when the text was drawn. Nothing this line writes wraps, but text that
4861
+ * fitted a wider terminal does not stop existing when the window narrows: the
4862
+ * terminal folds it onto rows below, and clearing only the row the cursor
4863
+ * ends on would leave the rest above as residue. So the sequence walks back
4864
+ * up over every row the text occupies. That folding is the behaviour of the
4865
+ * common terminals (iTerm2, Terminal.app, GNOME Terminal and recent xterm);
4866
+ * one that clips the row instead has no continuation to clear.
4867
+ *
4868
+ * The caller must have forgotten the text once its row was abandoned —
4869
+ * `rendered` is only meaningful while `onRow` is true.
4870
+ */
4871
+ erase(previous) {
4872
+ const rows = Math.max(1, Math.ceil(displayWidth(previous) / terminalWidth()));
4873
+ let sequence = "\r\x1B[K";
4874
+ for (let row = 1; row < rows; row++) {
4875
+ sequence += "\x1B[1A\r\x1B[K";
4876
+ }
4877
+ return sequence;
4878
+ }
4402
4879
  /**
4403
4880
  * Whether the cursor is at the start of a row nobody else is using. On stdout
4404
4881
  * that is not this terminal there is no shared cursor to disturb; when it is,
@@ -4678,8 +5155,8 @@ function writeTranscript(host, lines, root = process.cwd()) {
4678
5155
  }
4679
5156
 
4680
5157
  // src/commands/skills.ts
4681
- import { resolveAdapter as resolveAdapter5 } from "@noir-ai/adapters";
4682
- import { loadProjectInfo as loadProjectInfo11 } from "@noir-ai/core";
5158
+ import { resolveAdapter as resolveAdapter4 } from "@noir-ai/adapters";
5159
+ import { loadProjectInfo as loadProjectInfo12 } from "@noir-ai/core";
4683
5160
  import {
4684
5161
  buildRegistry,
4685
5162
  discoverAll,
@@ -4689,8 +5166,8 @@ import {
4689
5166
 
4690
5167
  // src/init.ts
4691
5168
  import { existsSync as existsSync4 } from "fs";
4692
- import { resolveAdapter as resolveAdapter4 } from "@noir-ai/adapters";
4693
- import { loadProjectInfo as loadProjectInfo10, paths as paths3 } from "@noir-ai/core";
5169
+ import { resolveAdapter as resolveAdapter3 } from "@noir-ai/adapters";
5170
+ import { loadProjectInfo as loadProjectInfo11, paths as paths3 } from "@noir-ai/core";
4694
5171
  import { scaffold } from "@noir-ai/create";
4695
5172
  import {
4696
5173
  emitSkillsToDir
@@ -4798,7 +5275,7 @@ function labelFor(resolution) {
4798
5275
  }
4799
5276
 
4800
5277
  // src/dedup-write.ts
4801
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync5 } from "fs";
5278
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync5 } from "fs";
4802
5279
  import { join as join5 } from "path";
4803
5280
  import { sha256Hex as sha256Hex2, sha256Hex12 } from "@noir-ai/core";
4804
5281
  var HOST_CONTEXT_RELS = /* @__PURE__ */ new Set(["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".noir/rules/RULES.md"]);
@@ -4818,7 +5295,7 @@ async function checkWritePathDedup(root, res, opts) {
4818
5295
  const proposedAbs = join5(root, proposedRel);
4819
5296
  let proposedText;
4820
5297
  try {
4821
- proposedText = readFileSync4(proposedAbs, "utf8");
5298
+ proposedText = readFileSync5(proposedAbs, "utf8");
4822
5299
  } catch {
4823
5300
  continue;
4824
5301
  }
@@ -4958,7 +5435,7 @@ async function promptDedupAction(root, proposedRel, matchedRel, proposedText, si
4958
5435
  return "replace";
4959
5436
  } else if (choice === "mirror") {
4960
5437
  try {
4961
- const existing = readFileSync4(join5(root, matchedRel), "utf8");
5438
+ const existing = readFileSync5(join5(root, matchedRel), "utf8");
4962
5439
  writeFileSync5(
4963
5440
  join5(root, matchedRel),
4964
5441
  `${existing}
@@ -4982,7 +5459,7 @@ function cachePath(root) {
4982
5459
  }
4983
5460
  function loadCache(root) {
4984
5461
  try {
4985
- const raw = readFileSync4(cachePath(root), "utf8");
5462
+ const raw = readFileSync5(cachePath(root), "utf8");
4986
5463
  const parsed = JSON.parse(raw);
4987
5464
  if (parsed.version !== DEDUP_CACHE_VERSION) return emptyCache();
4988
5465
  if (typeof parsed.entries !== "object" || parsed.entries === null) return emptyCache();
@@ -5014,7 +5491,7 @@ function makeCachedEmbed(cache, embed) {
5014
5491
  }
5015
5492
  function safeRead(abs) {
5016
5493
  try {
5017
- return readFileSync4(abs, "utf8");
5494
+ return readFileSync5(abs, "utf8");
5018
5495
  } catch {
5019
5496
  return void 0;
5020
5497
  }
@@ -5061,13 +5538,14 @@ async function init(root, opts) {
5061
5538
  reportPlannedWrites(res);
5062
5539
  return res;
5063
5540
  }
5541
+ reportEnvHeal(res);
5064
5542
  if (res.noop) return res;
5065
5543
  const skillSummary = await emitHostSkills(root, host, conflictOpts, interactive);
5066
5544
  res.skillConflicts = skillSummary?.conflicts ?? [];
5067
5545
  res.preservedSkills = skillSummary?.preserved ?? [];
5068
5546
  let projectInfo;
5069
5547
  try {
5070
- projectInfo = loadProjectInfo10(root);
5548
+ projectInfo = loadProjectInfo11(root);
5071
5549
  } catch {
5072
5550
  projectInfo = void 0;
5073
5551
  }
@@ -5098,7 +5576,7 @@ function resolveInitHost(root, opts) {
5098
5576
  if (opts.host !== void 0) return opts.host;
5099
5577
  if (!existsSync4(paths3.config(root))) return "claude";
5100
5578
  try {
5101
- return loadProjectInfo10(root).config.host;
5579
+ return loadProjectInfo11(root).config.host;
5102
5580
  } catch {
5103
5581
  process.stderr.write(
5104
5582
  `Could not read the configured host from ${paths3.config(root)}; using 'claude'.
@@ -5129,8 +5607,13 @@ function reportPlannedWrites(res) {
5129
5607
  for (const p of res.identical) log(` ${p}`);
5130
5608
  }
5131
5609
  }
5610
+ function reportEnvHeal(res) {
5611
+ if (res.envMode === "healed") {
5612
+ process.stderr.write("Tightened .noir/.env to 0600 (it was readable by group or others).\n");
5613
+ }
5614
+ }
5132
5615
  async function emitHostSkills(root, host, conflictOpts, interactive) {
5133
- const adapter = resolveAdapter4(host);
5616
+ const adapter = resolveAdapter3(host);
5134
5617
  const skillsDir = adapter.skillsDir?.({ root });
5135
5618
  if (skillsDir === void 0) {
5136
5619
  process.stderr.write(`host '${host}' has no skill emitter; skipping skills
@@ -5212,7 +5695,8 @@ var CATEGORY = {
5212
5695
  "noir-backend": "domain",
5213
5696
  "noir-doctor": "meta",
5214
5697
  "noir-writing-skills": "meta",
5215
- "noir-rules": "meta"
5698
+ "noir-rules": "meta",
5699
+ "noir-code-hygiene": "meta"
5216
5700
  };
5217
5701
  function categoryOf(name) {
5218
5702
  const mapped = CATEGORY[name];
@@ -5225,13 +5709,6 @@ function toRow(s, kind = "builtin") {
5225
5709
  const category = s.frontmatter.metadata?.category?.trim() || categoryOf(s.name);
5226
5710
  return { name: s.name, category, description, kind, status: status2 };
5227
5711
  }
5228
- function truncate(text, max) {
5229
- if (text.length <= max) return text;
5230
- const slice = text.slice(0, Math.max(0, max - 1));
5231
- const boundary = slice.lastIndexOf(" ");
5232
- const head = boundary > 0 ? slice.slice(0, boundary) : slice;
5233
- return `${head.trimEnd()}\u2026`;
5234
- }
5235
5712
  async function skillsList(opts) {
5236
5713
  let builtins;
5237
5714
  let integrations;
@@ -5265,7 +5742,9 @@ async function skillsList(opts) {
5265
5742
  Kind: r.kind,
5266
5743
  Category: r.category,
5267
5744
  Status: r.status,
5268
- Description: truncate(r.description, 80)
5745
+ // Display-only cut for the dense table cell; the `--json` payload always
5746
+ // carries the description whole (search snippets must never be truncated).
5747
+ Description: truncateToWidth(r.description, 80)
5269
5748
  })),
5270
5749
  ["Skill", "Kind", "Category", "Status", "Description"],
5271
5750
  opts
@@ -5275,11 +5754,11 @@ async function skillsSync(opts) {
5275
5754
  const root = process.cwd();
5276
5755
  let project;
5277
5756
  try {
5278
- project = loadProjectInfo11(root);
5757
+ project = loadProjectInfo12(root);
5279
5758
  } catch {
5280
5759
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
5281
5760
  }
5282
- const adapter = resolveAdapter5(project.config.host);
5761
+ const adapter = resolveAdapter4(project.config.host);
5283
5762
  if (!adapter.skillsDir) {
5284
5763
  const data2 = {
5285
5764
  emitted: [],
@@ -5341,7 +5820,7 @@ async function skillsLint(opts) {
5341
5820
  );
5342
5821
  }
5343
5822
  const errored = skills.filter((s) => s.errors.length > 0);
5344
- const warned = skills.filter((s) => s.warnings.length > 0 && s.errors.length === 0);
5823
+ const warned = skills.filter((s) => s.warnings.length > 0);
5345
5824
  const data = { count: skills.length, errored: errored.length, skills };
5346
5825
  if (errored.length > 0) {
5347
5826
  const message = `skills lint: ${errored.length} skill${errored.length === 1 ? "" : "s"} failed validation`;
@@ -5415,11 +5894,15 @@ async function skillsRegistry(opts) {
5415
5894
  }
5416
5895
 
5417
5896
  // src/serve.ts
5418
- import { applyNoirEnv, loadProjectInfo as loadProjectInfo12 } from "@noir-ai/core";
5897
+ import { applyNoirEnv, loadProjectInfo as loadProjectInfo13 } from "@noir-ai/core";
5419
5898
  import { ensureDaemonRunning as ensureDaemonRunning2, startStdioServer } from "@noir-ai/daemon";
5420
5899
  async function serve(opts) {
5421
5900
  applyNoirEnv(process.cwd());
5422
- const project = loadProjectInfo12(process.cwd());
5901
+ if (opts.workspace !== void 0) {
5902
+ await bridgeStdioToWorkspace(opts.workspace, process.cwd());
5903
+ return;
5904
+ }
5905
+ const project = loadProjectInfo13(process.cwd());
5423
5906
  if (opts.stdio) {
5424
5907
  await startStdioServer({ project, transport: "stdio", daemon: false });
5425
5908
  return;
@@ -5549,6 +6032,8 @@ function createProgram() {
5549
6032
  const nonInteractive = opts.json === true || opts.input === false;
5550
6033
  if (nonInteractive) process.env.NOIR_NON_INTERACTIVE = "1";
5551
6034
  else delete process.env.NOIR_NON_INTERACTIVE;
6035
+ if (opts.quiet === true) process.env.NOIR_QUIET = "1";
6036
+ else delete process.env.NOIR_QUIET;
5552
6037
  emitDeprecationHintsFor(actionCmd, toCliOptions(opts));
5553
6038
  });
5554
6039
  program2.command("init").description("scaffold Noir in the current project (.noir/, .mcp.json, CLAUDE.md, skills)").option("--transport <transport>", "stdio | streamable-http (default: stdio)", "stdio").option("--url <url>", "streamable-http daemon URL (localhost only)").option(
@@ -5596,7 +6081,7 @@ function createProgram() {
5596
6081
  const force = opts.force === true;
5597
6082
  const host = parseHost(opts.host);
5598
6083
  const dryRun = opts.dryRun === true || opts.preview === true;
5599
- const { create } = await import("./create-6KDIFPUL.js");
6084
+ const { create } = await import("./create-ZZN7XFLG.js");
5600
6085
  const result = await create(dir, {
5601
6086
  transport,
5602
6087
  url: opts.url,
@@ -5635,7 +6120,7 @@ function createProgram() {
5635
6120
  ).choices(SUPPORTED_HOSTS2)
5636
6121
  ).action(
5637
6122
  async (opts, cmd) => {
5638
- const { sync } = await import("./sync-AII5WCRO.js");
6123
+ const { sync } = await import("./sync-GHEPFPLX.js");
5639
6124
  const host = parseHost(opts.host);
5640
6125
  const force = opts.force === true;
5641
6126
  const merge = opts.merge === true;
@@ -5654,11 +6139,17 @@ function createProgram() {
5654
6139
  }
5655
6140
  );
5656
6141
  const mcpCmd = program2.command("mcp").description("MCP server control");
5657
- mcpCmd.command("serve").description("run the Noir MCP server (stdio, or via the shared daemon)").option("--stdio", "force the stdio transport").action(async (opts) => {
5658
- await serve({ stdio: opts.stdio === true });
6142
+ mcpCmd.command("serve").description("run the Noir MCP server (stdio, or via the shared daemon)").option("--stdio", "force the stdio transport").option(
6143
+ "--workspace <name>",
6144
+ "serve a shared workspace over stdio, bridging to that workspace daemon"
6145
+ ).action(async (opts) => {
6146
+ await serve({
6147
+ stdio: opts.stdio === true,
6148
+ ...typeof opts.workspace === "string" ? { workspace: opts.workspace } : {}
6149
+ });
5659
6150
  });
5660
6151
  mcpCmd.action(() => {
5661
- throw new NoirCliError(EXIT.USAGE, "Usage: noir mcp serve [--stdio]");
6152
+ throw new NoirCliError(EXIT.USAGE, "Usage: noir mcp serve [--stdio] [--workspace <name>]");
5662
6153
  });
5663
6154
  const daemonGrp = program2.command("daemon").description("control the Noir daemon");
5664
6155
  daemonGrp.command("start").description("start the Noir daemon (foreground, or background with --detach)").option("--detach", "run the daemon in the background and exit").option("--workspace <name>", "start a shared cross-repo workspace daemon instead").option("--force", "overwrite a non-Noir .mcp.json entry (--workspace only)").addOption(
@@ -5759,10 +6250,16 @@ function createProgram() {
5759
6250
  program2.command("doctor").description("environment + project health").option(
5760
6251
  "--dedup",
5761
6252
  "scan host-context + .noir/ docs for semantic near-duplicates (loads the local embedder)"
5762
- ).action(async (...args) => {
6253
+ ).option("--fix", "re-assert owner-only permissions on .noir/.env and the store DB + directory").action(async (...args) => {
5763
6254
  const g = actionGlobals(args);
5764
- const dedup = trailingCmd(args).opts().dedup === true;
5765
- await doctor({ ...toCliOptions(g), ...dedup ? { dedup: true } : {} });
6255
+ const opts = trailingCmd(args).opts();
6256
+ const dedup = opts.dedup === true;
6257
+ const fix = opts.fix === true;
6258
+ await doctor({
6259
+ ...toCliOptions(g),
6260
+ ...dedup ? { dedup: true } : {},
6261
+ ...fix ? { fix: true } : {}
6262
+ });
5766
6263
  });
5767
6264
  program2.command("status").description("project + daemon + workflow + store snapshot").action(async (...args) => {
5768
6265
  await status(toStatusOptions(actionGlobals(args)));
@@ -5955,7 +6452,7 @@ function createProgram() {
5955
6452
  ...check === void 0 || check.length === 0 ? {} : { check }
5956
6453
  });
5957
6454
  });
5958
- taskGrp.command("research").description("list research findings for the active (or named) task").argument("[id]", "task id (defaults to active)").action(async (...args) => {
6455
+ taskGrp.command("research").description("show research status for the active (or named) task").argument("[id]", "task id (defaults to active)").action(async (...args) => {
5959
6456
  const cmd = trailingCmd(args);
5960
6457
  const g = cmd.optsWithGlobals();
5961
6458
  const first = args[0];
@@ -6021,7 +6518,7 @@ function createProgram() {
6021
6518
  "dismiss the migration banner for the current version (persists in install.json)"
6022
6519
  ).argument("[spec]", "channel ('latest'|'beta') or exact version (default: latest)").action(
6023
6520
  async (spec, opts, cmd) => {
6024
- const { install } = await import("./install-KA35ONQP.js");
6521
+ const { install } = await import("./install-R5U2WTAQ.js");
6025
6522
  const cli = cmd.optsWithGlobals();
6026
6523
  await install({
6027
6524
  ...cli,
@@ -6034,7 +6531,7 @@ function createProgram() {
6034
6531
  );
6035
6532
  installCmd.alias("migrate");
6036
6533
  program2.command("update").description("update Noir to the latest version via the active install method").option("--check", "check for a new version without changing anything").argument("[spec]", "channel ('latest'|'beta') or exact version (default: latest)").action(async (spec, opts, cmd) => {
6037
- const { update } = await import("./update-XGKJ4KBR.js");
6534
+ const { update } = await import("./update-ZFOLOSZ2.js");
6038
6535
  const cli = cmd.optsWithGlobals();
6039
6536
  await update({ ...cli, spec, check: opts.check === true });
6040
6537
  });
@@ -6197,7 +6694,7 @@ function failDaemonDown(opts, cause) {
6197
6694
  async function probeDaemon(opts = {}) {
6198
6695
  const expectedProject = opts.project?.id ?? (() => {
6199
6696
  try {
6200
- return loadProjectInfo13(process.cwd()).id;
6697
+ return loadProjectInfo14(process.cwd()).id;
6201
6698
  } catch {
6202
6699
  return void 0;
6203
6700
  }
@@ -6211,7 +6708,7 @@ async function probeDaemon(opts = {}) {
6211
6708
  if (opts.verbose) process.stderr.write("noir: daemon probe: no daemon record\n");
6212
6709
  return { running: false };
6213
6710
  }
6214
- if (!pidAlive4(rec.pid)) {
6711
+ if (!pidAlive5(rec.pid)) {
6215
6712
  if (opts.verbose) process.stderr.write(`noir: daemon probe: pid ${rec.pid} not alive
6216
6713
  `);
6217
6714
  return { running: false };
@@ -6250,7 +6747,7 @@ async function probeDaemon(opts = {}) {
6250
6747
  async function resolveDaemon(opts) {
6251
6748
  let project;
6252
6749
  try {
6253
- project = opts.project ?? loadProjectInfo13(process.cwd());
6750
+ project = opts.project ?? loadProjectInfo14(process.cwd());
6254
6751
  } catch {
6255
6752
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
6256
6753
  }
@@ -6311,8 +6808,8 @@ async function resolveDaemon(opts) {
6311
6808
  return { client, stop };
6312
6809
  }
6313
6810
  function httpTransport(url, scopeKey) {
6314
- const token = scopeKey === null ? null : readDaemonToken2(scopeKey);
6315
- return new StreamableHTTPClientTransport(
6811
+ const token = scopeKey === null ? null : readDaemonToken3(scopeKey);
6812
+ return new StreamableHTTPClientTransport2(
6316
6813
  new URL(url),
6317
6814
  // Verified present: StreamableHTTPClientTransportOptions.requestInit?: RequestInit
6318
6815
  token ? { requestInit: { headers: { Authorization: `Bearer ${token}` } } } : {}
@@ -6320,7 +6817,7 @@ function httpTransport(url, scopeKey) {
6320
6817
  }
6321
6818
  function callerScopeKey(opts) {
6322
6819
  try {
6323
- return (opts.project ?? loadProjectInfo13(process.cwd())).id;
6820
+ return (opts.project ?? loadProjectInfo14(process.cwd())).id;
6324
6821
  } catch {
6325
6822
  return null;
6326
6823
  }
@@ -6350,7 +6847,7 @@ function failWorkspaceConnect(opts, routing, cause) {
6350
6847
  }
6351
6848
  fail(
6352
6849
  EXIT.DAEMON_DOWN,
6353
- `workspace ${JSON.stringify(routing.name)} daemon is running but rejected this client's token (HTTP 401) \u2014 its token file is ${tokenPath(routing.name)}; make sure it is readable, or restart the workspace daemon with \`noir daemon start --workspace ${routing.name}\` to mint a fresh one.`,
6850
+ `workspace ${JSON.stringify(routing.name)} daemon is running but rejected this client's token (HTTP 401) \u2014 its token file is ${tokenPath2(routing.name)}; make sure it is readable, or restart the workspace daemon with \`noir daemon start --workspace ${routing.name}\` to mint a fresh one.`,
6354
6851
  opts
6355
6852
  );
6356
6853
  }
@@ -6421,7 +6918,7 @@ async function callDaemonTool(opts, name, args = {}) {
6421
6918
  async function withInProcessRead(opts, fn) {
6422
6919
  let project;
6423
6920
  try {
6424
- project = opts.project ?? loadProjectInfo13(process.cwd());
6921
+ project = opts.project ?? loadProjectInfo14(process.cwd());
6425
6922
  } catch {
6426
6923
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
6427
6924
  }
@@ -6468,14 +6965,14 @@ function workspaceRoutingTarget(root) {
6468
6965
  const name = readWorkspaceMarker2(root);
6469
6966
  if (name === null) return null;
6470
6967
  try {
6471
- return { name, projectId: loadProjectInfo13(root).id };
6968
+ return { name, projectId: loadProjectInfo14(root).id };
6472
6969
  } catch {
6473
6970
  return null;
6474
6971
  }
6475
6972
  }
6476
6973
  async function probeWorkspaceDaemon(routing) {
6477
- const rec = readWorkspaceDaemonRecord2(routing.name);
6478
- if (rec === null || !pidAlive4(rec.pid)) return { running: false };
6974
+ const rec = readWorkspaceDaemonRecord3(routing.name);
6975
+ if (rec === null || !pidAlive5(rec.pid)) return { running: false };
6479
6976
  try {
6480
6977
  const res = await fetch(`http://127.0.0.1:${rec.port}/health`, {
6481
6978
  signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
@@ -6670,7 +7167,7 @@ function renderHuman2(p, opts) {
6670
7167
  async function gatherStatusPayload(opts) {
6671
7168
  let project;
6672
7169
  try {
6673
- project = loadProjectInfo14(process.cwd());
7170
+ project = loadProjectInfo15(process.cwd());
6674
7171
  } catch {
6675
7172
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
6676
7173
  }
@@ -6700,7 +7197,7 @@ async function status(opts) {
6700
7197
  env: process.env,
6701
7198
  configUpdate: (() => {
6702
7199
  try {
6703
- const info2 = loadProjectInfo14(process.cwd());
7200
+ const info2 = loadProjectInfo15(process.cwd());
6704
7201
  return info2.config.update ?? DEFAULT_UPDATE_CONFIG;
6705
7202
  } catch {
6706
7203
  return DEFAULT_UPDATE_CONFIG;
@@ -6744,8 +7241,9 @@ export {
6744
7241
  buildConflictOpts,
6745
7242
  checkWritePathDedup,
6746
7243
  reportPlannedWrites,
7244
+ reportEnvHeal,
6747
7245
  preservedStaleLine,
6748
7246
  assertTransportUrl,
6749
7247
  buildPaletteCommandsForTui
6750
7248
  };
6751
- //# sourceMappingURL=chunk-5KWETEMF.js.map
7249
+ //# sourceMappingURL=chunk-6AUAIEFG.js.map