@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.
package/dist/bin.js CHANGED
@@ -414,10 +414,82 @@ var init_daemon_client = __esm({
414
414
  }
415
415
  });
416
416
 
417
+ // src/width.ts
418
+ import stringWidth from "string-width";
419
+ import stripAnsi from "strip-ansi";
420
+ function clusters(s) {
421
+ return [...segmenter.segment(s)].map(({ segment }) => segment);
422
+ }
423
+ function displayWidth(s) {
424
+ return stringWidth(s);
425
+ }
426
+ function fitLeading(parts, width) {
427
+ let used = 0;
428
+ let count = 0;
429
+ for (const part of parts) {
430
+ const w = displayWidth(part);
431
+ if (used + w > width) {
432
+ break;
433
+ }
434
+ used += w;
435
+ count++;
436
+ }
437
+ return count;
438
+ }
439
+ function truncateToWidth(s, width, ellipsis = "\u2026") {
440
+ const plain = stripAnsi(s);
441
+ if (width <= 0) {
442
+ return "";
443
+ }
444
+ if (displayWidth(plain) <= width) {
445
+ return plain;
446
+ }
447
+ const markerParts = clusters(ellipsis);
448
+ const marker = markerParts.slice(0, fitLeading(markerParts, width)).join("");
449
+ const budget = width - displayWidth(marker);
450
+ const parts = clusters(plain);
451
+ return parts.slice(0, fitLeading(parts, budget)).join("") + marker;
452
+ }
453
+ function truncateMiddle(s, width, ellipsis = "\u2026") {
454
+ const plain = stripAnsi(s);
455
+ if (width <= 0) {
456
+ return "";
457
+ }
458
+ if (displayWidth(plain) <= width) {
459
+ return plain;
460
+ }
461
+ const markerParts = clusters(ellipsis);
462
+ const marker = markerParts.slice(0, fitLeading(markerParts, width)).join("");
463
+ const remaining = width - displayWidth(marker);
464
+ const headBudget = Math.ceil(remaining / 2);
465
+ const tailBudget = remaining - headBudget;
466
+ const parts = clusters(plain);
467
+ const headCount = fitLeading(parts, headBudget);
468
+ const tail = [];
469
+ let tailUsed = 0;
470
+ for (const part of parts.slice(headCount).reverse()) {
471
+ const w = displayWidth(part);
472
+ if (tailUsed + w > tailBudget) {
473
+ break;
474
+ }
475
+ tail.push(part);
476
+ tailUsed += w;
477
+ }
478
+ return parts.slice(0, headCount).join("") + marker + tail.reverse().join("");
479
+ }
480
+ var segmenter;
481
+ var init_width = __esm({
482
+ "src/width.ts"() {
483
+ "use strict";
484
+ segmenter = new Intl.Segmenter();
485
+ }
486
+ });
487
+
417
488
  // src/theme.ts
418
489
  import pc from "picocolors";
419
- function badge(state, label = state) {
420
- const text = `${BADGE_SYMBOL[state]} ${label}`;
490
+ function badge(state, label = state, maxWidth) {
491
+ const full = `${BADGE_SYMBOL[state]} ${label}`;
492
+ const text = maxWidth === void 0 ? full : truncateToWidth(full, maxWidth);
421
493
  switch (state) {
422
494
  case "ok":
423
495
  return c.ok(text);
@@ -438,8 +510,13 @@ function isCiEnv() {
438
510
  if (v === void 0 || v === "") return false;
439
511
  return v !== "0" && v !== "false";
440
512
  }
513
+ function quietMode() {
514
+ const v = process.env.NOIR_QUIET;
515
+ return v !== void 0 && v !== "";
516
+ }
441
517
  function useColor() {
442
518
  if (envFlagSet("NO_COLOR")) return false;
519
+ if (quietMode()) return false;
443
520
  if (process.env.CLICOLOR_FORCE === "1") return true;
444
521
  if (isCiEnv()) return false;
445
522
  return pc.isColorSupported;
@@ -453,14 +530,17 @@ function terminalWidth() {
453
530
  const n = Number(fromEnv);
454
531
  if (Number.isInteger(n) && n > 0) return Math.max(n, 20);
455
532
  }
456
- const cols = process.stdout.columns;
457
- if (typeof cols === "number" && cols > 0) return Math.max(cols, 20);
533
+ const stderrCols = process.stderr.columns;
534
+ if (typeof stderrCols === "number" && stderrCols > 0) return Math.max(stderrCols, 20);
535
+ const stdoutCols = process.stdout.columns;
536
+ if (typeof stdoutCols === "number" && stdoutCols > 0) return Math.max(stdoutCols, 20);
458
537
  return 80;
459
538
  }
460
539
  var FORCE, c, BADGE_SYMBOL;
461
540
  var init_theme = __esm({
462
541
  "src/theme.ts"() {
463
542
  "use strict";
543
+ init_width();
464
544
  FORCE = pc.createColors(true);
465
545
  c = {
466
546
  /** Green — success / healthy. */
@@ -559,17 +639,18 @@ function table(rows, cols, opts = {}) {
559
639
  info("(no rows)", opts);
560
640
  return;
561
641
  }
642
+ const widths = computeColWidths(rows, cols);
562
643
  const t = new Table({
563
644
  head: cols.map((col) => c.bold(c.info(col))),
564
645
  // Empty arrays ⇒ cli-table3 applies NO @colors/colors wrap (neither the
565
646
  // default red header nor a colored border). All color is ours, via theme.
566
647
  style: { head: [], border: [] },
567
- colWidths: computeColWidths(rows, cols),
648
+ colWidths: widths,
568
649
  wordWrap: true,
569
650
  truncate: "\u2026"
570
651
  });
571
652
  for (const row of rows) {
572
- t.push(cols.map((col) => formatCell(row[col])));
653
+ t.push(cols.map((col, i) => truncateCell(row[col], (widths[i] ?? 0) - 2)));
573
654
  }
574
655
  process.stderr.write(`${t.toString()}
575
656
  `);
@@ -577,43 +658,71 @@ function table(rows, cols, opts = {}) {
577
658
  function computeColWidths(rows, cols) {
578
659
  const n = cols.length;
579
660
  if (n === 0) return [];
580
- const headerLen = cols.map((col) => col.length);
661
+ const headerLen = cols.map((col) => displayWidth(col));
581
662
  const natural = cols.map((col, i) => {
582
- let max = Math.max(headerLen[i] ?? col.length, 3);
663
+ let max = Math.max(headerLen[i] ?? displayWidth(col), MIN_CONTENT_WIDTH);
583
664
  for (const row of rows) {
584
- const len = formatCell(row[col]).length;
665
+ const len = displayWidth(formatCell(row[col]));
585
666
  if (len > max) max = len;
586
667
  }
587
668
  return max;
588
669
  });
589
- const contentBudget = Math.max(n * 4, terminalWidth() - 3 * n - 1);
670
+ const budget = Math.max(n, terminalWidth() - 3 * n - 1);
590
671
  const naturalSum = natural.reduce((a, b) => a + b, 0);
591
- if (naturalSum <= contentBudget) {
672
+ if (naturalSum <= budget) {
592
673
  return natural.map((w) => w + 2);
593
674
  }
594
675
  const content = natural.slice();
595
676
  let sum = naturalSum;
596
- const floor = headerLen.map((h) => Math.max(h, 3));
597
- while (sum > contentBudget) {
677
+ const floor = headerLen.map((h) => Math.min(Math.max(h, MIN_CONTENT_WIDTH), budget));
678
+ while (sum > budget) {
598
679
  let idx = -1;
599
680
  let widest = -1;
600
681
  for (let i = 0; i < n; i++) {
601
682
  const c2 = content[i] ?? 0;
602
- const f = floor[i] ?? 3;
683
+ const f = floor[i] ?? MIN_CONTENT_WIDTH;
603
684
  if (c2 > f && c2 > widest) {
604
685
  widest = c2;
605
686
  idx = i;
606
687
  }
607
688
  }
608
689
  if (idx === -1) break;
609
- const overshoot = sum - contentBudget;
610
- const reducible = (content[idx] ?? 0) - (floor[idx] ?? 3);
690
+ const overshoot = sum - budget;
691
+ const reducible = (content[idx] ?? 0) - (floor[idx] ?? MIN_CONTENT_WIDTH);
611
692
  const cut = Math.min(overshoot, reducible);
612
693
  content[idx] = (content[idx] ?? 0) - cut;
613
694
  sum -= cut;
614
695
  }
696
+ if (sum > budget) {
697
+ for (let pass = 0; pass < n && sum > budget; pass++) {
698
+ let idx = -1;
699
+ let widest = 0;
700
+ for (let j = 0; j < n; j++) {
701
+ const c2 = content[j] ?? 0;
702
+ if (c2 > 1 && c2 > widest) {
703
+ widest = c2;
704
+ idx = j;
705
+ }
706
+ }
707
+ if (idx === -1) break;
708
+ const cut = Math.min(sum - budget, (content[idx] ?? 1) - 1);
709
+ content[idx] = (content[idx] ?? 1) - cut;
710
+ sum -= cut;
711
+ }
712
+ }
615
713
  return content.map((w) => w + 2);
616
714
  }
715
+ function isPathToken(s) {
716
+ if (!/[\\/]/.test(s)) return false;
717
+ return /^(\/|\.\/|\.\.\/|~\/|[A-Za-z]:[\\/])/.test(s) || /\.[A-Za-z0-9][A-Za-z0-9_-]*$/.test(s);
718
+ }
719
+ function truncateCell(value, contentWidth) {
720
+ const s = formatCell(value);
721
+ if (contentWidth <= 0 || displayWidth(s) <= contentWidth) return s;
722
+ if (isPathToken(s)) return truncateMiddle(s, contentWidth);
723
+ if (/\s/.test(s)) return s;
724
+ return truncateToWidth(s, contentWidth);
725
+ }
617
726
  function formatCell(value) {
618
727
  if (value === void 0 || value === null) return "";
619
728
  if (typeof value === "string") return value;
@@ -624,7 +733,7 @@ function formatCell(value) {
624
733
  }
625
734
  function definitionList(rows, opts = {}) {
626
735
  table(
627
- rows.map((r) => ({ Field: r.label, Value: formatCell(r.value) })),
736
+ rows.map((r) => ({ Field: r.label, Value: r.value })),
628
737
  ["Field", "Value"],
629
738
  opts
630
739
  );
@@ -705,11 +814,12 @@ function handleError(err) {
705
814
  }
706
815
  process.exitCode = inferExitCode(err);
707
816
  }
708
- var EXIT, NoirCliError, NOIR_ERROR_CODE, NoopSpinner, OraSpinner, EXIT_FLUSH_MS;
817
+ var EXIT, NoirCliError, NOIR_ERROR_CODE, MIN_CONTENT_WIDTH, NoopSpinner, OraSpinner, EXIT_FLUSH_MS;
709
818
  var init_output = __esm({
710
819
  "src/output.ts"() {
711
820
  "use strict";
712
821
  init_theme();
822
+ init_width();
713
823
  EXIT = {
714
824
  OK: 0,
715
825
  ERROR: 1,
@@ -727,6 +837,7 @@ var init_output = __esm({
727
837
  }
728
838
  };
729
839
  NOIR_ERROR_CODE = "noir.error";
840
+ MIN_CONTENT_WIDTH = 3;
730
841
  NoopSpinner = class {
731
842
  text = "";
732
843
  start(_text) {
@@ -961,14 +1072,230 @@ var init_context = __esm({
961
1072
  }
962
1073
  });
963
1074
 
1075
+ // src/workspace-bridge.ts
1076
+ import {
1077
+ ReadBuffer,
1078
+ StreamableHTTPClientTransport as StreamableHTTPClientTransport2,
1079
+ serializeMessage
1080
+ } from "@modelcontextprotocol/client";
1081
+ import { loadProjectInfo as loadProjectInfo2 } from "@noir-ai/core";
1082
+ import { pidAlive as pidAlive2, readDaemonToken as readDaemonToken2, readWorkspaceDaemonRecord as readWorkspaceDaemonRecord2, tokenPath as tokenPath2 } from "@noir-ai/daemon";
1083
+ async function resolveWorkspaceDaemon(name, root) {
1084
+ const record = readWorkspaceDaemonRecord2(name);
1085
+ if (record === null) {
1086
+ return { error: `no daemon recorded for workspace ${name}` };
1087
+ }
1088
+ const health = await verifyWorkspaceDaemon(record, name);
1089
+ if (health.kind === "foreign") {
1090
+ return {
1091
+ error: `workspace ${name} is recorded on port ${record.port}, but the daemon answering there serves workspace ${health.workspace}`
1092
+ };
1093
+ }
1094
+ if (health.kind === "silent") {
1095
+ return { error: `record exists but the daemon is not answering (pid ${record.pid})` };
1096
+ }
1097
+ const token = readDaemonToken2(name);
1098
+ if (token === null) {
1099
+ return {
1100
+ error: `workspace ${name} daemon is answering but its token is unreadable at ${tokenPath2(name)}`
1101
+ };
1102
+ }
1103
+ let callerProjectId;
1104
+ try {
1105
+ callerProjectId = loadProjectInfo2(root).id;
1106
+ } catch {
1107
+ return {
1108
+ error: `no project identity in ${root} \u2014 run \`noir init\` before serving a workspace from here`
1109
+ };
1110
+ }
1111
+ return { url: `http://127.0.0.1:${record.port}/mcp?p=${callerProjectId}`, token };
1112
+ }
1113
+ async function verifyWorkspaceDaemon(record, name) {
1114
+ if (!pidAlive2(record.pid)) return { kind: "silent" };
1115
+ return probeHealth(record.port, name, record.pid);
1116
+ }
1117
+ async function probeHealth(port, name, pid) {
1118
+ try {
1119
+ const response = await fetch(`http://127.0.0.1:${port}/health`, {
1120
+ signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS)
1121
+ });
1122
+ if (!response.ok) return { kind: "silent" };
1123
+ const body = await response.json().catch(() => null);
1124
+ if (body === null) return { kind: "silent" };
1125
+ if (typeof body.workspace === "string" && body.workspace !== name) {
1126
+ return { kind: "foreign", workspace: body.workspace };
1127
+ }
1128
+ if (body.ok !== true || body.pid !== pid) return { kind: "silent" };
1129
+ return { kind: "healthy" };
1130
+ } catch {
1131
+ return { kind: "silent" };
1132
+ }
1133
+ }
1134
+ async function bridgeStdioToWorkspace(name, root) {
1135
+ const resolved = await resolveWorkspaceDaemon(name, root);
1136
+ if ("error" in resolved) {
1137
+ fail(EXIT.DAEMON_DOWN, resolved.error);
1138
+ }
1139
+ const host = new StdioBridgeTransport(process.stdin, process.stdout);
1140
+ const daemon = new StreamableHTTPClientTransport2(new URL(resolved.url), {
1141
+ requestInit: { headers: { Authorization: `Bearer ${resolved.token}` } }
1142
+ });
1143
+ try {
1144
+ await relay(host, daemon);
1145
+ } finally {
1146
+ await daemon.close().catch(() => {
1147
+ });
1148
+ await host.close().catch(() => {
1149
+ });
1150
+ }
1151
+ }
1152
+ async function relay(host, daemon) {
1153
+ await new Promise((resolve3) => {
1154
+ let settled = false;
1155
+ let closing = false;
1156
+ const settle = () => {
1157
+ if (settled) return;
1158
+ settled = true;
1159
+ closing = true;
1160
+ resolve3();
1161
+ };
1162
+ const closeDaemon = () => {
1163
+ closing = true;
1164
+ void daemon.close().catch(() => {
1165
+ });
1166
+ };
1167
+ host.onmessage = (message) => {
1168
+ void daemon.send(message).catch(closeDaemon);
1169
+ };
1170
+ host.onclose = () => {
1171
+ closeDaemon();
1172
+ settle();
1173
+ };
1174
+ host.onerror = () => {
1175
+ closeDaemon();
1176
+ settle();
1177
+ };
1178
+ daemon.onmessage = (message) => {
1179
+ void host.send(message).catch(() => {
1180
+ });
1181
+ };
1182
+ daemon.onclose = () => {
1183
+ void host.close().catch(() => {
1184
+ });
1185
+ settle();
1186
+ };
1187
+ daemon.onerror = (error2) => {
1188
+ if (closing) return;
1189
+ process.stderr.write(
1190
+ `noir: workspace bridge: ${error2 instanceof Error ? error2.message : String(error2)}
1191
+ `
1192
+ );
1193
+ };
1194
+ const failStart = (error2) => {
1195
+ process.stderr.write(
1196
+ `noir: workspace bridge: ${error2 instanceof Error ? error2.message : String(error2)}
1197
+ `
1198
+ );
1199
+ host.onerror?.(error2);
1200
+ closeDaemon();
1201
+ settle();
1202
+ };
1203
+ void daemon.start().catch(failStart);
1204
+ void host.start().catch(failStart);
1205
+ });
1206
+ }
1207
+ function toError(value) {
1208
+ return value instanceof Error ? value : new Error(String(value));
1209
+ }
1210
+ var HEALTH_PROBE_TIMEOUT_MS, StdioBridgeTransport;
1211
+ var init_workspace_bridge = __esm({
1212
+ "src/workspace-bridge.ts"() {
1213
+ "use strict";
1214
+ init_output();
1215
+ HEALTH_PROBE_TIMEOUT_MS = 1500;
1216
+ StdioBridgeTransport = class {
1217
+ constructor(stdin, stdout) {
1218
+ this.stdin = stdin;
1219
+ this.stdout = stdout;
1220
+ }
1221
+ stdin;
1222
+ stdout;
1223
+ onclose;
1224
+ onerror;
1225
+ onmessage;
1226
+ buffer = new ReadBuffer();
1227
+ started = false;
1228
+ closed = false;
1229
+ async start() {
1230
+ if (this.started) throw new Error("the stdio bridge transport is already started");
1231
+ this.started = true;
1232
+ this.stdin.on("data", this.onData);
1233
+ this.stdin.on("error", this.onStdinFault);
1234
+ this.stdin.on("end", this.onStdinEnd);
1235
+ this.stdout.on("error", this.onStdoutFault);
1236
+ }
1237
+ send(message) {
1238
+ if (this.closed) return Promise.reject(new Error("the stdio bridge transport is closed"));
1239
+ return new Promise((resolve3, reject) => {
1240
+ if (this.stdout.write(serializeMessage(message))) {
1241
+ resolve3();
1242
+ return;
1243
+ }
1244
+ const onDrain = () => {
1245
+ this.stdout.off("error", onWriteError);
1246
+ resolve3();
1247
+ };
1248
+ const onWriteError = (error2) => {
1249
+ this.stdout.off("drain", onDrain);
1250
+ reject(error2);
1251
+ };
1252
+ this.stdout.once("drain", onDrain);
1253
+ this.stdout.once("error", onWriteError);
1254
+ });
1255
+ }
1256
+ async close() {
1257
+ if (this.closed) return;
1258
+ this.closed = true;
1259
+ this.stdin.off("data", this.onData);
1260
+ this.stdin.off("error", this.onStdinFault);
1261
+ this.stdin.off("end", this.onStdinEnd);
1262
+ this.stdout.off("error", this.onStdoutFault);
1263
+ this.buffer.clear();
1264
+ if (this.stdin.listenerCount("data") === 0) this.stdin.pause();
1265
+ this.onclose?.();
1266
+ }
1267
+ onData = (chunk) => {
1268
+ try {
1269
+ this.buffer.append(chunk);
1270
+ for (; ; ) {
1271
+ const message = this.buffer.readMessage();
1272
+ if (message === null) return;
1273
+ this.onmessage?.(message);
1274
+ }
1275
+ } catch (error2) {
1276
+ this.onerror?.(toError(error2));
1277
+ void this.close();
1278
+ }
1279
+ };
1280
+ /** The host closed its end of the pipe — the session is over. */
1281
+ onStdinEnd = () => {
1282
+ void this.close();
1283
+ };
1284
+ onStdinFault = (error2) => {
1285
+ this.onerror?.(error2);
1286
+ void this.close();
1287
+ };
1288
+ onStdoutFault = (error2) => {
1289
+ this.onerror?.(error2);
1290
+ };
1291
+ };
1292
+ }
1293
+ });
1294
+
964
1295
  // src/workspace-mcp.ts
965
1296
  import { existsSync, readFileSync } from "fs";
966
- import { join } from "path";
967
- import { resolveAdapter } from "@noir-ai/adapters";
1297
+ import { mcpConfigPathFor, noirStdioArgs, TRANSPORT_KEYS } from "@noir-ai/adapters";
968
1298
  import { atomicWriteFile, resolveNoirCommand } from "@noir-ai/core";
969
- function mcpPathFor(root, host) {
970
- return resolveAdapter(host).mcpConfigPath?.({ root }) ?? join(root, ".mcp.json");
971
- }
972
1299
  function readJson(path) {
973
1300
  if (!existsSync(path)) return null;
974
1301
  return JSON.parse(readFileSync(path, "utf8"));
@@ -1002,26 +1329,37 @@ function isNoirEmitted(mcpServers) {
1002
1329
  if (typeof noir.command === "string") return true;
1003
1330
  return noir.type === "http";
1004
1331
  }
1005
- function writeWorkspaceHttpEntry(root, host, url, projectId, opts) {
1006
- const path = mcpPathFor(root, host);
1332
+ function noirEntry(mcpServers) {
1333
+ const noir = mcpServers?.noir;
1334
+ if (typeof noir !== "object" || noir === null || Array.isArray(noir)) return {};
1335
+ return noir;
1336
+ }
1337
+ function writeNoirTransport(root, host, transport, opts) {
1338
+ const path = mcpConfigPathFor(host, root);
1007
1339
  const existing = loadExisting(path, opts);
1340
+ const kept = Object.fromEntries(
1341
+ Object.entries(noirEntry(existing?.mcpServers)).filter(([key]) => !TRANSPORT_KEYS.has(key))
1342
+ );
1008
1343
  const next = {
1009
1344
  ...existing ?? {},
1010
1345
  mcpServers: {
1011
1346
  ...existing?.mcpServers ?? {},
1012
- noir: { type: "http", url: `${url}?p=${projectId}` }
1347
+ noir: { ...transport, ...kept }
1013
1348
  }
1014
1349
  };
1015
1350
  atomicWriteFile(path, `${JSON.stringify(next, null, 2)}
1016
1351
  `);
1017
1352
  }
1353
+ function writeWorkspaceEntry(root, host, name, opts) {
1354
+ writeNoirTransport(
1355
+ root,
1356
+ host,
1357
+ { command: resolveNoirCommand(), args: noirStdioArgs(name) },
1358
+ opts
1359
+ );
1360
+ }
1018
1361
  function writeStdioEntry(root, host, opts) {
1019
- const path = mcpPathFor(root, host);
1020
- const existing = loadExisting(path, opts);
1021
- const noir = { command: resolveNoirCommand(), args: ["mcp", "serve", "--stdio"] };
1022
- const next = { ...existing ?? {}, mcpServers: { ...existing?.mcpServers ?? {}, noir } };
1023
- atomicWriteFile(path, `${JSON.stringify(next, null, 2)}
1024
- `);
1362
+ writeNoirTransport(root, host, { command: resolveNoirCommand(), args: noirStdioArgs() }, opts);
1025
1363
  }
1026
1364
  var init_workspace_mcp = __esm({
1027
1365
  "src/workspace-mcp.ts"() {
@@ -1037,7 +1375,7 @@ import {
1037
1375
  ensureWorkspaceRegistry,
1038
1376
  isValidWorkspaceName,
1039
1377
  isWorkspaceMember,
1040
- loadProjectInfo as loadProjectInfo2,
1378
+ loadProjectInfo as loadProjectInfo3,
1041
1379
  readWorkspaceMarker as readWorkspaceMarker2,
1042
1380
  readWorkspaceRegistry,
1043
1381
  removeWorkspaceMember,
@@ -1048,20 +1386,20 @@ import {
1048
1386
  import {
1049
1387
  clearWorkspaceDaemonRecord,
1050
1388
  ensureWorkspaceDaemonRunning,
1051
- pidAlive as pidAlive2,
1052
- readWorkspaceDaemonRecord as readWorkspaceDaemonRecord2,
1389
+ pidAlive as pidAlive3,
1390
+ readWorkspaceDaemonRecord as readWorkspaceDaemonRecord3,
1053
1391
  spawnDetachedWorkspaceDaemon
1054
1392
  } from "@noir-ai/daemon";
1055
1393
  function loadProjectOrFail(opts) {
1056
1394
  try {
1057
- return loadProjectInfo2(process.cwd());
1395
+ return loadProjectInfo3(process.cwd());
1058
1396
  } catch {
1059
1397
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
1060
1398
  }
1061
1399
  }
1062
- function finishJoin(project, name, url, opts) {
1400
+ function finishJoin(project, name, opts) {
1063
1401
  writeWorkspaceMarker(project.root, name);
1064
- writeWorkspaceHttpEntry(project.root, project.config.host, url, project.id, opts);
1402
+ writeWorkspaceEntry(project.root, project.config.host, name, opts);
1065
1403
  }
1066
1404
  async function daemonStartWorkspace(opts) {
1067
1405
  const project = loadProjectOrFail(opts);
@@ -1094,7 +1432,7 @@ async function daemonStartWorkspace(opts) {
1094
1432
  }
1095
1433
  if (opts.detach === true) {
1096
1434
  const spawned = await spawnDetachedWorkspaceDaemon({ name: opts.name, project });
1097
- finishJoin(project, opts.name, `http://127.0.0.1:${spawned.port}/mcp`, opts);
1435
+ finishJoin(project, opts.name, opts);
1098
1436
  if (opts.json === true) {
1099
1437
  process.stdout.write(
1100
1438
  `${JSON.stringify({ ok: true, data: { mode: "detached", pid: spawned.pid, port: spawned.port } })}
@@ -1113,7 +1451,7 @@ async function daemonStartWorkspace(opts) {
1113
1451
  project,
1114
1452
  idleTimeoutSec: project.config.workspace.idleTimeoutSec
1115
1453
  });
1116
- finishJoin(project, opts.name, ensured.url, opts);
1454
+ finishJoin(project, opts.name, opts);
1117
1455
  if (opts.json === true) {
1118
1456
  process.stdout.write(
1119
1457
  `${JSON.stringify({ ok: true, data: { url: ensured.url, port: ensured.port, reused: !ensured.started } })}
@@ -1143,7 +1481,7 @@ async function daemonJoin(opts) {
1143
1481
  idleTimeoutSec: project.config.workspace.idleTimeoutSec
1144
1482
  });
1145
1483
  upsertWorkspaceMember(reg, { projectId: project.id, root: project.root, joinedAt: Date.now() });
1146
- finishJoin(project, opts.name, ensured.url, opts);
1484
+ finishJoin(project, opts.name, opts);
1147
1485
  if (opts.json === true) {
1148
1486
  process.stdout.write(
1149
1487
  `${JSON.stringify({ ok: true, data: { joined: true, url: ensured.url, port: ensured.port } })}
@@ -1160,19 +1498,20 @@ async function workspaceList(opts) {
1160
1498
  } catch {
1161
1499
  names = [];
1162
1500
  }
1163
- const rows = names.map((n) => {
1164
- const reg = readWorkspaceRegistry(n);
1165
- return {
1166
- name: n,
1167
- members: reg?.members.length ?? 0,
1168
- // A record file whose pid is dead (daemon killed without cleanup) must not
1169
- // read as "running" — mirror the pidAlive liveness `workspace status` uses.
1170
- running: (() => {
1171
- const rec = readWorkspaceDaemonRecord2(n);
1172
- return rec !== null && pidAlive2(rec.pid);
1173
- })()
1174
- };
1175
- });
1501
+ const rows = await Promise.all(
1502
+ names.map(async (n) => {
1503
+ const reg = readWorkspaceRegistry(n);
1504
+ const rec = readWorkspaceDaemonRecord3(n);
1505
+ const health = rec === null ? null : await verifyWorkspaceDaemon(rec, n);
1506
+ const running = health?.kind === "healthy";
1507
+ return {
1508
+ name: n,
1509
+ members: reg?.members.length ?? 0,
1510
+ running,
1511
+ ...rec !== null && !running ? { stale: true } : {}
1512
+ };
1513
+ })
1514
+ );
1176
1515
  if (opts.json === true) {
1177
1516
  process.stdout.write(`${JSON.stringify({ ok: true, data: { workspaces: rows } })}
1178
1517
  `);
@@ -1183,7 +1522,8 @@ async function workspaceList(opts) {
1183
1522
  return;
1184
1523
  }
1185
1524
  for (const r of rows) {
1186
- log(`${r.name} \u2014 ${r.members} member(s)${r.running ? ", running" : ""}`, opts);
1525
+ const state = r.running ? ", running" : r.stale ? ", not running (stale record)" : "";
1526
+ log(`${r.name} \u2014 ${r.members} member(s)${state}`, opts);
1187
1527
  }
1188
1528
  }
1189
1529
  async function workspaceStatus(opts) {
@@ -1195,23 +1535,23 @@ async function workspaceStatus(opts) {
1195
1535
  fail(EXIT.USAGE, `invalid workspace name ${JSON.stringify(name)}.`, opts);
1196
1536
  }
1197
1537
  const reg = readWorkspaceRegistry(name);
1198
- const rec = readWorkspaceDaemonRecord2(name);
1199
- const running = rec !== null && pidAlive2(rec.pid);
1538
+ const rec = readWorkspaceDaemonRecord3(name);
1539
+ const health = rec === null ? null : await verifyWorkspaceDaemon(rec, name);
1540
+ const running = health?.kind === "healthy";
1200
1541
  const data = {
1201
1542
  name,
1202
1543
  members: reg?.members.map((m) => m.projectId) ?? [],
1203
1544
  running,
1204
- ...rec ? { pid: rec.pid, port: rec.port } : {}
1545
+ ...rec ? { pid: rec.pid, port: rec.port } : {},
1546
+ ...rec !== null && !running ? { stale: true } : {}
1205
1547
  };
1206
1548
  if (opts.json === true) {
1207
1549
  process.stdout.write(`${JSON.stringify({ ok: true, data })}
1208
1550
  `);
1209
1551
  return;
1210
1552
  }
1211
- log(
1212
- `workspace ${name}: ${running ? `running (pid ${rec?.pid}, port ${rec?.port})` : "not running"}`,
1213
- opts
1214
- );
1553
+ const state = running ? `running (pid ${rec?.pid}, port ${rec?.port})` : rec !== null ? `not running (stale record for pid ${rec.pid})` : "not running";
1554
+ log(`workspace ${name}: ${state}`, opts);
1215
1555
  for (const m of data.members) log(` member: ${m}`, opts);
1216
1556
  }
1217
1557
  async function workspaceLeave(opts) {
@@ -1241,8 +1581,8 @@ async function workspaceStop(opts) {
1241
1581
  if (!isValidWorkspaceName(name)) {
1242
1582
  fail(EXIT.USAGE, `invalid workspace name ${JSON.stringify(name)}.`, opts);
1243
1583
  }
1244
- const rec = readWorkspaceDaemonRecord2(name);
1245
- if (rec === null || !pidAlive2(rec.pid)) {
1584
+ const rec = readWorkspaceDaemonRecord3(name);
1585
+ if (rec === null || !pidAlive3(rec.pid)) {
1246
1586
  if (opts.json === true) {
1247
1587
  process.stdout.write(
1248
1588
  `${JSON.stringify({ ok: true, data: { running: false, stopped: false } })}
@@ -1253,6 +1593,10 @@ async function workspaceStop(opts) {
1253
1593
  info("workspace daemon is not running.", opts);
1254
1594
  return;
1255
1595
  }
1596
+ const health = await verifyWorkspaceDaemon(rec, name);
1597
+ if (health.kind !== "healthy") {
1598
+ fail(EXIT.ERROR, stopRefusal(name, rec, health), opts);
1599
+ }
1256
1600
  try {
1257
1601
  process.kill(rec.pid, "SIGTERM");
1258
1602
  } catch {
@@ -1267,21 +1611,28 @@ async function workspaceStop(opts) {
1267
1611
  }
1268
1612
  info(`stopped workspace daemon (pid ${rec.pid}).`, opts);
1269
1613
  }
1614
+ function stopRefusal(name, rec, health) {
1615
+ if (health.kind === "foreign") {
1616
+ 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.`;
1617
+ }
1618
+ 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.`;
1619
+ }
1270
1620
  var init_workspace = __esm({
1271
1621
  "src/commands/workspace.ts"() {
1272
1622
  "use strict";
1273
1623
  init_output();
1624
+ init_workspace_bridge();
1274
1625
  init_workspace_mcp();
1275
1626
  }
1276
1627
  });
1277
1628
 
1278
1629
  // src/commands/daemon.ts
1279
- import { loadProjectInfo as loadProjectInfo3 } from "@noir-ai/core";
1630
+ import { loadProjectInfo as loadProjectInfo4 } from "@noir-ai/core";
1280
1631
  import {
1281
1632
  clearProjectDaemonRecord,
1282
1633
  ensureDaemonRunning as ensureDaemonRunning2,
1283
- pidAlive as pidAlive3,
1284
- readDaemonToken as readDaemonToken2,
1634
+ pidAlive as pidAlive4,
1635
+ readDaemonToken as readDaemonToken3,
1285
1636
  readProjectDaemonRecord as readProjectDaemonRecord2,
1286
1637
  spawnDetachedDaemon
1287
1638
  } from "@noir-ai/daemon";
@@ -1293,7 +1644,7 @@ function formatUptime(sec) {
1293
1644
  }
1294
1645
  function resolveCallerProjectId() {
1295
1646
  try {
1296
- return loadProjectInfo3(process.cwd()).id;
1647
+ return loadProjectInfo4(process.cwd()).id;
1297
1648
  } catch {
1298
1649
  return void 0;
1299
1650
  }
@@ -1325,7 +1676,7 @@ async function daemonStart(opts) {
1325
1676
  }
1326
1677
  let project;
1327
1678
  try {
1328
- project = loadProjectInfo3(process.cwd());
1679
+ project = loadProjectInfo4(process.cwd());
1329
1680
  } catch {
1330
1681
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
1331
1682
  }
@@ -1357,7 +1708,7 @@ async function daemonStart(opts) {
1357
1708
  }
1358
1709
  if (opts.detach === true) {
1359
1710
  const existing = readProjectDaemonRecord2(project.id);
1360
- if (existing && pidAlive3(existing.pid) && await isHealthy(existing.port, existing.pid, project.id)) {
1711
+ if (existing && pidAlive4(existing.pid) && await isHealthy(existing.port, existing.pid, project.id)) {
1361
1712
  if (opts.json === true) {
1362
1713
  process.stdout.write(
1363
1714
  `${JSON.stringify({ ok: true, data: { mode: "detached", reused: true } })}
@@ -1467,7 +1818,7 @@ async function daemonStop(opts) {
1467
1818
  const ds = spinner(`Stopping daemon (pid ${rec.pid})...`, opts).start();
1468
1819
  let signalled = false;
1469
1820
  let errMsg2;
1470
- const healthy = pidAlive3(rec.pid) && await isHealthy(rec.port, rec.pid, callerProject);
1821
+ const healthy = pidAlive4(rec.pid) && await isHealthy(rec.port, rec.pid, callerProject);
1471
1822
  if (healthy) {
1472
1823
  try {
1473
1824
  process.kill(rec.pid, "SIGTERM");
@@ -1509,7 +1860,7 @@ async function daemonStatus(opts) {
1509
1860
  if (!rec) {
1510
1861
  fail(EXIT.DAEMON_DOWN, "Noir daemon is not running (start with `noir daemon start`).", opts);
1511
1862
  }
1512
- if (!pidAlive3(rec.pid)) {
1863
+ if (!pidAlive4(rec.pid)) {
1513
1864
  clearProjectDaemonRecord(callerProject);
1514
1865
  fail(EXIT.DAEMON_DOWN, "Noir daemon is not running (stale record removed).", opts);
1515
1866
  }
@@ -1571,14 +1922,14 @@ async function daemonToken(opts) {
1571
1922
  opts
1572
1923
  );
1573
1924
  }
1574
- if (!pidAlive3(rec.pid) || !await isHealthy(rec.port, rec.pid, callerProject)) {
1925
+ if (!pidAlive4(rec.pid) || !await isHealthy(rec.port, rec.pid, callerProject)) {
1575
1926
  fail(
1576
1927
  EXIT.DAEMON_DOWN,
1577
1928
  "Noir daemon is not running (stale record \u2014 start it with `noir daemon start`).",
1578
1929
  opts
1579
1930
  );
1580
1931
  }
1581
- const token = readDaemonToken2(callerProject);
1932
+ const token = readDaemonToken3(callerProject);
1582
1933
  if (token === null) {
1583
1934
  fail(
1584
1935
  EXIT.DAEMON_DOWN,
@@ -1609,17 +1960,182 @@ var init_daemon = __esm({
1609
1960
  }
1610
1961
  });
1611
1962
 
1963
+ // src/hygiene-scan.ts
1964
+ import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
1965
+ import { join } from "path";
1966
+ import { checkHygiene } from "@noir-ai/skills";
1967
+ function readDirEntries(dir) {
1968
+ try {
1969
+ return readdirSync2(dir, { withFileTypes: true });
1970
+ } catch {
1971
+ return [];
1972
+ }
1973
+ }
1974
+ function isDirectory(path) {
1975
+ try {
1976
+ return statSync(path).isDirectory();
1977
+ } catch {
1978
+ return false;
1979
+ }
1980
+ }
1981
+ function hygieneKindOf(path) {
1982
+ if (HYGIENE_MARKDOWN_EXTENSIONS.test(path)) return "markdown";
1983
+ if (HYGIENE_CODE_EXTENSIONS.test(path)) return "code";
1984
+ return null;
1985
+ }
1986
+ function hasScannableLayout(root) {
1987
+ if (isDirectory(join(root, "docs")) || isDirectory(join(root, "scripts"))) return true;
1988
+ for (const entry of readDirEntries(join(root, "packages"))) {
1989
+ if (!entry.isDirectory()) continue;
1990
+ if (isDirectory(join(root, "packages", entry.name, "src"))) return true;
1991
+ if (isDirectory(join(root, "packages", entry.name, "test"))) return true;
1992
+ }
1993
+ return false;
1994
+ }
1995
+ function addHygieneFile(rel, walk) {
1996
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
1997
+ if (name.startsWith(".") || HYGIENE_EXCLUDED_FILES.has(name)) return;
1998
+ const kind = hygieneKindOf(rel);
1999
+ if (kind === null) return;
2000
+ if (walk.files.length >= HYGIENE_MAX_FILES) {
2001
+ walk.truncated = true;
2002
+ return;
2003
+ }
2004
+ walk.files.push({ path: rel, kind });
2005
+ }
2006
+ function walkScannable(root, relDir, skip, walk) {
2007
+ if (walk.truncated) return;
2008
+ for (const entry of readDirEntries(join(root, relDir))) {
2009
+ if (walk.truncated) return;
2010
+ const rel = `${relDir}/${entry.name}`;
2011
+ if (entry.isDirectory()) {
2012
+ if (HYGIENE_SKIP_DIRS.has(entry.name) || entry.name.startsWith(".") || skip.has(rel)) {
2013
+ continue;
2014
+ }
2015
+ walkScannable(root, rel, skip, walk);
2016
+ } else if (entry.isFile()) {
2017
+ addHygieneFile(rel, walk);
2018
+ }
2019
+ }
2020
+ }
2021
+ function collectHygieneFiles(root) {
2022
+ const walk = { files: [], truncated: false };
2023
+ for (const entry of readDirEntries(root)) {
2024
+ if (entry.isFile()) addHygieneFile(entry.name, walk);
2025
+ }
2026
+ walkScannable(root, "docs", HYGIENE_EXCLUDED_DIRS, walk);
2027
+ walkScannable(root, ".claude/skills", NO_SKIP_DIRS, walk);
2028
+ for (const entry of readDirEntries(join(root, "packages"))) {
2029
+ if (!entry.isDirectory()) continue;
2030
+ walkScannable(root, `packages/${entry.name}/src`, NO_SKIP_DIRS, walk);
2031
+ walkScannable(root, `packages/${entry.name}/test`, NO_SKIP_DIRS, walk);
2032
+ }
2033
+ walkScannable(root, "scripts", NO_SKIP_DIRS, walk);
2034
+ walk.files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
2035
+ return walk;
2036
+ }
2037
+ function hygieneOmissions(result) {
2038
+ const notes = [];
2039
+ if (result.skipped > 0) {
2040
+ notes.push(
2041
+ `${result.skipped} file${result.skipped === 1 ? "" : "s"} over ${HYGIENE_MAX_FILE_BYTES / 1024} KiB skipped`
2042
+ );
2043
+ }
2044
+ if (result.truncated) notes.push(`stopped at ${HYGIENE_MAX_FILES} files`);
2045
+ return notes.length > 0 ? ` (${notes.join("; ")})` : "";
2046
+ }
2047
+ function hygieneCounts(result) {
2048
+ const omitted = hygieneOmissions(result);
2049
+ if (result.scanned === 0) {
2050
+ return `nothing to scan (no packages/*/src, packages/*/test, scripts/ or documents found)${omitted}`;
2051
+ }
2052
+ if (result.findings.length === 0) {
2053
+ return `clean \u2014 ${result.scanned} file${result.scanned === 1 ? "" : "s"} scanned${omitted}`;
2054
+ }
2055
+ const files = new Set(result.findings.map((f) => f.path)).size;
2056
+ return `${result.fail} fail, ${result.warn} warn in ${files} file${files === 1 ? "" : "s"}${omitted}`;
2057
+ }
2058
+ function hygieneDetail(result) {
2059
+ const counts = hygieneCounts(result);
2060
+ if (result.scanned === 0 || result.findings.length === 0) return counts;
2061
+ const named = result.findings.slice(0, HYGIENE_FINDING_CAP);
2062
+ const hidden = result.findings.length - named.length;
2063
+ const where = named.map((f) => `${f.path}:${f.line} ${f.id}`).join("; ");
2064
+ return `${counts} \u2014 ${where}${hidden > 0 ? ` (+${hidden} more)` : ""}`;
2065
+ }
2066
+ function scanOutputHygiene(root) {
2067
+ const empty = {
2068
+ scanned: 0,
2069
+ skipped: 0,
2070
+ truncated: false,
2071
+ findings: [],
2072
+ fail: 0,
2073
+ warn: 0
2074
+ };
2075
+ if (!hasScannableLayout(root)) return empty;
2076
+ const walk = collectHygieneFiles(root);
2077
+ const findings = [];
2078
+ let scanned = 0;
2079
+ let skipped = 0;
2080
+ for (const file of walk.files) {
2081
+ const abs = join(root, file.path);
2082
+ try {
2083
+ if (statSync(abs).size > HYGIENE_MAX_FILE_BYTES) {
2084
+ skipped++;
2085
+ continue;
2086
+ }
2087
+ const text = readFileSync2(abs, "utf8");
2088
+ scanned++;
2089
+ for (const found of checkHygiene(text, file.kind)) {
2090
+ findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });
2091
+ }
2092
+ } catch {
2093
+ }
2094
+ }
2095
+ findings.sort((a, b) => {
2096
+ if (a.tier !== b.tier) return a.tier === "fail" ? -1 : 1;
2097
+ if (a.path !== b.path) return a.path < b.path ? -1 : 1;
2098
+ return a.line - b.line;
2099
+ });
2100
+ const fail2 = findings.filter((f) => f.tier === "fail").length;
2101
+ return {
2102
+ scanned,
2103
+ skipped,
2104
+ truncated: walk.truncated,
2105
+ findings,
2106
+ fail: fail2,
2107
+ warn: findings.length - fail2
2108
+ };
2109
+ }
2110
+ var HYGIENE_FINDING_CAP, HYGIENE_MAX_FILE_BYTES, HYGIENE_MAX_FILES, HYGIENE_CODE_EXTENSIONS, HYGIENE_MARKDOWN_EXTENSIONS, HYGIENE_SKIP_DIRS, HYGIENE_EXCLUDED_DIRS, HYGIENE_EXCLUDED_FILES, NO_SKIP_DIRS;
2111
+ var init_hygiene_scan = __esm({
2112
+ "src/hygiene-scan.ts"() {
2113
+ "use strict";
2114
+ HYGIENE_FINDING_CAP = 5;
2115
+ HYGIENE_MAX_FILE_BYTES = 512 * 1024;
2116
+ HYGIENE_MAX_FILES = 2e3;
2117
+ HYGIENE_CODE_EXTENSIONS = /\.(?:ts|tsx|js|jsx|mjs|cjs|sh)$/i;
2118
+ HYGIENE_MARKDOWN_EXTENSIONS = /\.(?:md|mdx)$/i;
2119
+ HYGIENE_SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage"]);
2120
+ HYGIENE_EXCLUDED_DIRS = /* @__PURE__ */ new Set(["docs/internal", "docs/decisions", "docs/roadmap"]);
2121
+ HYGIENE_EXCLUDED_FILES = /* @__PURE__ */ new Set(["CHANGELOG.md"]);
2122
+ NO_SKIP_DIRS = /* @__PURE__ */ new Set();
2123
+ }
2124
+ });
2125
+
1612
2126
  // src/commands/doctor.ts
1613
2127
  import { spawnSync } from "child_process";
1614
- import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
2128
+ import { existsSync as existsSync2, readdirSync as readdirSync3, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
1615
2129
  import { dirname, join as join2, relative } from "path";
1616
2130
  import { fileURLToPath } from "url";
1617
- import { resolveAdapter as resolveAdapter2 } from "@noir-ai/adapters";
2131
+ import { mcpConfigPathFor as mcpConfigPathFor2, resolveAdapter } from "@noir-ai/adapters";
1618
2132
  import {
1619
2133
  detectActiveMethod,
2134
+ ensureOwnerOnly,
2135
+ ensureOwnerOnlyDir,
1620
2136
  latestVersionFromCache,
1621
2137
  loadNoirEnv,
1622
- loadProjectInfo as loadProjectInfo4,
2138
+ loadProjectInfo as loadProjectInfo5,
1623
2139
  NOIR_VERSION as NOIR_VERSION2,
1624
2140
  paths,
1625
2141
  readInstallRecord,
@@ -1632,7 +2148,7 @@ import {
1632
2148
  readScaffoldVersion,
1633
2149
  render
1634
2150
  } from "@noir-ai/create";
1635
- import { pidAlive as pidAlive4, readProjectDaemonRecord as readProjectDaemonRecord3 } from "@noir-ai/daemon";
2151
+ import { pidAlive as pidAlive5, readProjectDaemonRecord as readProjectDaemonRecord3 } from "@noir-ai/daemon";
1636
2152
  import { resolveModelConfig } from "@noir-ai/model";
1637
2153
  async function probeOnnx() {
1638
2154
  try {
@@ -1648,7 +2164,7 @@ function describeOnnx(o) {
1648
2164
  if (/Cannot find (module|package)/i.test(o.reason)) {
1649
2165
  return "not resolvable from CLI probe (best-effort)";
1650
2166
  }
1651
- const r = o.reason.length > 60 ? `${o.reason.slice(0, 59)}\u2026` : o.reason;
2167
+ const r = truncateToWidth(o.reason, 60);
1652
2168
  return `probe failed: ${r}`;
1653
2169
  }
1654
2170
  function fmtUptime(sec) {
@@ -1680,7 +2196,7 @@ function checkConfig(root) {
1680
2196
  };
1681
2197
  }
1682
2198
  try {
1683
- const project = loadProjectInfo4(root);
2199
+ const project = loadProjectInfo5(root);
1684
2200
  return {
1685
2201
  project,
1686
2202
  result: {
@@ -1714,7 +2230,7 @@ async function checkDaemon(checks, project) {
1714
2230
  });
1715
2231
  return;
1716
2232
  }
1717
- if (!pidAlive4(rec.pid)) {
2233
+ if (!pidAlive5(rec.pid)) {
1718
2234
  checks.push({
1719
2235
  name: "daemon",
1720
2236
  status: "warn",
@@ -1797,11 +2313,21 @@ async function checkStore(checks, project, root) {
1797
2313
  }
1798
2314
  }
1799
2315
  }
2316
+ function envModeCause(st) {
2317
+ return st.ctimeMs > st.mtimeMs ? "changed" : "legacy";
2318
+ }
2319
+ function octalMode(mode) {
2320
+ return mode.toString(8).padStart(4, "0");
2321
+ }
2322
+ function describeEnvMode(cause, mode) {
2323
+ const lead = `is ${octalMode(mode)} \u2014 accessible to other accounts; run \`noir sync\` (or \`chmod 600 .noir/.env\`) to make it owner-only.`;
2324
+ 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.`;
2325
+ }
1800
2326
  function checkNoirEnv(checks, root, loaded) {
1801
2327
  const path = join2(paths.noirDir(root), ".env");
1802
2328
  let st;
1803
2329
  try {
1804
- st = statSync(path);
2330
+ st = statSync2(path);
1805
2331
  } catch {
1806
2332
  return;
1807
2333
  }
@@ -1819,8 +2345,8 @@ function checkNoirEnv(checks, root, loaded) {
1819
2345
  tooOpen ? {
1820
2346
  name: "noir-env",
1821
2347
  status: "warn",
1822
- detail: `${path} is ${mode.toString(8)} \u2014 readable by others; run chmod 600`
1823
- } : { name: "noir-env", status: "ok", detail: `${path} permissions ok (${mode.toString(8)})` }
2348
+ detail: `${path} ${describeEnvMode(envModeCause(st), mode)}`
2349
+ } : { name: "noir-env", status: "ok", detail: `${path} permissions ok (${octalMode(mode)})` }
1824
2350
  );
1825
2351
  for (const [key, source] of Object.entries(loaded.sources)) {
1826
2352
  if (source !== "file") continue;
@@ -1920,7 +2446,7 @@ function checkSeedDrift(checks, root) {
1920
2446
  const seedPath = join2(root, ".noir", ".env.example");
1921
2447
  let bytes;
1922
2448
  try {
1923
- bytes = readFileSync2(seedPath, "utf8");
2449
+ bytes = readFileSync3(seedPath, "utf8");
1924
2450
  } catch {
1925
2451
  return;
1926
2452
  }
@@ -1938,6 +2464,14 @@ function checkRulesMdBudget(checks, root, project) {
1938
2464
  checks.push({ name: "rules budget", status: "warn", detail: "skipped \u2014 not initialized" });
1939
2465
  return null;
1940
2466
  }
2467
+ if (project.config.rules.enabled === false) {
2468
+ checks.push({
2469
+ name: "rules budget",
2470
+ status: "ok",
2471
+ detail: "disabled \u2014 rules.enabled is false in .noir/config.yml (RULES.md is not measured)"
2472
+ });
2473
+ return null;
2474
+ }
1941
2475
  const rulesPath = paths.rulesMd(root);
1942
2476
  if (!existsSync2(rulesPath)) {
1943
2477
  checks.push({
@@ -1949,7 +2483,7 @@ function checkRulesMdBudget(checks, root, project) {
1949
2483
  }
1950
2484
  let content;
1951
2485
  try {
1952
- content = readFileSync2(rulesPath, "utf8");
2486
+ content = readFileSync3(rulesPath, "utf8");
1953
2487
  } catch (e) {
1954
2488
  checks.push({
1955
2489
  name: "rules budget",
@@ -1993,7 +2527,7 @@ function checkHostArtifacts(checks, root, project) {
1993
2527
  return null;
1994
2528
  }
1995
2529
  const host = project.config.host;
1996
- const adapter = resolveAdapter2(host);
2530
+ const adapter = resolveAdapter(host);
1997
2531
  const ectx = { root };
1998
2532
  const expected = [];
1999
2533
  const emitsAgentsMd = host === "agents-md" || host === "cursor" || host === "opencode";
@@ -2002,7 +2536,7 @@ function checkHostArtifacts(checks, root, project) {
2002
2536
  }
2003
2537
  if (host === "claude") expected.push("CLAUDE.md");
2004
2538
  else if (host === "gemini") expected.push("GEMINI.md");
2005
- expected.push(relOr(adapter.mcpConfigPath?.(ectx) ?? ".mcp.json", root));
2539
+ expected.push(relOr(mcpConfigPathFor2(host, root), root));
2006
2540
  const missing = expected.filter((p) => !existsSync2(joinRoot(root, p)));
2007
2541
  const status2 = missing.length === 0 ? "ok" : "warn";
2008
2542
  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)`;
@@ -2029,6 +2563,15 @@ function checkNestedNoir(checks, root) {
2029
2563
  });
2030
2564
  return { detected, paths: found };
2031
2565
  }
2566
+ function checkOutputHygiene(checks, root) {
2567
+ const result = scanOutputHygiene(root);
2568
+ checks.push({
2569
+ name: "output hygiene",
2570
+ status: result.fail > 0 ? "fail" : result.warn > 0 ? "warn" : "ok",
2571
+ detail: hygieneDetail(result)
2572
+ });
2573
+ return result;
2574
+ }
2032
2575
  function collectDedupCandidates(root) {
2033
2576
  const rels = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".noir/rules/RULES.md"];
2034
2577
  const out = [];
@@ -2037,7 +2580,7 @@ function collectDedupCandidates(root) {
2037
2580
  if (!existsSync2(abs)) continue;
2038
2581
  let text;
2039
2582
  try {
2040
- text = readFileSync2(abs, "utf8");
2583
+ text = readFileSync3(abs, "utf8");
2041
2584
  } catch {
2042
2585
  continue;
2043
2586
  }
@@ -2097,7 +2640,7 @@ function resolveWorkspacePackagesDir() {
2097
2640
  const pkgPath = join2(here, "package.json");
2098
2641
  if (existsSync2(pkgPath)) {
2099
2642
  try {
2100
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf8"));
2643
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
2101
2644
  if (pkg.name === "@noir-ai/cli") {
2102
2645
  const packagesDir = dirname(here);
2103
2646
  if (existsSync2(join2(dirname(packagesDir), "pnpm-workspace.yaml"))) {
@@ -2145,7 +2688,7 @@ function checkPublish(checks, packagesDir, opts = {}) {
2145
2688
  }
2146
2689
  let entries = [];
2147
2690
  try {
2148
- entries = readdirSync2(packagesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
2691
+ entries = readdirSync3(packagesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
2149
2692
  } catch (e) {
2150
2693
  checks.push({
2151
2694
  name: "publish",
@@ -2162,7 +2705,7 @@ function checkPublish(checks, packagesDir, opts = {}) {
2162
2705
  if (!existsSync2(pkgPath)) continue;
2163
2706
  let pkg;
2164
2707
  try {
2165
- pkg = JSON.parse(readFileSync2(pkgPath, "utf8"));
2708
+ pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
2166
2709
  } catch (e) {
2167
2710
  issues.push(`${name}/package.json: unparseable (${msg(e)})`);
2168
2711
  checked++;
@@ -2241,6 +2784,17 @@ function renderHuman(payload, opts) {
2241
2784
  ["Check", "Status", "Detail"],
2242
2785
  opts
2243
2786
  );
2787
+ if (payload.fixes) {
2788
+ const healed = payload.fixes.filter((f) => f.outcome === "healed");
2789
+ if (healed.length > 0) {
2790
+ log(
2791
+ `fixed ${healed.length} permission${healed.length === 1 ? "" : "s"}: ${healed.map((f) => f.path).join(", ")}`,
2792
+ opts
2793
+ );
2794
+ } else {
2795
+ log("nothing to fix \u2014 permissions already owner-only", opts);
2796
+ }
2797
+ }
2244
2798
  const { ok, warn: warnN, fail: fail2 } = payload.summary;
2245
2799
  if (fail2 > 0) {
2246
2800
  error(
@@ -2253,12 +2807,24 @@ function renderHuman(payload, opts) {
2253
2807
  success(`all ${ok} check${ok === 1 ? "" : "s"} passed`, opts);
2254
2808
  }
2255
2809
  }
2810
+ function fixOwnerOnlyPermissions(root, project) {
2811
+ const fixes = [
2812
+ { path: ".noir/.env", outcome: ensureOwnerOnly(join2(paths.noirDir(root), ".env")) }
2813
+ ];
2814
+ if (project) {
2815
+ const dbRel = `.noir/store/${project.id}.db`;
2816
+ fixes.push({ path: dbRel, outcome: ensureOwnerOnly(join2(root, dbRel)) });
2817
+ fixes.push({ path: ".noir/store", outcome: ensureOwnerOnlyDir(paths.storeDir(root)) });
2818
+ }
2819
+ return fixes;
2820
+ }
2256
2821
  async function doctor(opts = {}) {
2257
2822
  const root = process.cwd();
2258
2823
  const checks = [];
2259
2824
  await checkRuntime(checks);
2260
2825
  const { project, result: configResult } = checkConfig(root);
2261
2826
  checks.push(configResult);
2827
+ const fixes = opts.fix === true ? fixOwnerOnlyPermissions(root, project) : void 0;
2262
2828
  await checkDaemon(checks, project);
2263
2829
  const { vecOk } = await checkNativeDeps(checks);
2264
2830
  await checkStore(checks, project, root);
@@ -2271,6 +2837,7 @@ async function doctor(opts = {}) {
2271
2837
  const rules = checkRulesMdBudget(checks, root, project);
2272
2838
  const host = checkHostArtifacts(checks, root, project);
2273
2839
  checkNestedNoir(checks, root);
2840
+ checkOutputHygiene(checks, root);
2274
2841
  if (opts.dedup === true) {
2275
2842
  await checkSemanticDupDoctor(checks, root, project);
2276
2843
  }
@@ -2294,7 +2861,8 @@ async function doctor(opts = {}) {
2294
2861
  method: installCheck.method,
2295
2862
  installedVersion: installCheck.installedVersion,
2296
2863
  latestKnown: installCheck.latestKnown
2297
- }
2864
+ },
2865
+ ...fixes ? { fixes } : {}
2298
2866
  };
2299
2867
  if (opts.json === true) {
2300
2868
  process.stdout.write(`${JSON.stringify({ ok: true, data: payload })}
@@ -2314,26 +2882,28 @@ var init_doctor = __esm({
2314
2882
  "src/commands/doctor.ts"() {
2315
2883
  "use strict";
2316
2884
  init_daemon_client();
2885
+ init_hygiene_scan();
2317
2886
  init_output();
2318
2887
  init_theme();
2888
+ init_width();
2319
2889
  ENV_REFUSAL_PREFIX = ".noir/.env: refusing to load";
2320
2890
  SEMVER_ISH = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
2321
2891
  }
2322
2892
  });
2323
2893
 
2324
2894
  // src/commands/env.ts
2325
- import { loadNoirEnv as loadNoirEnv2, loadProjectInfo as loadProjectInfo5 } from "@noir-ai/core";
2895
+ import { loadNoirEnv as loadNoirEnv2, loadProjectInfo as loadProjectInfo6 } from "@noir-ai/core";
2326
2896
  import { resolveModelConfig as resolveModelConfig2 } from "@noir-ai/model";
2327
2897
  function captureAmbientEnv(env2 = process.env) {
2328
2898
  ambientBaseline = { ...env2 };
2329
2899
  }
2330
2900
  function redactShape(value) {
2331
2901
  if (value.length <= SHORT_VALUE_MAX) return `\u2026(${value.length})`;
2332
- return `${value.slice(0, 3)}\u2026(${value.length})`;
2902
+ return `${truncateToWidth(value, 3, "")}\u2026(${value.length})`;
2333
2903
  }
2334
2904
  function configApiKeyEnvNames(root) {
2335
2905
  try {
2336
- const project = loadProjectInfo5(root);
2906
+ const project = loadProjectInfo6(root);
2337
2907
  const names = /* @__PURE__ */ new Set();
2338
2908
  for (const provider of Object.values(resolveModelConfig2(project.config.model).providers)) {
2339
2909
  if (provider.apiKeyEnv !== void 0 && provider.apiKeyEnv.length > 0) {
@@ -2402,6 +2972,7 @@ var init_env = __esm({
2402
2972
  "src/commands/env.ts"() {
2403
2973
  "use strict";
2404
2974
  init_output();
2975
+ init_width();
2405
2976
  CURATED_AMBIENT_KEYS = [
2406
2977
  "CLICKUP_API_TOKEN",
2407
2978
  "OPENAI_API_KEY",
@@ -2432,7 +3003,7 @@ import {
2432
3003
  detectActiveMethod as detectActiveMethod2,
2433
3004
  detectInstallMethods,
2434
3005
  ensureShimExecutable,
2435
- loadProjectInfo as loadProjectInfo6,
3006
+ loadProjectInfo as loadProjectInfo7,
2436
3007
  MANAGED_NODE_VERSION,
2437
3008
  NOIR_VERSION as NOIR_VERSION3,
2438
3009
  noirHome,
@@ -2571,7 +3142,7 @@ async function install(opts = {}) {
2571
3142
  const installedRecord = readInstallRecord2();
2572
3143
  let minVersion = "1.6.0";
2573
3144
  try {
2574
- minVersion = loadProjectInfo6(process.cwd()).config.update.minVersion;
3145
+ minVersion = loadProjectInfo7(process.cwd()).config.update.minVersion;
2575
3146
  } catch {
2576
3147
  }
2577
3148
  const plan = buildMigrationPlan({
@@ -2648,7 +3219,7 @@ import {
2648
3219
  detectActiveMethod as detectActiveMethod3,
2649
3220
  ensureShimExecutable as ensureShimExecutable2,
2650
3221
  fetchLatestVersion,
2651
- loadProjectInfo as loadProjectInfo7,
3222
+ loadProjectInfo as loadProjectInfo8,
2652
3223
  readInstallRecord as readInstallRecord3,
2653
3224
  readUpdateCache as readUpdateCache2,
2654
3225
  runManagerCmd as runManagerCmd2,
@@ -2728,7 +3299,7 @@ async function update(opts = {}) {
2728
3299
  fetchSpin.succeed(`Latest: ${latest}`);
2729
3300
  let minVersion = DEFAULT_UPDATE_CONFIG.minVersion;
2730
3301
  try {
2731
- minVersion = loadProjectInfo7(process.cwd()).config.update.minVersion;
3302
+ minVersion = loadProjectInfo8(process.cwd()).config.update.minVersion;
2732
3303
  } catch {
2733
3304
  }
2734
3305
  const target = buildUpdateTarget({
@@ -2878,7 +3449,7 @@ var init_update = __esm({
2878
3449
  });
2879
3450
 
2880
3451
  // src/commands/status.ts
2881
- import { loadProjectInfo as loadProjectInfo8, NOIR_VERSION as NOIR_VERSION4 } from "@noir-ai/core";
3452
+ import { loadProjectInfo as loadProjectInfo9, NOIR_VERSION as NOIR_VERSION4 } from "@noir-ai/core";
2882
3453
  async function tryTool(caller, name, args) {
2883
3454
  try {
2884
3455
  return await caller.callTool(name, args);
@@ -2999,7 +3570,7 @@ function renderHuman2(p, opts) {
2999
3570
  async function gatherStatusPayload(opts) {
3000
3571
  let project;
3001
3572
  try {
3002
- project = loadProjectInfo8(process.cwd());
3573
+ project = loadProjectInfo9(process.cwd());
3003
3574
  } catch {
3004
3575
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
3005
3576
  }
@@ -3029,7 +3600,7 @@ async function status(opts) {
3029
3600
  env: process.env,
3030
3601
  configUpdate: (() => {
3031
3602
  try {
3032
- const info2 = loadProjectInfo8(process.cwd());
3603
+ const info2 = loadProjectInfo9(process.cwd());
3033
3604
  return info2.config.update ?? DEFAULT_UPDATE_CONFIG;
3034
3605
  } catch {
3035
3606
  return DEFAULT_UPDATE_CONFIG;
@@ -3058,7 +3629,7 @@ var init_status = __esm({
3058
3629
  // src/commands/task.ts
3059
3630
  import { spawnSync as spawnSync2 } from "child_process";
3060
3631
  import { writeFileSync } from "fs";
3061
- import { loadProjectInfo as loadProjectInfo9, nextArtifactSequence, sha256Hex } from "@noir-ai/core";
3632
+ import { loadProjectInfo as loadProjectInfo10, nextArtifactSequence, sha256Hex } from "@noir-ai/core";
3062
3633
  import {
3063
3634
  PHASES,
3064
3635
  TASK_CLASSES,
@@ -3071,7 +3642,7 @@ function skillFor(phase) {
3071
3642
  }
3072
3643
  function writeDoneArtifacts(opts, taskId) {
3073
3644
  try {
3074
- const project = loadProjectInfo9(process.cwd());
3645
+ const project = loadProjectInfo10(process.cwd());
3075
3646
  const conflict = { conflictPolicy: "preserve", interactive: false };
3076
3647
  writeChangelogStub(project.root, `- ${taskId}: completed (verify gate passed)`, conflict);
3077
3648
  const nextN = nextArtifactSequence(project.root, "adr");
@@ -3394,7 +3965,7 @@ function runCheck(cmd) {
3394
3965
  async function taskVerify(opts) {
3395
3966
  let project;
3396
3967
  try {
3397
- project = loadProjectInfo9(process.cwd());
3968
+ project = loadProjectInfo10(process.cwd());
3398
3969
  } catch {
3399
3970
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
3400
3971
  }
@@ -3500,7 +4071,7 @@ async function taskResearch(opts) {
3500
4071
  return;
3501
4072
  }
3502
4073
  renderStatusRow(s, opts);
3503
- info("research findings are recorded via `noir task research record` (list + record)", opts);
4074
+ info("this is a status view; record findings with `noir task research-record`", opts);
3504
4075
  }
3505
4076
  async function taskResearchRecord(opts) {
3506
4077
  const TYPES = ["assumption", "discovery", "decision", "grounding-fact"];
@@ -3524,10 +4095,7 @@ async function taskResearchRecord(opts) {
3524
4095
  `);
3525
4096
  return;
3526
4097
  }
3527
- success(
3528
- `research recorded \u2192 ${opts.type}: ${opts.text.slice(0, 80)}${opts.text.length > 80 ? "\u2026" : ""}`,
3529
- opts
3530
- );
4098
+ success(`research recorded \u2192 ${opts.type}: ${truncateToWidth(opts.text, 80)}`, opts);
3531
4099
  }
3532
4100
  async function taskDecompose(opts) {
3533
4101
  const cap = opts.capability.trim();
@@ -3583,6 +4151,7 @@ var init_task = __esm({
3583
4151
  init_daemon_client();
3584
4152
  init_output();
3585
4153
  init_theme();
4154
+ init_width();
3586
4155
  PHASE_SKILL = {
3587
4156
  // Skill curation: intake+clarify merged into brainstorming;
3588
4157
  // execute → executing-plans; verify → verifying; document → wrap.
@@ -3604,9 +4173,9 @@ import { join as join4 } from "path";
3604
4173
  import {
3605
4174
  defaultHandoffBlock,
3606
4175
  hostLaunchDirective,
3607
- resolveAdapter as resolveAdapter3
4176
+ resolveAdapter as resolveAdapter2
3608
4177
  } from "@noir-ai/adapters";
3609
- import { loadProjectInfo as loadProjectInfo10, NOIR_DIR, resolveArtifactPath } from "@noir-ai/core";
4178
+ import { loadProjectInfo as loadProjectInfo11, NOIR_DIR, resolveArtifactPath } from "@noir-ai/core";
3610
4179
  function heading(name, id) {
3611
4180
  return `# Noir handoff \u2014 ${name} (${id})`;
3612
4181
  }
@@ -3702,7 +4271,7 @@ function toHostPayload(p) {
3702
4271
  }
3703
4272
  function resolveDirective(root, p) {
3704
4273
  const ctx = { root };
3705
- const adapter = resolveAdapter3(p.host);
4274
+ const adapter = resolveAdapter2(p.host);
3706
4275
  const hostPayload = toHostPayload(p);
3707
4276
  if (typeof adapter.emitHandoff === "function") {
3708
4277
  return adapter.emitHandoff(ctx, hostPayload);
@@ -3745,7 +4314,7 @@ async function probeOnly(opts) {
3745
4314
  async function handoff(opts) {
3746
4315
  let project;
3747
4316
  try {
3748
- project = loadProjectInfo10(process.cwd());
4317
+ project = loadProjectInfo11(process.cwd());
3749
4318
  } catch {
3750
4319
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
3751
4320
  }
@@ -3805,7 +4374,7 @@ import gradient from "gradient-string";
3805
4374
  function renderBanner(opts = {}) {
3806
4375
  const width = opts.width ?? process.stdout.columns ?? 80;
3807
4376
  const colorOn = opts.color !== false && useColor();
3808
- if (width < 50) {
4377
+ if (width < BLOCK_MIN_WIDTH) {
3809
4378
  return colorOn ? `${c.accent("\u25C6")} noir` : "\u25C6 noir";
3810
4379
  }
3811
4380
  const block = NOIR_BLOCK.join("\n");
@@ -3821,11 +4390,12 @@ function shouldShowBanner(opts) {
3821
4390
  if (v !== void 0 && v !== "") return false;
3822
4391
  return true;
3823
4392
  }
3824
- var NOIR_BLOCK, NOIR_GRADIENT, NOIR_TAGLINE;
4393
+ var NOIR_BLOCK, NOIR_GRADIENT, NOIR_TAGLINE, BLOCK_WIDTH, BLOCK_MIN_WIDTH;
3825
4394
  var init_banner = __esm({
3826
4395
  "src/banner.ts"() {
3827
4396
  "use strict";
3828
4397
  init_theme();
4398
+ init_width();
3829
4399
  NOIR_BLOCK = [
3830
4400
  "\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 ",
3831
4401
  "\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557",
@@ -3836,6 +4406,8 @@ var init_banner = __esm({
3836
4406
  ];
3837
4407
  NOIR_GRADIENT = gradient("#2c5282", "#3b82f6", "#7dd3fc");
3838
4408
  NOIR_TAGLINE = "discipline, context, and memory layer for agentic CLIs";
4409
+ BLOCK_WIDTH = NOIR_BLOCK.reduce((max, line) => Math.max(max, displayWidth(line)), 0);
4410
+ BLOCK_MIN_WIDTH = BLOCK_WIDTH + 17;
3839
4411
  }
3840
4412
  });
3841
4413
 
@@ -4135,14 +4707,14 @@ import { hostLaunchDirective as hostLaunchDirective2 } from "@noir-ai/adapters";
4135
4707
  import {
4136
4708
  detectActiveMethod as detectActiveMethod4,
4137
4709
  latestVersionFromCache as latestVersionFromCache2,
4138
- loadProjectInfo as loadProjectInfo11,
4710
+ loadProjectInfo as loadProjectInfo12,
4139
4711
  NOIR_VERSION as NOIR_VERSION5,
4140
4712
  readInstallRecord as readInstallRecord4,
4141
4713
  readUpdateCache as readUpdateCache3
4142
4714
  } from "@noir-ai/core";
4143
4715
  function tryProject() {
4144
4716
  try {
4145
- const info2 = loadProjectInfo11(process.cwd());
4717
+ const info2 = loadProjectInfo12(process.cwd());
4146
4718
  return { id: info2.id, name: info2.name, host: info2.config.host };
4147
4719
  } catch {
4148
4720
  return null;
@@ -4305,7 +4877,7 @@ async function runMenu(opts, deps) {
4305
4877
  env: process.env,
4306
4878
  configUpdate: (() => {
4307
4879
  try {
4308
- const info2 = loadProjectInfo11(process.cwd());
4880
+ const info2 = loadProjectInfo12(process.cwd());
4309
4881
  return info2.config.update ?? DEFAULT_UPDATE_CONFIG;
4310
4882
  } catch {
4311
4883
  return DEFAULT_UPDATE_CONFIG;
@@ -4367,13 +4939,13 @@ var init_home = __esm({
4367
4939
  });
4368
4940
 
4369
4941
  // src/commands/memory.ts
4370
- import { readFileSync as readFileSync3 } from "fs";
4371
- import { loadProjectInfo as loadProjectInfo12 } from "@noir-ai/core";
4942
+ import { readFileSync as readFileSync4 } from "fs";
4943
+ import { loadProjectInfo as loadProjectInfo13 } from "@noir-ai/core";
4372
4944
  function resolveWorkspace() {
4373
4945
  const routing = workspaceRoutingTarget(process.cwd());
4374
4946
  if (routing === null) return null;
4375
4947
  try {
4376
- return { routing, project: loadProjectInfo12(process.cwd()) };
4948
+ return { routing, project: loadProjectInfo13(process.cwd()) };
4377
4949
  } catch {
4378
4950
  return null;
4379
4951
  }
@@ -4585,14 +5157,14 @@ async function resolveCaptureContent(opts) {
4585
5157
  if (typeof opts.content === "string" && opts.content.length > 0) return opts.content;
4586
5158
  if (typeof opts.file === "string" && opts.file.length > 0) {
4587
5159
  try {
4588
- return readFileSync3(opts.file, "utf8");
5160
+ return readFileSync4(opts.file, "utf8");
4589
5161
  } catch {
4590
5162
  fail(EXIT.USAGE, `memory capture could not read --file ${opts.file}`, opts);
4591
5163
  }
4592
5164
  }
4593
5165
  if (!process.stdin.isTTY) {
4594
5166
  try {
4595
- return readFileSync3(0, "utf8");
5167
+ return readFileSync4(0, "utf8");
4596
5168
  } catch {
4597
5169
  fail(
4598
5170
  EXIT.USAGE,
@@ -4786,7 +5358,7 @@ function describe(step, who) {
4786
5358
  const m = {
4787
5359
  preflight: "check clean tree, on develop, HEAD pushed, gate green, no version collision",
4788
5360
  bump: "run bump-version.mjs + update CHANGELOG + roadmap docs",
4789
- gate: "re-run the full gate (lint/build/typecheck/test/docs:validate)",
5361
+ gate: "re-run the full six-step gate (lint/build/typecheck/test/docs:validate/hygiene:gate)",
4790
5362
  commit: "commit chore(release) + push develop",
4791
5363
  "ci-develop": "wait for CI on develop \u2192 green",
4792
5364
  "beta-tag": "pnpm release:tag \u2192 beta tag + push",
@@ -4831,11 +5403,14 @@ async function release(opts) {
4831
5403
  });
4832
5404
  success(`bump: \u2713 version ${version}`, opts);
4833
5405
  info(`[3/13] gate: ${describe("gate", "noir")}`, opts);
4834
- execSync("pnpm lint && pnpm build && pnpm typecheck && pnpm test && pnpm docs:validate", {
4835
- encoding: "utf8",
4836
- stdio: "inherit"
4837
- });
4838
- success("gate: \u2713 full gate green", opts);
5406
+ execSync(
5407
+ "pnpm lint && pnpm build && pnpm typecheck && pnpm test && pnpm docs:validate && pnpm hygiene:gate",
5408
+ {
5409
+ encoding: "utf8",
5410
+ stdio: "inherit"
5411
+ }
5412
+ );
5413
+ success("gate: \u2713 all six gates green", opts);
4839
5414
  info(`[4/13] commit/push: ${describe("commit", "noir")}`, opts);
4840
5415
  spawnSync3("git", ["add", "-A"], { encoding: "utf8", stdio: "inherit" });
4841
5416
  spawnSync3("git", ["commit", "-m", `chore(release): v${version} + docs sync`], {
@@ -5695,10 +6270,10 @@ var init_run_interrupt = __esm({
5695
6270
  });
5696
6271
 
5697
6272
  // src/run-profiles.ts
5698
- import { isDeniedEnvKey, loadProjectInfo as loadProjectInfo13 } from "@noir-ai/core";
6273
+ import { isDeniedEnvKey, loadProjectInfo as loadProjectInfo14 } from "@noir-ai/core";
5699
6274
  function loadRunConfig(root) {
5700
6275
  try {
5701
- return loadProjectInfo13(root).config;
6276
+ return loadProjectInfo14(root).config;
5702
6277
  } catch {
5703
6278
  return null;
5704
6279
  }
@@ -5782,6 +6357,8 @@ var init_run_status = __esm({
5782
6357
  "src/run-status.ts"() {
5783
6358
  "use strict";
5784
6359
  init_orchestrator();
6360
+ init_theme();
6361
+ init_width();
5785
6362
  HOST_STDERR_TAIL_LINES = 20;
5786
6363
  RunStatusLine = class {
5787
6364
  host;
@@ -5864,6 +6441,7 @@ var init_run_status = __esm({
5864
6441
  if (this.animated && this.sharesCursor && this.onRow) {
5865
6442
  this.write("\n");
5866
6443
  this.onRow = false;
6444
+ this.rendered = "";
5867
6445
  }
5868
6446
  this.stdoutAtRowStart = chunk.endsWith("\n");
5869
6447
  }
@@ -5894,19 +6472,45 @@ var init_run_status = __esm({
5894
6472
  return;
5895
6473
  }
5896
6474
  if (!this.onRow || !this.cursorFree()) return;
5897
- this.write("\r\x1B[K");
6475
+ this.write(this.erase(this.rendered));
5898
6476
  this.rendered = "";
5899
6477
  this.onRow = false;
5900
6478
  }
5901
6479
  /** Rewrite the current row in place, skipping no-op rewrites and unsafe moments. */
5902
6480
  draw() {
5903
6481
  if (!this.animated || !this.cursorFree()) return;
5904
- const text = this.render();
6482
+ const text = truncateToWidth(this.render(), terminalWidth());
5905
6483
  if (text === this.rendered) return;
5906
- this.write(`\r\x1B[K${text}`);
6484
+ this.write(`${this.erase(this.rendered)}${text}`);
5907
6485
  this.rendered = text;
5908
6486
  this.onRow = true;
5909
6487
  }
6488
+ /**
6489
+ * The escape sequence that clears the status text currently on screen and
6490
+ * leaves the cursor at the start of the row it began on — where the next
6491
+ * render writes. An empty `previous` still clears the row, so a line that has
6492
+ * drawn nothing yet starts from a known-blank one.
6493
+ *
6494
+ * The extent is measured against the terminal width as it is NOW, not as it
6495
+ * was when the text was drawn. Nothing this line writes wraps, but text that
6496
+ * fitted a wider terminal does not stop existing when the window narrows: the
6497
+ * terminal folds it onto rows below, and clearing only the row the cursor
6498
+ * ends on would leave the rest above as residue. So the sequence walks back
6499
+ * up over every row the text occupies. That folding is the behaviour of the
6500
+ * common terminals (iTerm2, Terminal.app, GNOME Terminal and recent xterm);
6501
+ * one that clips the row instead has no continuation to clear.
6502
+ *
6503
+ * The caller must have forgotten the text once its row was abandoned —
6504
+ * `rendered` is only meaningful while `onRow` is true.
6505
+ */
6506
+ erase(previous) {
6507
+ const rows = Math.max(1, Math.ceil(displayWidth(previous) / terminalWidth()));
6508
+ let sequence = "\r\x1B[K";
6509
+ for (let row = 1; row < rows; row++) {
6510
+ sequence += "\x1B[1A\r\x1B[K";
6511
+ }
6512
+ return sequence;
6513
+ }
5910
6514
  /**
5911
6515
  * Whether the cursor is at the start of a row nobody else is using. On stdout
5912
6516
  * that is not this terminal there is no shared cursor to disturb; when it is,
@@ -6303,7 +6907,7 @@ var init_conflict = __esm({
6303
6907
  });
6304
6908
 
6305
6909
  // src/dedup-write.ts
6306
- import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync5 } from "fs";
6910
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync5 } from "fs";
6307
6911
  import { join as join6 } from "path";
6308
6912
  import { sha256Hex as sha256Hex2, sha256Hex12 } from "@noir-ai/core";
6309
6913
  async function checkWritePathDedup(root, res, opts) {
@@ -6318,7 +6922,7 @@ async function checkWritePathDedup(root, res, opts) {
6318
6922
  const proposedAbs = join6(root, proposedRel);
6319
6923
  let proposedText;
6320
6924
  try {
6321
- proposedText = readFileSync4(proposedAbs, "utf8");
6925
+ proposedText = readFileSync5(proposedAbs, "utf8");
6322
6926
  } catch {
6323
6927
  continue;
6324
6928
  }
@@ -6458,7 +7062,7 @@ async function promptDedupAction(root, proposedRel, matchedRel, proposedText, si
6458
7062
  return "replace";
6459
7063
  } else if (choice === "mirror") {
6460
7064
  try {
6461
- const existing = readFileSync4(join6(root, matchedRel), "utf8");
7065
+ const existing = readFileSync5(join6(root, matchedRel), "utf8");
6462
7066
  writeFileSync5(
6463
7067
  join6(root, matchedRel),
6464
7068
  `${existing}
@@ -6482,7 +7086,7 @@ function cachePath(root) {
6482
7086
  }
6483
7087
  function loadCache(root) {
6484
7088
  try {
6485
- const raw = readFileSync4(cachePath(root), "utf8");
7089
+ const raw = readFileSync5(cachePath(root), "utf8");
6486
7090
  const parsed = JSON.parse(raw);
6487
7091
  if (parsed.version !== DEDUP_CACHE_VERSION) return emptyCache();
6488
7092
  if (typeof parsed.entries !== "object" || parsed.entries === null) return emptyCache();
@@ -6514,7 +7118,7 @@ function makeCachedEmbed(cache, embed) {
6514
7118
  }
6515
7119
  function safeRead(abs) {
6516
7120
  try {
6517
- return readFileSync4(abs, "utf8");
7121
+ return readFileSync5(abs, "utf8");
6518
7122
  } catch {
6519
7123
  return void 0;
6520
7124
  }
@@ -6553,8 +7157,8 @@ var init_dedup_write = __esm({
6553
7157
 
6554
7158
  // src/init.ts
6555
7159
  import { existsSync as existsSync4 } from "fs";
6556
- import { resolveAdapter as resolveAdapter4 } from "@noir-ai/adapters";
6557
- import { loadProjectInfo as loadProjectInfo14, paths as paths3 } from "@noir-ai/core";
7160
+ import { resolveAdapter as resolveAdapter3 } from "@noir-ai/adapters";
7161
+ import { loadProjectInfo as loadProjectInfo15, paths as paths3 } from "@noir-ai/core";
6558
7162
  import { scaffold } from "@noir-ai/create";
6559
7163
  import {
6560
7164
  emitSkillsToDir
@@ -6581,13 +7185,14 @@ async function init(root, opts) {
6581
7185
  reportPlannedWrites(res);
6582
7186
  return res;
6583
7187
  }
7188
+ reportEnvHeal(res);
6584
7189
  if (res.noop) return res;
6585
7190
  const skillSummary = await emitHostSkills(root, host, conflictOpts, interactive);
6586
7191
  res.skillConflicts = skillSummary?.conflicts ?? [];
6587
7192
  res.preservedSkills = skillSummary?.preserved ?? [];
6588
7193
  let projectInfo;
6589
7194
  try {
6590
- projectInfo = loadProjectInfo14(root);
7195
+ projectInfo = loadProjectInfo15(root);
6591
7196
  } catch {
6592
7197
  projectInfo = void 0;
6593
7198
  }
@@ -6618,7 +7223,7 @@ function resolveInitHost(root, opts) {
6618
7223
  if (opts.host !== void 0) return opts.host;
6619
7224
  if (!existsSync4(paths3.config(root))) return "claude";
6620
7225
  try {
6621
- return loadProjectInfo14(root).config.host;
7226
+ return loadProjectInfo15(root).config.host;
6622
7227
  } catch {
6623
7228
  process.stderr.write(
6624
7229
  `Could not read the configured host from ${paths3.config(root)}; using 'claude'.
@@ -6649,8 +7254,13 @@ function reportPlannedWrites(res) {
6649
7254
  for (const p of res.identical) log(` ${p}`);
6650
7255
  }
6651
7256
  }
7257
+ function reportEnvHeal(res) {
7258
+ if (res.envMode === "healed") {
7259
+ process.stderr.write("Tightened .noir/.env to 0600 (it was readable by group or others).\n");
7260
+ }
7261
+ }
6652
7262
  async function emitHostSkills(root, host, conflictOpts, interactive) {
6653
- const adapter = resolveAdapter4(host);
7263
+ const adapter = resolveAdapter3(host);
6654
7264
  const skillsDir = adapter.skillsDir?.({ root });
6655
7265
  if (skillsDir === void 0) {
6656
7266
  process.stderr.write(`host '${host}' has no skill emitter; skipping skills
@@ -6714,8 +7324,8 @@ var init_init = __esm({
6714
7324
  });
6715
7325
 
6716
7326
  // src/commands/skills.ts
6717
- import { resolveAdapter as resolveAdapter5 } from "@noir-ai/adapters";
6718
- import { loadProjectInfo as loadProjectInfo15 } from "@noir-ai/core";
7327
+ import { resolveAdapter as resolveAdapter4 } from "@noir-ai/adapters";
7328
+ import { loadProjectInfo as loadProjectInfo16 } from "@noir-ai/core";
6719
7329
  import {
6720
7330
  buildRegistry,
6721
7331
  discoverAll,
@@ -6733,13 +7343,6 @@ function toRow(s, kind = "builtin") {
6733
7343
  const category = s.frontmatter.metadata?.category?.trim() || categoryOf(s.name);
6734
7344
  return { name: s.name, category, description, kind, status: status2 };
6735
7345
  }
6736
- function truncate(text, max) {
6737
- if (text.length <= max) return text;
6738
- const slice = text.slice(0, Math.max(0, max - 1));
6739
- const boundary = slice.lastIndexOf(" ");
6740
- const head = boundary > 0 ? slice.slice(0, boundary) : slice;
6741
- return `${head.trimEnd()}\u2026`;
6742
- }
6743
7346
  async function skillsList(opts) {
6744
7347
  let builtins;
6745
7348
  let integrations;
@@ -6773,7 +7376,9 @@ async function skillsList(opts) {
6773
7376
  Kind: r.kind,
6774
7377
  Category: r.category,
6775
7378
  Status: r.status,
6776
- Description: truncate(r.description, 80)
7379
+ // Display-only cut for the dense table cell; the `--json` payload always
7380
+ // carries the description whole (search snippets must never be truncated).
7381
+ Description: truncateToWidth(r.description, 80)
6777
7382
  })),
6778
7383
  ["Skill", "Kind", "Category", "Status", "Description"],
6779
7384
  opts
@@ -6783,11 +7388,11 @@ async function skillsSync(opts) {
6783
7388
  const root = process.cwd();
6784
7389
  let project;
6785
7390
  try {
6786
- project = loadProjectInfo15(root);
7391
+ project = loadProjectInfo16(root);
6787
7392
  } catch {
6788
7393
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
6789
7394
  }
6790
- const adapter = resolveAdapter5(project.config.host);
7395
+ const adapter = resolveAdapter4(project.config.host);
6791
7396
  if (!adapter.skillsDir) {
6792
7397
  const data2 = {
6793
7398
  emitted: [],
@@ -6849,7 +7454,7 @@ async function skillsLint(opts) {
6849
7454
  );
6850
7455
  }
6851
7456
  const errored = skills.filter((s) => s.errors.length > 0);
6852
- const warned = skills.filter((s) => s.warnings.length > 0 && s.errors.length === 0);
7457
+ const warned = skills.filter((s) => s.warnings.length > 0);
6853
7458
  const data = { count: skills.length, errored: errored.length, skills };
6854
7459
  if (errored.length > 0) {
6855
7460
  const message = `skills lint: ${errored.length} skill${errored.length === 1 ? "" : "s"} failed validation`;
@@ -6927,6 +7532,7 @@ var init_skills = __esm({
6927
7532
  "use strict";
6928
7533
  init_init();
6929
7534
  init_output();
7535
+ init_width();
6930
7536
  CATEGORY = {
6931
7537
  "noir-brainstorming": "discovery",
6932
7538
  "noir-sync": "discovery",
@@ -6953,17 +7559,22 @@ var init_skills = __esm({
6953
7559
  "noir-backend": "domain",
6954
7560
  "noir-doctor": "meta",
6955
7561
  "noir-writing-skills": "meta",
6956
- "noir-rules": "meta"
7562
+ "noir-rules": "meta",
7563
+ "noir-code-hygiene": "meta"
6957
7564
  };
6958
7565
  }
6959
7566
  });
6960
7567
 
6961
7568
  // src/serve.ts
6962
- import { applyNoirEnv, loadProjectInfo as loadProjectInfo16 } from "@noir-ai/core";
7569
+ import { applyNoirEnv, loadProjectInfo as loadProjectInfo17 } from "@noir-ai/core";
6963
7570
  import { ensureDaemonRunning as ensureDaemonRunning3, startStdioServer } from "@noir-ai/daemon";
6964
7571
  async function serve(opts) {
6965
7572
  applyNoirEnv(process.cwd());
6966
- const project = loadProjectInfo16(process.cwd());
7573
+ if (opts.workspace !== void 0) {
7574
+ await bridgeStdioToWorkspace(opts.workspace, process.cwd());
7575
+ return;
7576
+ }
7577
+ const project = loadProjectInfo17(process.cwd());
6967
7578
  if (opts.stdio) {
6968
7579
  await startStdioServer({ project, transport: "stdio", daemon: false });
6969
7580
  return;
@@ -6989,6 +7600,7 @@ async function serve(opts) {
6989
7600
  var init_serve = __esm({
6990
7601
  "src/serve.ts"() {
6991
7602
  "use strict";
7603
+ init_workspace_bridge();
6992
7604
  }
6993
7605
  });
6994
7606
 
@@ -6998,8 +7610,8 @@ __export(create_exports, {
6998
7610
  create: () => create
6999
7611
  });
7000
7612
  import { resolve as resolve2 } from "path";
7001
- import { resolveAdapter as resolveAdapter6 } from "@noir-ai/adapters";
7002
- import { loadProjectInfo as loadProjectInfo17 } from "@noir-ai/core";
7613
+ import { resolveAdapter as resolveAdapter5 } from "@noir-ai/adapters";
7614
+ import { loadProjectInfo as loadProjectInfo18 } from "@noir-ai/core";
7003
7615
  import { scaffold as scaffold2 } from "@noir-ai/create";
7004
7616
  import { emitSkillsToDir as emitSkillsToDir3 } from "@noir-ai/skills";
7005
7617
  async function create(dir, opts) {
@@ -7023,9 +7635,10 @@ async function create(dir, opts) {
7023
7635
  reportPlannedWrites(res);
7024
7636
  return res;
7025
7637
  }
7638
+ reportEnvHeal(res);
7026
7639
  if (res.noop) return res;
7027
7640
  const host = res.host;
7028
- const adapter = resolveAdapter6(host);
7641
+ const adapter = resolveAdapter5(host);
7029
7642
  const skillsDir = adapter.skillsDir?.({ root });
7030
7643
  if (skillsDir === void 0) {
7031
7644
  process.stderr.write(`host '${host}' has no skill emitter; skipping skills
@@ -7053,7 +7666,7 @@ async function create(dir, opts) {
7053
7666
  }
7054
7667
  let projectInfo;
7055
7668
  try {
7056
- projectInfo = loadProjectInfo17(root);
7669
+ projectInfo = loadProjectInfo18(root);
7057
7670
  } catch {
7058
7671
  projectInfo = void 0;
7059
7672
  }
@@ -7080,8 +7693,8 @@ __export(sync_exports, {
7080
7693
  sync: () => sync
7081
7694
  });
7082
7695
  import { existsSync as existsSync5 } from "fs";
7083
- import { resolveAdapter as resolveAdapter7 } from "@noir-ai/adapters";
7084
- import { loadProjectInfo as loadProjectInfo18, paths as paths4 } from "@noir-ai/core";
7696
+ import { resolveAdapter as resolveAdapter6 } from "@noir-ai/adapters";
7697
+ import { loadProjectInfo as loadProjectInfo19, paths as paths4 } from "@noir-ai/core";
7085
7698
  import { scaffold as scaffold3 } from "@noir-ai/create";
7086
7699
  import { emitSkillsToDir as emitSkillsToDir4 } from "@noir-ai/skills";
7087
7700
  async function sync(root, opts = {}) {
@@ -7090,7 +7703,7 @@ async function sync(root, opts = {}) {
7090
7703
  const conflictOpts = buildConflictOpts({ force: opts.force, interactive });
7091
7704
  let projectInfo;
7092
7705
  try {
7093
- projectInfo = loadProjectInfo18(root);
7706
+ projectInfo = loadProjectInfo19(root);
7094
7707
  } catch {
7095
7708
  projectInfo = void 0;
7096
7709
  }
@@ -7109,10 +7722,11 @@ async function sync(root, opts = {}) {
7109
7722
  reportPlannedWrites(res);
7110
7723
  return res;
7111
7724
  }
7725
+ reportEnvHeal(res);
7112
7726
  if (res.written.length === 0 && res.identical.length > 0) {
7113
7727
  process.stderr.write("Nothing to sync (Noir-managed files are up to date).\n");
7114
7728
  }
7115
- const adapter = resolveAdapter7(host);
7729
+ const adapter = resolveAdapter6(host);
7116
7730
  const skillsDir = adapter.skillsDir?.({ root });
7117
7731
  if (skillsDir === void 0) {
7118
7732
  process.stderr.write(`host '${host}' has no skill emitter; skipping skills
@@ -7153,7 +7767,7 @@ function resolveSyncHost(root, opts) {
7153
7767
  if (opts.host !== void 0) return opts.host;
7154
7768
  if (!existsSync5(paths4.config(root))) return "claude";
7155
7769
  try {
7156
- return loadProjectInfo18(root).config.host;
7770
+ return loadProjectInfo19(root).config.host;
7157
7771
  } catch {
7158
7772
  return "claude";
7159
7773
  }
@@ -7278,6 +7892,8 @@ function createProgram() {
7278
7892
  const nonInteractive = opts.json === true || opts.input === false;
7279
7893
  if (nonInteractive) process.env.NOIR_NON_INTERACTIVE = "1";
7280
7894
  else delete process.env.NOIR_NON_INTERACTIVE;
7895
+ if (opts.quiet === true) process.env.NOIR_QUIET = "1";
7896
+ else delete process.env.NOIR_QUIET;
7281
7897
  emitDeprecationHintsFor(actionCmd, toCliOptions(opts));
7282
7898
  });
7283
7899
  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(
@@ -7383,11 +7999,17 @@ function createProgram() {
7383
7999
  }
7384
8000
  );
7385
8001
  const mcpCmd = program2.command("mcp").description("MCP server control");
7386
- mcpCmd.command("serve").description("run the Noir MCP server (stdio, or via the shared daemon)").option("--stdio", "force the stdio transport").action(async (opts) => {
7387
- await serve({ stdio: opts.stdio === true });
8002
+ mcpCmd.command("serve").description("run the Noir MCP server (stdio, or via the shared daemon)").option("--stdio", "force the stdio transport").option(
8003
+ "--workspace <name>",
8004
+ "serve a shared workspace over stdio, bridging to that workspace daemon"
8005
+ ).action(async (opts) => {
8006
+ await serve({
8007
+ stdio: opts.stdio === true,
8008
+ ...typeof opts.workspace === "string" ? { workspace: opts.workspace } : {}
8009
+ });
7388
8010
  });
7389
8011
  mcpCmd.action(() => {
7390
- throw new NoirCliError(EXIT.USAGE, "Usage: noir mcp serve [--stdio]");
8012
+ throw new NoirCliError(EXIT.USAGE, "Usage: noir mcp serve [--stdio] [--workspace <name>]");
7391
8013
  });
7392
8014
  const daemonGrp = program2.command("daemon").description("control the Noir daemon");
7393
8015
  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(
@@ -7488,10 +8110,16 @@ function createProgram() {
7488
8110
  program2.command("doctor").description("environment + project health").option(
7489
8111
  "--dedup",
7490
8112
  "scan host-context + .noir/ docs for semantic near-duplicates (loads the local embedder)"
7491
- ).action(async (...args) => {
8113
+ ).option("--fix", "re-assert owner-only permissions on .noir/.env and the store DB + directory").action(async (...args) => {
7492
8114
  const g = actionGlobals(args);
7493
- const dedup = trailingCmd(args).opts().dedup === true;
7494
- await doctor({ ...toCliOptions(g), ...dedup ? { dedup: true } : {} });
8115
+ const opts = trailingCmd(args).opts();
8116
+ const dedup = opts.dedup === true;
8117
+ const fix = opts.fix === true;
8118
+ await doctor({
8119
+ ...toCliOptions(g),
8120
+ ...dedup ? { dedup: true } : {},
8121
+ ...fix ? { fix: true } : {}
8122
+ });
7495
8123
  });
7496
8124
  program2.command("status").description("project + daemon + workflow + store snapshot").action(async (...args) => {
7497
8125
  await status(toStatusOptions(actionGlobals(args)));
@@ -7684,7 +8312,7 @@ function createProgram() {
7684
8312
  ...check === void 0 || check.length === 0 ? {} : { check }
7685
8313
  });
7686
8314
  });
7687
- taskGrp.command("research").description("list research findings for the active (or named) task").argument("[id]", "task id (defaults to active)").action(async (...args) => {
8315
+ taskGrp.command("research").description("show research status for the active (or named) task").argument("[id]", "task id (defaults to active)").action(async (...args) => {
7688
8316
  const cmd = trailingCmd(args);
7689
8317
  const g = cmd.optsWithGlobals();
7690
8318
  const first = args[0];