@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/index.js CHANGED
@@ -14,10 +14,82 @@ var __export = (target, all) => {
14
14
  __defProp(target, name, { get: all[name], enumerable: true });
15
15
  };
16
16
 
17
+ // src/width.ts
18
+ import stringWidth from "string-width";
19
+ import stripAnsi from "strip-ansi";
20
+ function clusters(s) {
21
+ return [...segmenter.segment(s)].map(({ segment }) => segment);
22
+ }
23
+ function displayWidth(s) {
24
+ return stringWidth(s);
25
+ }
26
+ function fitLeading(parts, width) {
27
+ let used = 0;
28
+ let count = 0;
29
+ for (const part of parts) {
30
+ const w = displayWidth(part);
31
+ if (used + w > width) {
32
+ break;
33
+ }
34
+ used += w;
35
+ count++;
36
+ }
37
+ return count;
38
+ }
39
+ function truncateToWidth(s, width, ellipsis = "\u2026") {
40
+ const plain = stripAnsi(s);
41
+ if (width <= 0) {
42
+ return "";
43
+ }
44
+ if (displayWidth(plain) <= width) {
45
+ return plain;
46
+ }
47
+ const markerParts = clusters(ellipsis);
48
+ const marker = markerParts.slice(0, fitLeading(markerParts, width)).join("");
49
+ const budget = width - displayWidth(marker);
50
+ const parts = clusters(plain);
51
+ return parts.slice(0, fitLeading(parts, budget)).join("") + marker;
52
+ }
53
+ function truncateMiddle(s, width, ellipsis = "\u2026") {
54
+ const plain = stripAnsi(s);
55
+ if (width <= 0) {
56
+ return "";
57
+ }
58
+ if (displayWidth(plain) <= width) {
59
+ return plain;
60
+ }
61
+ const markerParts = clusters(ellipsis);
62
+ const marker = markerParts.slice(0, fitLeading(markerParts, width)).join("");
63
+ const remaining = width - displayWidth(marker);
64
+ const headBudget = Math.ceil(remaining / 2);
65
+ const tailBudget = remaining - headBudget;
66
+ const parts = clusters(plain);
67
+ const headCount = fitLeading(parts, headBudget);
68
+ const tail = [];
69
+ let tailUsed = 0;
70
+ for (const part of parts.slice(headCount).reverse()) {
71
+ const w = displayWidth(part);
72
+ if (tailUsed + w > tailBudget) {
73
+ break;
74
+ }
75
+ tail.push(part);
76
+ tailUsed += w;
77
+ }
78
+ return parts.slice(0, headCount).join("") + marker + tail.reverse().join("");
79
+ }
80
+ var segmenter;
81
+ var init_width = __esm({
82
+ "src/width.ts"() {
83
+ "use strict";
84
+ segmenter = new Intl.Segmenter();
85
+ }
86
+ });
87
+
17
88
  // src/theme.ts
18
89
  import pc from "picocolors";
19
- function badge(state, label = state) {
20
- const text = `${BADGE_SYMBOL[state]} ${label}`;
90
+ function badge(state, label = state, maxWidth) {
91
+ const full = `${BADGE_SYMBOL[state]} ${label}`;
92
+ const text = maxWidth === void 0 ? full : truncateToWidth(full, maxWidth);
21
93
  switch (state) {
22
94
  case "ok":
23
95
  return c.ok(text);
@@ -38,8 +110,13 @@ function isCiEnv() {
38
110
  if (v === void 0 || v === "") return false;
39
111
  return v !== "0" && v !== "false";
40
112
  }
113
+ function quietMode() {
114
+ const v = process.env.NOIR_QUIET;
115
+ return v !== void 0 && v !== "";
116
+ }
41
117
  function useColor() {
42
118
  if (envFlagSet("NO_COLOR")) return false;
119
+ if (quietMode()) return false;
43
120
  if (process.env.CLICOLOR_FORCE === "1") return true;
44
121
  if (isCiEnv()) return false;
45
122
  return pc.isColorSupported;
@@ -53,14 +130,17 @@ function terminalWidth() {
53
130
  const n = Number(fromEnv);
54
131
  if (Number.isInteger(n) && n > 0) return Math.max(n, 20);
55
132
  }
56
- const cols = process.stdout.columns;
57
- if (typeof cols === "number" && cols > 0) return Math.max(cols, 20);
133
+ const stderrCols = process.stderr.columns;
134
+ if (typeof stderrCols === "number" && stderrCols > 0) return Math.max(stderrCols, 20);
135
+ const stdoutCols = process.stdout.columns;
136
+ if (typeof stdoutCols === "number" && stdoutCols > 0) return Math.max(stdoutCols, 20);
58
137
  return 80;
59
138
  }
60
139
  var FORCE, c, BADGE_SYMBOL;
61
140
  var init_theme = __esm({
62
141
  "src/theme.ts"() {
63
142
  "use strict";
143
+ init_width();
64
144
  FORCE = pc.createColors(true);
65
145
  c = {
66
146
  /** Green — success / healthy. */
@@ -159,17 +239,18 @@ function table(rows, cols, opts = {}) {
159
239
  info("(no rows)", opts);
160
240
  return;
161
241
  }
242
+ const widths = computeColWidths(rows, cols);
162
243
  const t = new Table({
163
244
  head: cols.map((col) => c.bold(c.info(col))),
164
245
  // Empty arrays ⇒ cli-table3 applies NO @colors/colors wrap (neither the
165
246
  // default red header nor a colored border). All color is ours, via theme.
166
247
  style: { head: [], border: [] },
167
- colWidths: computeColWidths(rows, cols),
248
+ colWidths: widths,
168
249
  wordWrap: true,
169
250
  truncate: "\u2026"
170
251
  });
171
252
  for (const row of rows) {
172
- t.push(cols.map((col) => formatCell(row[col])));
253
+ t.push(cols.map((col, i) => truncateCell(row[col], (widths[i] ?? 0) - 2)));
173
254
  }
174
255
  process.stderr.write(`${t.toString()}
175
256
  `);
@@ -177,43 +258,71 @@ function table(rows, cols, opts = {}) {
177
258
  function computeColWidths(rows, cols) {
178
259
  const n = cols.length;
179
260
  if (n === 0) return [];
180
- const headerLen = cols.map((col) => col.length);
261
+ const headerLen = cols.map((col) => displayWidth(col));
181
262
  const natural = cols.map((col, i) => {
182
- let max = Math.max(headerLen[i] ?? col.length, 3);
263
+ let max = Math.max(headerLen[i] ?? displayWidth(col), MIN_CONTENT_WIDTH);
183
264
  for (const row of rows) {
184
- const len = formatCell(row[col]).length;
265
+ const len = displayWidth(formatCell(row[col]));
185
266
  if (len > max) max = len;
186
267
  }
187
268
  return max;
188
269
  });
189
- const contentBudget = Math.max(n * 4, terminalWidth() - 3 * n - 1);
270
+ const budget = Math.max(n, terminalWidth() - 3 * n - 1);
190
271
  const naturalSum = natural.reduce((a, b) => a + b, 0);
191
- if (naturalSum <= contentBudget) {
272
+ if (naturalSum <= budget) {
192
273
  return natural.map((w) => w + 2);
193
274
  }
194
275
  const content = natural.slice();
195
276
  let sum = naturalSum;
196
- const floor = headerLen.map((h) => Math.max(h, 3));
197
- while (sum > contentBudget) {
277
+ const floor = headerLen.map((h) => Math.min(Math.max(h, MIN_CONTENT_WIDTH), budget));
278
+ while (sum > budget) {
198
279
  let idx = -1;
199
280
  let widest = -1;
200
281
  for (let i = 0; i < n; i++) {
201
282
  const c2 = content[i] ?? 0;
202
- const f = floor[i] ?? 3;
283
+ const f = floor[i] ?? MIN_CONTENT_WIDTH;
203
284
  if (c2 > f && c2 > widest) {
204
285
  widest = c2;
205
286
  idx = i;
206
287
  }
207
288
  }
208
289
  if (idx === -1) break;
209
- const overshoot = sum - contentBudget;
210
- const reducible = (content[idx] ?? 0) - (floor[idx] ?? 3);
290
+ const overshoot = sum - budget;
291
+ const reducible = (content[idx] ?? 0) - (floor[idx] ?? MIN_CONTENT_WIDTH);
211
292
  const cut = Math.min(overshoot, reducible);
212
293
  content[idx] = (content[idx] ?? 0) - cut;
213
294
  sum -= cut;
214
295
  }
296
+ if (sum > budget) {
297
+ for (let pass = 0; pass < n && sum > budget; pass++) {
298
+ let idx = -1;
299
+ let widest = 0;
300
+ for (let j = 0; j < n; j++) {
301
+ const c2 = content[j] ?? 0;
302
+ if (c2 > 1 && c2 > widest) {
303
+ widest = c2;
304
+ idx = j;
305
+ }
306
+ }
307
+ if (idx === -1) break;
308
+ const cut = Math.min(sum - budget, (content[idx] ?? 1) - 1);
309
+ content[idx] = (content[idx] ?? 1) - cut;
310
+ sum -= cut;
311
+ }
312
+ }
215
313
  return content.map((w) => w + 2);
216
314
  }
315
+ function isPathToken(s) {
316
+ if (!/[\\/]/.test(s)) return false;
317
+ return /^(\/|\.\/|\.\.\/|~\/|[A-Za-z]:[\\/])/.test(s) || /\.[A-Za-z0-9][A-Za-z0-9_-]*$/.test(s);
318
+ }
319
+ function truncateCell(value, contentWidth) {
320
+ const s = formatCell(value);
321
+ if (contentWidth <= 0 || displayWidth(s) <= contentWidth) return s;
322
+ if (isPathToken(s)) return truncateMiddle(s, contentWidth);
323
+ if (/\s/.test(s)) return s;
324
+ return truncateToWidth(s, contentWidth);
325
+ }
217
326
  function formatCell(value) {
218
327
  if (value === void 0 || value === null) return "";
219
328
  if (typeof value === "string") return value;
@@ -224,7 +333,7 @@ function formatCell(value) {
224
333
  }
225
334
  function definitionList(rows, opts = {}) {
226
335
  table(
227
- rows.map((r) => ({ Field: r.label, Value: formatCell(r.value) })),
336
+ rows.map((r) => ({ Field: r.label, Value: r.value })),
228
337
  ["Field", "Value"],
229
338
  opts
230
339
  );
@@ -305,11 +414,12 @@ function handleError(err) {
305
414
  }
306
415
  process.exitCode = inferExitCode(err);
307
416
  }
308
- var EXIT, NoirCliError, NOIR_ERROR_CODE, NoopSpinner, OraSpinner, EXIT_FLUSH_MS;
417
+ var EXIT, NoirCliError, NOIR_ERROR_CODE, MIN_CONTENT_WIDTH, NoopSpinner, OraSpinner, EXIT_FLUSH_MS;
309
418
  var init_output = __esm({
310
419
  "src/output.ts"() {
311
420
  "use strict";
312
421
  init_theme();
422
+ init_width();
313
423
  EXIT = {
314
424
  OK: 0,
315
425
  ERROR: 1,
@@ -327,6 +437,7 @@ var init_output = __esm({
327
437
  }
328
438
  };
329
439
  NOIR_ERROR_CODE = "noir.error";
440
+ MIN_CONTENT_WIDTH = 3;
330
441
  NoopSpinner = class {
331
442
  text = "";
332
443
  start(_text) {
@@ -561,14 +672,230 @@ var init_context = __esm({
561
672
  }
562
673
  });
563
674
 
675
+ // src/workspace-bridge.ts
676
+ import {
677
+ ReadBuffer,
678
+ StreamableHTTPClientTransport,
679
+ serializeMessage
680
+ } from "@modelcontextprotocol/client";
681
+ import { loadProjectInfo } from "@noir-ai/core";
682
+ import { pidAlive, readDaemonToken, readWorkspaceDaemonRecord, tokenPath } from "@noir-ai/daemon";
683
+ async function resolveWorkspaceDaemon(name, root) {
684
+ const record = readWorkspaceDaemonRecord(name);
685
+ if (record === null) {
686
+ return { error: `no daemon recorded for workspace ${name}` };
687
+ }
688
+ const health = await verifyWorkspaceDaemon(record, name);
689
+ if (health.kind === "foreign") {
690
+ return {
691
+ error: `workspace ${name} is recorded on port ${record.port}, but the daemon answering there serves workspace ${health.workspace}`
692
+ };
693
+ }
694
+ if (health.kind === "silent") {
695
+ return { error: `record exists but the daemon is not answering (pid ${record.pid})` };
696
+ }
697
+ const token = readDaemonToken(name);
698
+ if (token === null) {
699
+ return {
700
+ error: `workspace ${name} daemon is answering but its token is unreadable at ${tokenPath(name)}`
701
+ };
702
+ }
703
+ let callerProjectId;
704
+ try {
705
+ callerProjectId = loadProjectInfo(root).id;
706
+ } catch {
707
+ return {
708
+ error: `no project identity in ${root} \u2014 run \`noir init\` before serving a workspace from here`
709
+ };
710
+ }
711
+ return { url: `http://127.0.0.1:${record.port}/mcp?p=${callerProjectId}`, token };
712
+ }
713
+ async function verifyWorkspaceDaemon(record, name) {
714
+ if (!pidAlive(record.pid)) return { kind: "silent" };
715
+ return probeHealth(record.port, name, record.pid);
716
+ }
717
+ async function probeHealth(port, name, pid) {
718
+ try {
719
+ const response = await fetch(`http://127.0.0.1:${port}/health`, {
720
+ signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS)
721
+ });
722
+ if (!response.ok) return { kind: "silent" };
723
+ const body = await response.json().catch(() => null);
724
+ if (body === null) return { kind: "silent" };
725
+ if (typeof body.workspace === "string" && body.workspace !== name) {
726
+ return { kind: "foreign", workspace: body.workspace };
727
+ }
728
+ if (body.ok !== true || body.pid !== pid) return { kind: "silent" };
729
+ return { kind: "healthy" };
730
+ } catch {
731
+ return { kind: "silent" };
732
+ }
733
+ }
734
+ async function bridgeStdioToWorkspace(name, root) {
735
+ const resolved = await resolveWorkspaceDaemon(name, root);
736
+ if ("error" in resolved) {
737
+ fail(EXIT.DAEMON_DOWN, resolved.error);
738
+ }
739
+ const host = new StdioBridgeTransport(process.stdin, process.stdout);
740
+ const daemon = new StreamableHTTPClientTransport(new URL(resolved.url), {
741
+ requestInit: { headers: { Authorization: `Bearer ${resolved.token}` } }
742
+ });
743
+ try {
744
+ await relay(host, daemon);
745
+ } finally {
746
+ await daemon.close().catch(() => {
747
+ });
748
+ await host.close().catch(() => {
749
+ });
750
+ }
751
+ }
752
+ async function relay(host, daemon) {
753
+ await new Promise((resolve3) => {
754
+ let settled = false;
755
+ let closing = false;
756
+ const settle = () => {
757
+ if (settled) return;
758
+ settled = true;
759
+ closing = true;
760
+ resolve3();
761
+ };
762
+ const closeDaemon = () => {
763
+ closing = true;
764
+ void daemon.close().catch(() => {
765
+ });
766
+ };
767
+ host.onmessage = (message) => {
768
+ void daemon.send(message).catch(closeDaemon);
769
+ };
770
+ host.onclose = () => {
771
+ closeDaemon();
772
+ settle();
773
+ };
774
+ host.onerror = () => {
775
+ closeDaemon();
776
+ settle();
777
+ };
778
+ daemon.onmessage = (message) => {
779
+ void host.send(message).catch(() => {
780
+ });
781
+ };
782
+ daemon.onclose = () => {
783
+ void host.close().catch(() => {
784
+ });
785
+ settle();
786
+ };
787
+ daemon.onerror = (error2) => {
788
+ if (closing) return;
789
+ process.stderr.write(
790
+ `noir: workspace bridge: ${error2 instanceof Error ? error2.message : String(error2)}
791
+ `
792
+ );
793
+ };
794
+ const failStart = (error2) => {
795
+ process.stderr.write(
796
+ `noir: workspace bridge: ${error2 instanceof Error ? error2.message : String(error2)}
797
+ `
798
+ );
799
+ host.onerror?.(error2);
800
+ closeDaemon();
801
+ settle();
802
+ };
803
+ void daemon.start().catch(failStart);
804
+ void host.start().catch(failStart);
805
+ });
806
+ }
807
+ function toError(value) {
808
+ return value instanceof Error ? value : new Error(String(value));
809
+ }
810
+ var HEALTH_PROBE_TIMEOUT_MS, StdioBridgeTransport;
811
+ var init_workspace_bridge = __esm({
812
+ "src/workspace-bridge.ts"() {
813
+ "use strict";
814
+ init_output();
815
+ HEALTH_PROBE_TIMEOUT_MS = 1500;
816
+ StdioBridgeTransport = class {
817
+ constructor(stdin, stdout) {
818
+ this.stdin = stdin;
819
+ this.stdout = stdout;
820
+ }
821
+ stdin;
822
+ stdout;
823
+ onclose;
824
+ onerror;
825
+ onmessage;
826
+ buffer = new ReadBuffer();
827
+ started = false;
828
+ closed = false;
829
+ async start() {
830
+ if (this.started) throw new Error("the stdio bridge transport is already started");
831
+ this.started = true;
832
+ this.stdin.on("data", this.onData);
833
+ this.stdin.on("error", this.onStdinFault);
834
+ this.stdin.on("end", this.onStdinEnd);
835
+ this.stdout.on("error", this.onStdoutFault);
836
+ }
837
+ send(message) {
838
+ if (this.closed) return Promise.reject(new Error("the stdio bridge transport is closed"));
839
+ return new Promise((resolve3, reject) => {
840
+ if (this.stdout.write(serializeMessage(message))) {
841
+ resolve3();
842
+ return;
843
+ }
844
+ const onDrain = () => {
845
+ this.stdout.off("error", onWriteError);
846
+ resolve3();
847
+ };
848
+ const onWriteError = (error2) => {
849
+ this.stdout.off("drain", onDrain);
850
+ reject(error2);
851
+ };
852
+ this.stdout.once("drain", onDrain);
853
+ this.stdout.once("error", onWriteError);
854
+ });
855
+ }
856
+ async close() {
857
+ if (this.closed) return;
858
+ this.closed = true;
859
+ this.stdin.off("data", this.onData);
860
+ this.stdin.off("error", this.onStdinFault);
861
+ this.stdin.off("end", this.onStdinEnd);
862
+ this.stdout.off("error", this.onStdoutFault);
863
+ this.buffer.clear();
864
+ if (this.stdin.listenerCount("data") === 0) this.stdin.pause();
865
+ this.onclose?.();
866
+ }
867
+ onData = (chunk) => {
868
+ try {
869
+ this.buffer.append(chunk);
870
+ for (; ; ) {
871
+ const message = this.buffer.readMessage();
872
+ if (message === null) return;
873
+ this.onmessage?.(message);
874
+ }
875
+ } catch (error2) {
876
+ this.onerror?.(toError(error2));
877
+ void this.close();
878
+ }
879
+ };
880
+ /** The host closed its end of the pipe — the session is over. */
881
+ onStdinEnd = () => {
882
+ void this.close();
883
+ };
884
+ onStdinFault = (error2) => {
885
+ this.onerror?.(error2);
886
+ void this.close();
887
+ };
888
+ onStdoutFault = (error2) => {
889
+ this.onerror?.(error2);
890
+ };
891
+ };
892
+ }
893
+ });
894
+
564
895
  // src/workspace-mcp.ts
565
896
  import { existsSync, readFileSync } from "fs";
566
- import { join } from "path";
567
- import { resolveAdapter } from "@noir-ai/adapters";
897
+ import { mcpConfigPathFor, noirStdioArgs, TRANSPORT_KEYS } from "@noir-ai/adapters";
568
898
  import { atomicWriteFile, resolveNoirCommand } from "@noir-ai/core";
569
- function mcpPathFor(root, host) {
570
- return resolveAdapter(host).mcpConfigPath?.({ root }) ?? join(root, ".mcp.json");
571
- }
572
899
  function readJson(path) {
573
900
  if (!existsSync(path)) return null;
574
901
  return JSON.parse(readFileSync(path, "utf8"));
@@ -602,26 +929,37 @@ function isNoirEmitted(mcpServers) {
602
929
  if (typeof noir.command === "string") return true;
603
930
  return noir.type === "http";
604
931
  }
605
- function writeWorkspaceHttpEntry(root, host, url, projectId, opts) {
606
- const path = mcpPathFor(root, host);
932
+ function noirEntry(mcpServers) {
933
+ const noir = mcpServers?.noir;
934
+ if (typeof noir !== "object" || noir === null || Array.isArray(noir)) return {};
935
+ return noir;
936
+ }
937
+ function writeNoirTransport(root, host, transport, opts) {
938
+ const path = mcpConfigPathFor(host, root);
607
939
  const existing = loadExisting(path, opts);
940
+ const kept = Object.fromEntries(
941
+ Object.entries(noirEntry(existing?.mcpServers)).filter(([key]) => !TRANSPORT_KEYS.has(key))
942
+ );
608
943
  const next = {
609
944
  ...existing ?? {},
610
945
  mcpServers: {
611
946
  ...existing?.mcpServers ?? {},
612
- noir: { type: "http", url: `${url}?p=${projectId}` }
947
+ noir: { ...transport, ...kept }
613
948
  }
614
949
  };
615
950
  atomicWriteFile(path, `${JSON.stringify(next, null, 2)}
616
951
  `);
617
952
  }
953
+ function writeWorkspaceEntry(root, host, name, opts) {
954
+ writeNoirTransport(
955
+ root,
956
+ host,
957
+ { command: resolveNoirCommand(), args: noirStdioArgs(name) },
958
+ opts
959
+ );
960
+ }
618
961
  function writeStdioEntry(root, host, opts) {
619
- const path = mcpPathFor(root, host);
620
- const existing = loadExisting(path, opts);
621
- const noir = { command: resolveNoirCommand(), args: ["mcp", "serve", "--stdio"] };
622
- const next = { ...existing ?? {}, mcpServers: { ...existing?.mcpServers ?? {}, noir } };
623
- atomicWriteFile(path, `${JSON.stringify(next, null, 2)}
624
- `);
962
+ writeNoirTransport(root, host, { command: resolveNoirCommand(), args: noirStdioArgs() }, opts);
625
963
  }
626
964
  var init_workspace_mcp = __esm({
627
965
  "src/workspace-mcp.ts"() {
@@ -637,7 +975,7 @@ import {
637
975
  ensureWorkspaceRegistry,
638
976
  isValidWorkspaceName,
639
977
  isWorkspaceMember,
640
- loadProjectInfo,
978
+ loadProjectInfo as loadProjectInfo2,
641
979
  readWorkspaceMarker,
642
980
  readWorkspaceRegistry,
643
981
  removeWorkspaceMember,
@@ -648,20 +986,20 @@ import {
648
986
  import {
649
987
  clearWorkspaceDaemonRecord,
650
988
  ensureWorkspaceDaemonRunning,
651
- pidAlive,
652
- readWorkspaceDaemonRecord,
989
+ pidAlive as pidAlive2,
990
+ readWorkspaceDaemonRecord as readWorkspaceDaemonRecord2,
653
991
  spawnDetachedWorkspaceDaemon
654
992
  } from "@noir-ai/daemon";
655
993
  function loadProjectOrFail(opts) {
656
994
  try {
657
- return loadProjectInfo(process.cwd());
995
+ return loadProjectInfo2(process.cwd());
658
996
  } catch {
659
997
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
660
998
  }
661
999
  }
662
- function finishJoin(project, name, url, opts) {
1000
+ function finishJoin(project, name, opts) {
663
1001
  writeWorkspaceMarker(project.root, name);
664
- writeWorkspaceHttpEntry(project.root, project.config.host, url, project.id, opts);
1002
+ writeWorkspaceEntry(project.root, project.config.host, name, opts);
665
1003
  }
666
1004
  async function daemonStartWorkspace(opts) {
667
1005
  const project = loadProjectOrFail(opts);
@@ -694,7 +1032,7 @@ async function daemonStartWorkspace(opts) {
694
1032
  }
695
1033
  if (opts.detach === true) {
696
1034
  const spawned = await spawnDetachedWorkspaceDaemon({ name: opts.name, project });
697
- finishJoin(project, opts.name, `http://127.0.0.1:${spawned.port}/mcp`, opts);
1035
+ finishJoin(project, opts.name, opts);
698
1036
  if (opts.json === true) {
699
1037
  process.stdout.write(
700
1038
  `${JSON.stringify({ ok: true, data: { mode: "detached", pid: spawned.pid, port: spawned.port } })}
@@ -713,7 +1051,7 @@ async function daemonStartWorkspace(opts) {
713
1051
  project,
714
1052
  idleTimeoutSec: project.config.workspace.idleTimeoutSec
715
1053
  });
716
- finishJoin(project, opts.name, ensured.url, opts);
1054
+ finishJoin(project, opts.name, opts);
717
1055
  if (opts.json === true) {
718
1056
  process.stdout.write(
719
1057
  `${JSON.stringify({ ok: true, data: { url: ensured.url, port: ensured.port, reused: !ensured.started } })}
@@ -743,7 +1081,7 @@ async function daemonJoin(opts) {
743
1081
  idleTimeoutSec: project.config.workspace.idleTimeoutSec
744
1082
  });
745
1083
  upsertWorkspaceMember(reg, { projectId: project.id, root: project.root, joinedAt: Date.now() });
746
- finishJoin(project, opts.name, ensured.url, opts);
1084
+ finishJoin(project, opts.name, opts);
747
1085
  if (opts.json === true) {
748
1086
  process.stdout.write(
749
1087
  `${JSON.stringify({ ok: true, data: { joined: true, url: ensured.url, port: ensured.port } })}
@@ -760,19 +1098,20 @@ async function workspaceList(opts) {
760
1098
  } catch {
761
1099
  names = [];
762
1100
  }
763
- const rows = names.map((n) => {
764
- const reg = readWorkspaceRegistry(n);
765
- return {
766
- name: n,
767
- members: reg?.members.length ?? 0,
768
- // A record file whose pid is dead (daemon killed without cleanup) must not
769
- // read as "running" — mirror the pidAlive liveness `workspace status` uses.
770
- running: (() => {
771
- const rec = readWorkspaceDaemonRecord(n);
772
- return rec !== null && pidAlive(rec.pid);
773
- })()
774
- };
775
- });
1101
+ const rows = await Promise.all(
1102
+ names.map(async (n) => {
1103
+ const reg = readWorkspaceRegistry(n);
1104
+ const rec = readWorkspaceDaemonRecord2(n);
1105
+ const health = rec === null ? null : await verifyWorkspaceDaemon(rec, n);
1106
+ const running = health?.kind === "healthy";
1107
+ return {
1108
+ name: n,
1109
+ members: reg?.members.length ?? 0,
1110
+ running,
1111
+ ...rec !== null && !running ? { stale: true } : {}
1112
+ };
1113
+ })
1114
+ );
776
1115
  if (opts.json === true) {
777
1116
  process.stdout.write(`${JSON.stringify({ ok: true, data: { workspaces: rows } })}
778
1117
  `);
@@ -783,7 +1122,8 @@ async function workspaceList(opts) {
783
1122
  return;
784
1123
  }
785
1124
  for (const r of rows) {
786
- log(`${r.name} \u2014 ${r.members} member(s)${r.running ? ", running" : ""}`, opts);
1125
+ const state = r.running ? ", running" : r.stale ? ", not running (stale record)" : "";
1126
+ log(`${r.name} \u2014 ${r.members} member(s)${state}`, opts);
787
1127
  }
788
1128
  }
789
1129
  async function workspaceStatus(opts) {
@@ -795,23 +1135,23 @@ async function workspaceStatus(opts) {
795
1135
  fail(EXIT.USAGE, `invalid workspace name ${JSON.stringify(name)}.`, opts);
796
1136
  }
797
1137
  const reg = readWorkspaceRegistry(name);
798
- const rec = readWorkspaceDaemonRecord(name);
799
- const running = rec !== null && pidAlive(rec.pid);
1138
+ const rec = readWorkspaceDaemonRecord2(name);
1139
+ const health = rec === null ? null : await verifyWorkspaceDaemon(rec, name);
1140
+ const running = health?.kind === "healthy";
800
1141
  const data = {
801
1142
  name,
802
1143
  members: reg?.members.map((m) => m.projectId) ?? [],
803
1144
  running,
804
- ...rec ? { pid: rec.pid, port: rec.port } : {}
1145
+ ...rec ? { pid: rec.pid, port: rec.port } : {},
1146
+ ...rec !== null && !running ? { stale: true } : {}
805
1147
  };
806
1148
  if (opts.json === true) {
807
1149
  process.stdout.write(`${JSON.stringify({ ok: true, data })}
808
1150
  `);
809
1151
  return;
810
1152
  }
811
- log(
812
- `workspace ${name}: ${running ? `running (pid ${rec?.pid}, port ${rec?.port})` : "not running"}`,
813
- opts
814
- );
1153
+ const state = running ? `running (pid ${rec?.pid}, port ${rec?.port})` : rec !== null ? `not running (stale record for pid ${rec.pid})` : "not running";
1154
+ log(`workspace ${name}: ${state}`, opts);
815
1155
  for (const m of data.members) log(` member: ${m}`, opts);
816
1156
  }
817
1157
  async function workspaceLeave(opts) {
@@ -841,8 +1181,8 @@ async function workspaceStop(opts) {
841
1181
  if (!isValidWorkspaceName(name)) {
842
1182
  fail(EXIT.USAGE, `invalid workspace name ${JSON.stringify(name)}.`, opts);
843
1183
  }
844
- const rec = readWorkspaceDaemonRecord(name);
845
- if (rec === null || !pidAlive(rec.pid)) {
1184
+ const rec = readWorkspaceDaemonRecord2(name);
1185
+ if (rec === null || !pidAlive2(rec.pid)) {
846
1186
  if (opts.json === true) {
847
1187
  process.stdout.write(
848
1188
  `${JSON.stringify({ ok: true, data: { running: false, stopped: false } })}
@@ -853,6 +1193,10 @@ async function workspaceStop(opts) {
853
1193
  info("workspace daemon is not running.", opts);
854
1194
  return;
855
1195
  }
1196
+ const health = await verifyWorkspaceDaemon(rec, name);
1197
+ if (health.kind !== "healthy") {
1198
+ fail(EXIT.ERROR, stopRefusal(name, rec, health), opts);
1199
+ }
856
1200
  try {
857
1201
  process.kill(rec.pid, "SIGTERM");
858
1202
  } catch {
@@ -867,21 +1211,28 @@ async function workspaceStop(opts) {
867
1211
  }
868
1212
  info(`stopped workspace daemon (pid ${rec.pid}).`, opts);
869
1213
  }
1214
+ function stopRefusal(name, rec, health) {
1215
+ if (health.kind === "foreign") {
1216
+ 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.`;
1217
+ }
1218
+ 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.`;
1219
+ }
870
1220
  var init_workspace = __esm({
871
1221
  "src/commands/workspace.ts"() {
872
1222
  "use strict";
873
1223
  init_output();
1224
+ init_workspace_bridge();
874
1225
  init_workspace_mcp();
875
1226
  }
876
1227
  });
877
1228
 
878
1229
  // src/commands/daemon.ts
879
- import { loadProjectInfo as loadProjectInfo2 } from "@noir-ai/core";
1230
+ import { loadProjectInfo as loadProjectInfo3 } from "@noir-ai/core";
880
1231
  import {
881
1232
  clearProjectDaemonRecord,
882
1233
  ensureDaemonRunning,
883
- pidAlive as pidAlive2,
884
- readDaemonToken,
1234
+ pidAlive as pidAlive3,
1235
+ readDaemonToken as readDaemonToken2,
885
1236
  readProjectDaemonRecord,
886
1237
  spawnDetachedDaemon
887
1238
  } from "@noir-ai/daemon";
@@ -893,7 +1244,7 @@ function formatUptime(sec) {
893
1244
  }
894
1245
  function resolveCallerProjectId() {
895
1246
  try {
896
- return loadProjectInfo2(process.cwd()).id;
1247
+ return loadProjectInfo3(process.cwd()).id;
897
1248
  } catch {
898
1249
  return void 0;
899
1250
  }
@@ -925,7 +1276,7 @@ async function daemonStart(opts) {
925
1276
  }
926
1277
  let project;
927
1278
  try {
928
- project = loadProjectInfo2(process.cwd());
1279
+ project = loadProjectInfo3(process.cwd());
929
1280
  } catch {
930
1281
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
931
1282
  }
@@ -957,7 +1308,7 @@ async function daemonStart(opts) {
957
1308
  }
958
1309
  if (opts.detach === true) {
959
1310
  const existing = readProjectDaemonRecord(project.id);
960
- if (existing && pidAlive2(existing.pid) && await isHealthy(existing.port, existing.pid, project.id)) {
1311
+ if (existing && pidAlive3(existing.pid) && await isHealthy(existing.port, existing.pid, project.id)) {
961
1312
  if (opts.json === true) {
962
1313
  process.stdout.write(
963
1314
  `${JSON.stringify({ ok: true, data: { mode: "detached", reused: true } })}
@@ -1067,7 +1418,7 @@ async function daemonStop(opts) {
1067
1418
  const ds = spinner(`Stopping daemon (pid ${rec.pid})...`, opts).start();
1068
1419
  let signalled = false;
1069
1420
  let errMsg2;
1070
- const healthy = pidAlive2(rec.pid) && await isHealthy(rec.port, rec.pid, callerProject);
1421
+ const healthy = pidAlive3(rec.pid) && await isHealthy(rec.port, rec.pid, callerProject);
1071
1422
  if (healthy) {
1072
1423
  try {
1073
1424
  process.kill(rec.pid, "SIGTERM");
@@ -1109,7 +1460,7 @@ async function daemonStatus(opts) {
1109
1460
  if (!rec) {
1110
1461
  fail(EXIT.DAEMON_DOWN, "Noir daemon is not running (start with `noir daemon start`).", opts);
1111
1462
  }
1112
- if (!pidAlive2(rec.pid)) {
1463
+ if (!pidAlive3(rec.pid)) {
1113
1464
  clearProjectDaemonRecord(callerProject);
1114
1465
  fail(EXIT.DAEMON_DOWN, "Noir daemon is not running (stale record removed).", opts);
1115
1466
  }
@@ -1171,14 +1522,14 @@ async function daemonToken(opts) {
1171
1522
  opts
1172
1523
  );
1173
1524
  }
1174
- if (!pidAlive2(rec.pid) || !await isHealthy(rec.port, rec.pid, callerProject)) {
1525
+ if (!pidAlive3(rec.pid) || !await isHealthy(rec.port, rec.pid, callerProject)) {
1175
1526
  fail(
1176
1527
  EXIT.DAEMON_DOWN,
1177
1528
  "Noir daemon is not running (stale record \u2014 start it with `noir daemon start`).",
1178
1529
  opts
1179
1530
  );
1180
1531
  }
1181
- const token = readDaemonToken(callerProject);
1532
+ const token = readDaemonToken2(callerProject);
1182
1533
  if (token === null) {
1183
1534
  fail(
1184
1535
  EXIT.DAEMON_DOWN,
@@ -1210,18 +1561,18 @@ var init_daemon = __esm({
1210
1561
  });
1211
1562
 
1212
1563
  // src/commands/env.ts
1213
- import { loadNoirEnv, loadProjectInfo as loadProjectInfo3 } from "@noir-ai/core";
1564
+ import { loadNoirEnv, loadProjectInfo as loadProjectInfo4 } from "@noir-ai/core";
1214
1565
  import { resolveModelConfig } from "@noir-ai/model";
1215
1566
  function captureAmbientEnv(env2 = process.env) {
1216
1567
  ambientBaseline = { ...env2 };
1217
1568
  }
1218
1569
  function redactShape(value) {
1219
1570
  if (value.length <= SHORT_VALUE_MAX) return `\u2026(${value.length})`;
1220
- return `${value.slice(0, 3)}\u2026(${value.length})`;
1571
+ return `${truncateToWidth(value, 3, "")}\u2026(${value.length})`;
1221
1572
  }
1222
1573
  function configApiKeyEnvNames(root) {
1223
1574
  try {
1224
- const project = loadProjectInfo3(root);
1575
+ const project = loadProjectInfo4(root);
1225
1576
  const names = /* @__PURE__ */ new Set();
1226
1577
  for (const provider of Object.values(resolveModelConfig(project.config.model).providers)) {
1227
1578
  if (provider.apiKeyEnv !== void 0 && provider.apiKeyEnv.length > 0) {
@@ -1290,6 +1641,7 @@ var init_env = __esm({
1290
1641
  "src/commands/env.ts"() {
1291
1642
  "use strict";
1292
1643
  init_output();
1644
+ init_width();
1293
1645
  CURATED_AMBIENT_KEYS = [
1294
1646
  "CLICKUP_API_TOKEN",
1295
1647
  "OPENAI_API_KEY",
@@ -1314,13 +1666,13 @@ __export(install_exports, {
1314
1666
  installManagedNode: () => installManagedNode
1315
1667
  });
1316
1668
  import { chmodSync, mkdirSync } from "fs";
1317
- import { join as join2 } from "path";
1669
+ import { join } from "path";
1318
1670
  import {
1319
1671
  atomicWriteFile as atomicWriteFile2,
1320
1672
  detectActiveMethod,
1321
1673
  detectInstallMethods,
1322
1674
  ensureShimExecutable,
1323
- loadProjectInfo as loadProjectInfo4,
1675
+ loadProjectInfo as loadProjectInfo5,
1324
1676
  MANAGED_NODE_VERSION,
1325
1677
  NOIR_VERSION,
1326
1678
  noirHome,
@@ -1366,8 +1718,8 @@ function buildMigrationPlan(opts) {
1366
1718
  async function installManagedNode(opts) {
1367
1719
  const spec = opts.version ?? opts.channel ?? "latest";
1368
1720
  const home2 = noirHome();
1369
- const cliDir = join2(home2, "cli");
1370
- const binDir = join2(home2, "bin");
1721
+ const cliDir = join(home2, "cli");
1722
+ const binDir = join(home2, "bin");
1371
1723
  mkdirSync(cliDir, { recursive: true });
1372
1724
  mkdirSync(binDir, { recursive: true });
1373
1725
  let runtime;
@@ -1393,9 +1745,9 @@ async function installManagedNode(opts) {
1393
1745
  if (code !== 0) {
1394
1746
  return { ok: false, version: null, error: `npm install failed: ${stderr.slice(0, 300)}` };
1395
1747
  }
1396
- const shim = join2(binDir, "noir");
1748
+ const shim = join(binDir, "noir");
1397
1749
  const shimBody = `#!/usr/bin/env bash
1398
- "${nodeBin}" "${join2(cliDir, "lib", "node_modules", "@noir-ai", "cli", "dist", "bin.js")}" "$@"
1750
+ "${nodeBin}" "${join(cliDir, "lib", "node_modules", "@noir-ai", "cli", "dist", "bin.js")}" "$@"
1399
1751
  `;
1400
1752
  atomicWriteFile2(shim, shimBody);
1401
1753
  chmodSync(shim, 493);
@@ -1403,7 +1755,7 @@ async function installManagedNode(opts) {
1403
1755
  const ver = await runManagerCmd(
1404
1756
  [
1405
1757
  nodeBin,
1406
- join2(cliDir, "lib", "node_modules", "@noir-ai", "cli", "dist", "bin.js"),
1758
+ join(cliDir, "lib", "node_modules", "@noir-ai", "cli", "dist", "bin.js"),
1407
1759
  "--version"
1408
1760
  ],
1409
1761
  { env: opts.env, timeoutMs: 3e4 }
@@ -1459,7 +1811,7 @@ async function install(opts = {}) {
1459
1811
  const installedRecord = readInstallRecord();
1460
1812
  let minVersion = "1.6.0";
1461
1813
  try {
1462
- minVersion = loadProjectInfo4(process.cwd()).config.update.minVersion;
1814
+ minVersion = loadProjectInfo5(process.cwd()).config.update.minVersion;
1463
1815
  } catch {
1464
1816
  }
1465
1817
  const plan = buildMigrationPlan({
@@ -1536,7 +1888,7 @@ import {
1536
1888
  detectActiveMethod as detectActiveMethod2,
1537
1889
  ensureShimExecutable as ensureShimExecutable2,
1538
1890
  fetchLatestVersion,
1539
- loadProjectInfo as loadProjectInfo5,
1891
+ loadProjectInfo as loadProjectInfo6,
1540
1892
  readInstallRecord as readInstallRecord2,
1541
1893
  readUpdateCache,
1542
1894
  runManagerCmd as runManagerCmd2,
@@ -1616,7 +1968,7 @@ async function update(opts = {}) {
1616
1968
  fetchSpin.succeed(`Latest: ${latest}`);
1617
1969
  let minVersion = DEFAULT_UPDATE_CONFIG.minVersion;
1618
1970
  try {
1619
- minVersion = loadProjectInfo5(process.cwd()).config.update.minVersion;
1971
+ minVersion = loadProjectInfo6(process.cwd()).config.update.minVersion;
1620
1972
  } catch {
1621
1973
  }
1622
1974
  const target = buildUpdateTarget({
@@ -1766,7 +2118,7 @@ var init_update = __esm({
1766
2118
  });
1767
2119
 
1768
2120
  // src/commands/status.ts
1769
- import { loadProjectInfo as loadProjectInfo6, NOIR_VERSION as NOIR_VERSION2 } from "@noir-ai/core";
2121
+ import { loadProjectInfo as loadProjectInfo7, NOIR_VERSION as NOIR_VERSION2 } from "@noir-ai/core";
1770
2122
  async function tryTool(caller, name, args) {
1771
2123
  try {
1772
2124
  return await caller.callTool(name, args);
@@ -1887,7 +2239,7 @@ function renderHuman(p, opts) {
1887
2239
  async function gatherStatusPayload(opts) {
1888
2240
  let project;
1889
2241
  try {
1890
- project = loadProjectInfo6(process.cwd());
2242
+ project = loadProjectInfo7(process.cwd());
1891
2243
  } catch {
1892
2244
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
1893
2245
  }
@@ -1917,7 +2269,7 @@ async function status(opts) {
1917
2269
  env: process.env,
1918
2270
  configUpdate: (() => {
1919
2271
  try {
1920
- const info2 = loadProjectInfo6(process.cwd());
2272
+ const info2 = loadProjectInfo7(process.cwd());
1921
2273
  return info2.config.update ?? DEFAULT_UPDATE_CONFIG;
1922
2274
  } catch {
1923
2275
  return DEFAULT_UPDATE_CONFIG;
@@ -1946,7 +2298,7 @@ var init_status = __esm({
1946
2298
  // src/commands/task.ts
1947
2299
  import { spawnSync } from "child_process";
1948
2300
  import { writeFileSync } from "fs";
1949
- import { loadProjectInfo as loadProjectInfo7, nextArtifactSequence, sha256Hex } from "@noir-ai/core";
2301
+ import { loadProjectInfo as loadProjectInfo8, nextArtifactSequence, sha256Hex } from "@noir-ai/core";
1950
2302
  import {
1951
2303
  PHASES,
1952
2304
  TASK_CLASSES,
@@ -1959,7 +2311,7 @@ function skillFor(phase) {
1959
2311
  }
1960
2312
  function writeDoneArtifacts(opts, taskId) {
1961
2313
  try {
1962
- const project = loadProjectInfo7(process.cwd());
2314
+ const project = loadProjectInfo8(process.cwd());
1963
2315
  const conflict = { conflictPolicy: "preserve", interactive: false };
1964
2316
  writeChangelogStub(project.root, `- ${taskId}: completed (verify gate passed)`, conflict);
1965
2317
  const nextN = nextArtifactSequence(project.root, "adr");
@@ -2282,7 +2634,7 @@ function runCheck(cmd) {
2282
2634
  async function taskVerify(opts) {
2283
2635
  let project;
2284
2636
  try {
2285
- project = loadProjectInfo7(process.cwd());
2637
+ project = loadProjectInfo8(process.cwd());
2286
2638
  } catch {
2287
2639
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
2288
2640
  }
@@ -2388,7 +2740,7 @@ async function taskResearch(opts) {
2388
2740
  return;
2389
2741
  }
2390
2742
  renderStatusRow(s, opts);
2391
- info("research findings are recorded via `noir task research record` (list + record)", opts);
2743
+ info("this is a status view; record findings with `noir task research-record`", opts);
2392
2744
  }
2393
2745
  async function taskResearchRecord(opts) {
2394
2746
  const TYPES = ["assumption", "discovery", "decision", "grounding-fact"];
@@ -2412,10 +2764,7 @@ async function taskResearchRecord(opts) {
2412
2764
  `);
2413
2765
  return;
2414
2766
  }
2415
- success(
2416
- `research recorded \u2192 ${opts.type}: ${opts.text.slice(0, 80)}${opts.text.length > 80 ? "\u2026" : ""}`,
2417
- opts
2418
- );
2767
+ success(`research recorded \u2192 ${opts.type}: ${truncateToWidth(opts.text, 80)}`, opts);
2419
2768
  }
2420
2769
  async function taskDecompose(opts) {
2421
2770
  const cap = opts.capability.trim();
@@ -2471,6 +2820,7 @@ var init_task = __esm({
2471
2820
  init_daemon_client();
2472
2821
  init_output();
2473
2822
  init_theme();
2823
+ init_width();
2474
2824
  PHASE_SKILL = {
2475
2825
  // Skill curation: intake+clarify merged into brainstorming;
2476
2826
  // execute → executing-plans; verify → verifying; document → wrap.
@@ -2488,13 +2838,13 @@ var init_task = __esm({
2488
2838
 
2489
2839
  // src/commands/handoff.ts
2490
2840
  import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
2491
- import { join as join3 } from "path";
2841
+ import { join as join2 } from "path";
2492
2842
  import {
2493
2843
  defaultHandoffBlock,
2494
2844
  hostLaunchDirective,
2495
- resolveAdapter as resolveAdapter2
2845
+ resolveAdapter
2496
2846
  } from "@noir-ai/adapters";
2497
- import { loadProjectInfo as loadProjectInfo8, NOIR_DIR, resolveArtifactPath } from "@noir-ai/core";
2847
+ import { loadProjectInfo as loadProjectInfo9, NOIR_DIR, resolveArtifactPath } from "@noir-ai/core";
2498
2848
  function heading(name, id) {
2499
2849
  return `# Noir handoff \u2014 ${name} (${id})`;
2500
2850
  }
@@ -2590,7 +2940,7 @@ function toHostPayload(p) {
2590
2940
  }
2591
2941
  function resolveDirective(root, p) {
2592
2942
  const ctx = { root };
2593
- const adapter = resolveAdapter2(p.host);
2943
+ const adapter = resolveAdapter(p.host);
2594
2944
  const hostPayload = toHostPayload(p);
2595
2945
  if (typeof adapter.emitHandoff === "function") {
2596
2946
  return adapter.emitHandoff(ctx, hostPayload);
@@ -2633,7 +2983,7 @@ async function probeOnly(opts) {
2633
2983
  async function handoff(opts) {
2634
2984
  let project;
2635
2985
  try {
2636
- project = loadProjectInfo8(process.cwd());
2986
+ project = loadProjectInfo9(process.cwd());
2637
2987
  } catch {
2638
2988
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
2639
2989
  }
@@ -2665,7 +3015,7 @@ async function handoff(opts) {
2665
3015
  if (opts.write === true) {
2666
3016
  const md = renderMarkdown(full);
2667
3017
  const path = handoffFilePath(project.root, task?.taskId, project.id);
2668
- mkdirSync2(join3(project.root, NOIR_DIR, "handoff"), { recursive: true });
3018
+ mkdirSync2(join2(project.root, NOIR_DIR, "handoff"), { recursive: true });
2669
3019
  writeFileSync2(path, md, "utf8");
2670
3020
  info(`handoff written to ${path}`, opts);
2671
3021
  }
@@ -2693,7 +3043,7 @@ import gradient from "gradient-string";
2693
3043
  function renderBanner(opts = {}) {
2694
3044
  const width = opts.width ?? process.stdout.columns ?? 80;
2695
3045
  const colorOn = opts.color !== false && useColor();
2696
- if (width < 50) {
3046
+ if (width < BLOCK_MIN_WIDTH) {
2697
3047
  return colorOn ? `${c.accent("\u25C6")} noir` : "\u25C6 noir";
2698
3048
  }
2699
3049
  const block = NOIR_BLOCK.join("\n");
@@ -2709,11 +3059,12 @@ function shouldShowBanner(opts) {
2709
3059
  if (v !== void 0 && v !== "") return false;
2710
3060
  return true;
2711
3061
  }
2712
- var NOIR_BLOCK, NOIR_GRADIENT, NOIR_TAGLINE;
3062
+ var NOIR_BLOCK, NOIR_GRADIENT, NOIR_TAGLINE, BLOCK_WIDTH, BLOCK_MIN_WIDTH;
2713
3063
  var init_banner = __esm({
2714
3064
  "src/banner.ts"() {
2715
3065
  "use strict";
2716
3066
  init_theme();
3067
+ init_width();
2717
3068
  NOIR_BLOCK = [
2718
3069
  "\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 ",
2719
3070
  "\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",
@@ -2724,6 +3075,8 @@ var init_banner = __esm({
2724
3075
  ];
2725
3076
  NOIR_GRADIENT = gradient("#2c5282", "#3b82f6", "#7dd3fc");
2726
3077
  NOIR_TAGLINE = "discipline, context, and memory layer for agentic CLIs";
3078
+ BLOCK_WIDTH = NOIR_BLOCK.reduce((max, line) => Math.max(max, displayWidth(line)), 0);
3079
+ BLOCK_MIN_WIDTH = BLOCK_WIDTH + 17;
2727
3080
  }
2728
3081
  });
2729
3082
 
@@ -3023,14 +3376,14 @@ import { hostLaunchDirective as hostLaunchDirective2 } from "@noir-ai/adapters";
3023
3376
  import {
3024
3377
  detectActiveMethod as detectActiveMethod3,
3025
3378
  latestVersionFromCache,
3026
- loadProjectInfo as loadProjectInfo9,
3379
+ loadProjectInfo as loadProjectInfo10,
3027
3380
  NOIR_VERSION as NOIR_VERSION3,
3028
3381
  readInstallRecord as readInstallRecord3,
3029
3382
  readUpdateCache as readUpdateCache2
3030
3383
  } from "@noir-ai/core";
3031
3384
  function tryProject() {
3032
3385
  try {
3033
- const info2 = loadProjectInfo9(process.cwd());
3386
+ const info2 = loadProjectInfo10(process.cwd());
3034
3387
  return { id: info2.id, name: info2.name, host: info2.config.host };
3035
3388
  } catch {
3036
3389
  return null;
@@ -3193,7 +3546,7 @@ async function runMenu(opts, deps) {
3193
3546
  env: process.env,
3194
3547
  configUpdate: (() => {
3195
3548
  try {
3196
- const info2 = loadProjectInfo9(process.cwd());
3549
+ const info2 = loadProjectInfo10(process.cwd());
3197
3550
  return info2.config.update ?? DEFAULT_UPDATE_CONFIG;
3198
3551
  } catch {
3199
3552
  return DEFAULT_UPDATE_CONFIG;
@@ -3256,12 +3609,12 @@ var init_home = __esm({
3256
3609
 
3257
3610
  // src/commands/memory.ts
3258
3611
  import { readFileSync as readFileSync2 } from "fs";
3259
- import { loadProjectInfo as loadProjectInfo10 } from "@noir-ai/core";
3612
+ import { loadProjectInfo as loadProjectInfo11 } from "@noir-ai/core";
3260
3613
  function resolveWorkspace() {
3261
3614
  const routing = workspaceRoutingTarget(process.cwd());
3262
3615
  if (routing === null) return null;
3263
3616
  try {
3264
- return { routing, project: loadProjectInfo10(process.cwd()) };
3617
+ return { routing, project: loadProjectInfo11(process.cwd()) };
3265
3618
  } catch {
3266
3619
  return null;
3267
3620
  }
@@ -3674,7 +4027,7 @@ function describe(step, who) {
3674
4027
  const m = {
3675
4028
  preflight: "check clean tree, on develop, HEAD pushed, gate green, no version collision",
3676
4029
  bump: "run bump-version.mjs + update CHANGELOG + roadmap docs",
3677
- gate: "re-run the full gate (lint/build/typecheck/test/docs:validate)",
4030
+ gate: "re-run the full six-step gate (lint/build/typecheck/test/docs:validate/hygiene:gate)",
3678
4031
  commit: "commit chore(release) + push develop",
3679
4032
  "ci-develop": "wait for CI on develop \u2192 green",
3680
4033
  "beta-tag": "pnpm release:tag \u2192 beta tag + push",
@@ -3719,11 +4072,14 @@ async function release(opts) {
3719
4072
  });
3720
4073
  success(`bump: \u2713 version ${version}`, opts);
3721
4074
  info(`[3/13] gate: ${describe("gate", "noir")}`, opts);
3722
- execSync("pnpm lint && pnpm build && pnpm typecheck && pnpm test && pnpm docs:validate", {
3723
- encoding: "utf8",
3724
- stdio: "inherit"
3725
- });
3726
- success("gate: \u2713 full gate green", opts);
4075
+ execSync(
4076
+ "pnpm lint && pnpm build && pnpm typecheck && pnpm test && pnpm docs:validate && pnpm hygiene:gate",
4077
+ {
4078
+ encoding: "utf8",
4079
+ stdio: "inherit"
4080
+ }
4081
+ );
4082
+ success("gate: \u2713 all six gates green", opts);
3727
4083
  info(`[4/13] commit/push: ${describe("commit", "noir")}`, opts);
3728
4084
  spawnSync2("git", ["add", "-A"], { encoding: "utf8", stdio: "inherit" });
3729
4085
  spawnSync2("git", ["commit", "-m", `chore(release): v${version} + docs sync`], {
@@ -4583,10 +4939,10 @@ var init_run_interrupt = __esm({
4583
4939
  });
4584
4940
 
4585
4941
  // src/run-profiles.ts
4586
- import { isDeniedEnvKey, loadProjectInfo as loadProjectInfo11 } from "@noir-ai/core";
4942
+ import { isDeniedEnvKey, loadProjectInfo as loadProjectInfo12 } from "@noir-ai/core";
4587
4943
  function loadRunConfig(root) {
4588
4944
  try {
4589
- return loadProjectInfo11(root).config;
4945
+ return loadProjectInfo12(root).config;
4590
4946
  } catch {
4591
4947
  return null;
4592
4948
  }
@@ -4670,6 +5026,8 @@ var init_run_status = __esm({
4670
5026
  "src/run-status.ts"() {
4671
5027
  "use strict";
4672
5028
  init_orchestrator();
5029
+ init_theme();
5030
+ init_width();
4673
5031
  HOST_STDERR_TAIL_LINES = 20;
4674
5032
  RunStatusLine = class {
4675
5033
  host;
@@ -4752,6 +5110,7 @@ var init_run_status = __esm({
4752
5110
  if (this.animated && this.sharesCursor && this.onRow) {
4753
5111
  this.write("\n");
4754
5112
  this.onRow = false;
5113
+ this.rendered = "";
4755
5114
  }
4756
5115
  this.stdoutAtRowStart = chunk.endsWith("\n");
4757
5116
  }
@@ -4782,19 +5141,45 @@ var init_run_status = __esm({
4782
5141
  return;
4783
5142
  }
4784
5143
  if (!this.onRow || !this.cursorFree()) return;
4785
- this.write("\r\x1B[K");
5144
+ this.write(this.erase(this.rendered));
4786
5145
  this.rendered = "";
4787
5146
  this.onRow = false;
4788
5147
  }
4789
5148
  /** Rewrite the current row in place, skipping no-op rewrites and unsafe moments. */
4790
5149
  draw() {
4791
5150
  if (!this.animated || !this.cursorFree()) return;
4792
- const text = this.render();
5151
+ const text = truncateToWidth(this.render(), terminalWidth());
4793
5152
  if (text === this.rendered) return;
4794
- this.write(`\r\x1B[K${text}`);
5153
+ this.write(`${this.erase(this.rendered)}${text}`);
4795
5154
  this.rendered = text;
4796
5155
  this.onRow = true;
4797
5156
  }
5157
+ /**
5158
+ * The escape sequence that clears the status text currently on screen and
5159
+ * leaves the cursor at the start of the row it began on — where the next
5160
+ * render writes. An empty `previous` still clears the row, so a line that has
5161
+ * drawn nothing yet starts from a known-blank one.
5162
+ *
5163
+ * The extent is measured against the terminal width as it is NOW, not as it
5164
+ * was when the text was drawn. Nothing this line writes wraps, but text that
5165
+ * fitted a wider terminal does not stop existing when the window narrows: the
5166
+ * terminal folds it onto rows below, and clearing only the row the cursor
5167
+ * ends on would leave the rest above as residue. So the sequence walks back
5168
+ * up over every row the text occupies. That folding is the behaviour of the
5169
+ * common terminals (iTerm2, Terminal.app, GNOME Terminal and recent xterm);
5170
+ * one that clips the row instead has no continuation to clear.
5171
+ *
5172
+ * The caller must have forgotten the text once its row was abandoned —
5173
+ * `rendered` is only meaningful while `onRow` is true.
5174
+ */
5175
+ erase(previous) {
5176
+ const rows = Math.max(1, Math.ceil(displayWidth(previous) / terminalWidth()));
5177
+ let sequence = "\r\x1B[K";
5178
+ for (let row = 1; row < rows; row++) {
5179
+ sequence += "\x1B[1A\r\x1B[K";
5180
+ }
5181
+ return sequence;
5182
+ }
4798
5183
  /**
4799
5184
  * Whether the cursor is at the start of a row nobody else is using. On stdout
4800
5185
  * that is not this terminal there is no shared cursor to disturb; when it is,
@@ -4832,7 +5217,7 @@ var init_run_status = __esm({
4832
5217
 
4833
5218
  // src/commands/run.ts
4834
5219
  import { existsSync as existsSync2, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
4835
- import { join as join4 } from "path";
5220
+ import { join as join3 } from "path";
4836
5221
  import { SUPPORTED_HOSTS } from "@noir-ai/adapters";
4837
5222
  import { isDeniedEnvKey as isDeniedEnvKey2, loadNoirEnv as loadNoirEnv2, NOIR_DIR as NOIR_DIR2, parseConfig, paths } from "@noir-ai/core";
4838
5223
  function credentialNote(env2, sources) {
@@ -4892,7 +5277,7 @@ async function runOnce(prompt, opts, resumeSessionId) {
4892
5277
  const customBinary = opts.command ?? profile.binary;
4893
5278
  const extraArgs = resumeSessionId === void 0 ? profile.args : [...profile.args ?? [], "--resume", resumeSessionId];
4894
5279
  const env2 = profile.env ? mergeEnv(process.env, profile.env) : void 0;
4895
- if (!existsSync2(join4(root, NOIR_DIR2, ".env"))) {
5280
+ if (!existsSync2(join3(root, NOIR_DIR2, ".env"))) {
4896
5281
  log(
4897
5282
  `This project has no ${NOIR_DIR2}/.env \u2014 that is where Noir reads project credentials and run config from. Run \`noir init\` to scaffold one (\`noir run\` works without it).`,
4898
5283
  opts
@@ -5055,7 +5440,7 @@ function safeTranscript(host, lines) {
5055
5440
  function writeTranscript(host, lines, root = process.cwd()) {
5056
5441
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5057
5442
  const dir = paths.transcriptsDir(root);
5058
- const file = join4(dir, `${host}-${ts}.jsonl`);
5443
+ const file = join3(dir, `${host}-${ts}.jsonl`);
5059
5444
  try {
5060
5445
  mkdirSync3(dir, { recursive: true, mode: 448 });
5061
5446
  writeFileSync4(file, `${lines.join("\n")}${lines.length > 0 ? "\n" : ""}`, { mode: 384 });
@@ -5192,7 +5577,7 @@ var init_conflict = __esm({
5192
5577
 
5193
5578
  // src/dedup-write.ts
5194
5579
  import { mkdirSync as mkdirSync4, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync5 } from "fs";
5195
- import { join as join5 } from "path";
5580
+ import { join as join4 } from "path";
5196
5581
  import { sha256Hex as sha256Hex2, sha256Hex12 } from "@noir-ai/core";
5197
5582
  async function checkWritePathDedup(root, res, opts) {
5198
5583
  const result = { found: [], conflicts: [] };
@@ -5203,7 +5588,7 @@ async function checkWritePathDedup(root, res, opts) {
5203
5588
  if (embed === void 0) return result;
5204
5589
  const cachedEmbed = makeCachedEmbed(cache, embed);
5205
5590
  for (const proposedRel of writtenHostFiles) {
5206
- const proposedAbs = join5(root, proposedRel);
5591
+ const proposedAbs = join4(root, proposedRel);
5207
5592
  let proposedText;
5208
5593
  try {
5209
5594
  proposedText = readFileSync3(proposedAbs, "utf8");
@@ -5250,7 +5635,7 @@ async function checkWritePathDedup(root, res, opts) {
5250
5635
  `
5251
5636
  );
5252
5637
  }
5253
- const matchedText = safeRead(join5(root, matchedRel));
5638
+ const matchedText = safeRead(join4(root, matchedRel));
5254
5639
  result.conflicts.push({
5255
5640
  path: proposedRel,
5256
5641
  mode: "artifact",
@@ -5334,21 +5719,21 @@ async function promptDedupAction(root, proposedRel, matchedRel, proposedText, si
5334
5719
  if (clack.isCancel(choice)) return "preserve";
5335
5720
  if (choice === "skip") {
5336
5721
  try {
5337
- rmSync(join5(root, proposedRel), { force: true });
5722
+ rmSync(join4(root, proposedRel), { force: true });
5338
5723
  } catch {
5339
5724
  }
5340
5725
  return "preserve";
5341
5726
  } else if (choice === "replace") {
5342
5727
  try {
5343
- writeFileSync5(join5(root, matchedRel), proposedText, "utf8");
5728
+ writeFileSync5(join4(root, matchedRel), proposedText, "utf8");
5344
5729
  } catch {
5345
5730
  }
5346
5731
  return "replace";
5347
5732
  } else if (choice === "mirror") {
5348
5733
  try {
5349
- const existing = readFileSync3(join5(root, matchedRel), "utf8");
5734
+ const existing = readFileSync3(join4(root, matchedRel), "utf8");
5350
5735
  writeFileSync5(
5351
- join5(root, matchedRel),
5736
+ join4(root, matchedRel),
5352
5737
  `${existing}
5353
5738
 
5354
5739
  <!-- mirrored from ${proposedRel} -->
@@ -5366,7 +5751,7 @@ function emptyCache() {
5366
5751
  return { version: DEDUP_CACHE_VERSION, model: "", entries: {} };
5367
5752
  }
5368
5753
  function cachePath(root) {
5369
- return join5(root, ".noir", "dedup-cache.json");
5754
+ return join4(root, ".noir", "dedup-cache.json");
5370
5755
  }
5371
5756
  function loadCache(root) {
5372
5757
  try {
@@ -5385,7 +5770,7 @@ function loadCache(root) {
5385
5770
  }
5386
5771
  function saveCache(root, cache) {
5387
5772
  try {
5388
- mkdirSync4(join5(root, ".noir"), { recursive: true });
5773
+ mkdirSync4(join4(root, ".noir"), { recursive: true });
5389
5774
  writeFileSync5(cachePath(root), JSON.stringify(cache), "utf8");
5390
5775
  } catch {
5391
5776
  }
@@ -5441,8 +5826,8 @@ var init_dedup_write = __esm({
5441
5826
 
5442
5827
  // src/init.ts
5443
5828
  import { existsSync as existsSync3 } from "fs";
5444
- import { resolveAdapter as resolveAdapter3 } from "@noir-ai/adapters";
5445
- import { loadProjectInfo as loadProjectInfo12, paths as paths2 } from "@noir-ai/core";
5829
+ import { resolveAdapter as resolveAdapter2 } from "@noir-ai/adapters";
5830
+ import { loadProjectInfo as loadProjectInfo13, paths as paths2 } from "@noir-ai/core";
5446
5831
  import { scaffold } from "@noir-ai/create";
5447
5832
  import {
5448
5833
  emitSkillsToDir
@@ -5469,13 +5854,14 @@ async function init(root, opts) {
5469
5854
  reportPlannedWrites(res);
5470
5855
  return res;
5471
5856
  }
5857
+ reportEnvHeal(res);
5472
5858
  if (res.noop) return res;
5473
5859
  const skillSummary = await emitHostSkills(root, host, conflictOpts, interactive);
5474
5860
  res.skillConflicts = skillSummary?.conflicts ?? [];
5475
5861
  res.preservedSkills = skillSummary?.preserved ?? [];
5476
5862
  let projectInfo;
5477
5863
  try {
5478
- projectInfo = loadProjectInfo12(root);
5864
+ projectInfo = loadProjectInfo13(root);
5479
5865
  } catch {
5480
5866
  projectInfo = void 0;
5481
5867
  }
@@ -5506,7 +5892,7 @@ function resolveInitHost(root, opts) {
5506
5892
  if (opts.host !== void 0) return opts.host;
5507
5893
  if (!existsSync3(paths2.config(root))) return "claude";
5508
5894
  try {
5509
- return loadProjectInfo12(root).config.host;
5895
+ return loadProjectInfo13(root).config.host;
5510
5896
  } catch {
5511
5897
  process.stderr.write(
5512
5898
  `Could not read the configured host from ${paths2.config(root)}; using 'claude'.
@@ -5537,8 +5923,13 @@ function reportPlannedWrites(res) {
5537
5923
  for (const p of res.identical) log(` ${p}`);
5538
5924
  }
5539
5925
  }
5926
+ function reportEnvHeal(res) {
5927
+ if (res.envMode === "healed") {
5928
+ process.stderr.write("Tightened .noir/.env to 0600 (it was readable by group or others).\n");
5929
+ }
5930
+ }
5540
5931
  async function emitHostSkills(root, host, conflictOpts, interactive) {
5541
- const adapter = resolveAdapter3(host);
5932
+ const adapter = resolveAdapter2(host);
5542
5933
  const skillsDir = adapter.skillsDir?.({ root });
5543
5934
  if (skillsDir === void 0) {
5544
5935
  process.stderr.write(`host '${host}' has no skill emitter; skipping skills
@@ -5602,8 +5993,8 @@ var init_init = __esm({
5602
5993
  });
5603
5994
 
5604
5995
  // src/commands/skills.ts
5605
- import { resolveAdapter as resolveAdapter4 } from "@noir-ai/adapters";
5606
- import { loadProjectInfo as loadProjectInfo13 } from "@noir-ai/core";
5996
+ import { resolveAdapter as resolveAdapter3 } from "@noir-ai/adapters";
5997
+ import { loadProjectInfo as loadProjectInfo14 } from "@noir-ai/core";
5607
5998
  import {
5608
5999
  buildRegistry,
5609
6000
  discoverAll,
@@ -5621,13 +6012,6 @@ function toRow(s, kind = "builtin") {
5621
6012
  const category = s.frontmatter.metadata?.category?.trim() || categoryOf(s.name);
5622
6013
  return { name: s.name, category, description, kind, status: status2 };
5623
6014
  }
5624
- function truncate(text, max) {
5625
- if (text.length <= max) return text;
5626
- const slice = text.slice(0, Math.max(0, max - 1));
5627
- const boundary = slice.lastIndexOf(" ");
5628
- const head = boundary > 0 ? slice.slice(0, boundary) : slice;
5629
- return `${head.trimEnd()}\u2026`;
5630
- }
5631
6015
  async function skillsList(opts) {
5632
6016
  let builtins;
5633
6017
  let integrations;
@@ -5661,7 +6045,9 @@ async function skillsList(opts) {
5661
6045
  Kind: r.kind,
5662
6046
  Category: r.category,
5663
6047
  Status: r.status,
5664
- Description: truncate(r.description, 80)
6048
+ // Display-only cut for the dense table cell; the `--json` payload always
6049
+ // carries the description whole (search snippets must never be truncated).
6050
+ Description: truncateToWidth(r.description, 80)
5665
6051
  })),
5666
6052
  ["Skill", "Kind", "Category", "Status", "Description"],
5667
6053
  opts
@@ -5671,11 +6057,11 @@ async function skillsSync(opts) {
5671
6057
  const root = process.cwd();
5672
6058
  let project;
5673
6059
  try {
5674
- project = loadProjectInfo13(root);
6060
+ project = loadProjectInfo14(root);
5675
6061
  } catch {
5676
6062
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
5677
6063
  }
5678
- const adapter = resolveAdapter4(project.config.host);
6064
+ const adapter = resolveAdapter3(project.config.host);
5679
6065
  if (!adapter.skillsDir) {
5680
6066
  const data2 = {
5681
6067
  emitted: [],
@@ -5737,7 +6123,7 @@ async function skillsLint(opts) {
5737
6123
  );
5738
6124
  }
5739
6125
  const errored = skills.filter((s) => s.errors.length > 0);
5740
- const warned = skills.filter((s) => s.warnings.length > 0 && s.errors.length === 0);
6126
+ const warned = skills.filter((s) => s.warnings.length > 0);
5741
6127
  const data = { count: skills.length, errored: errored.length, skills };
5742
6128
  if (errored.length > 0) {
5743
6129
  const message = `skills lint: ${errored.length} skill${errored.length === 1 ? "" : "s"} failed validation`;
@@ -5815,6 +6201,7 @@ var init_skills = __esm({
5815
6201
  "use strict";
5816
6202
  init_init();
5817
6203
  init_output();
6204
+ init_width();
5818
6205
  CATEGORY = {
5819
6206
  "noir-brainstorming": "discovery",
5820
6207
  "noir-sync": "discovery",
@@ -5841,17 +6228,22 @@ var init_skills = __esm({
5841
6228
  "noir-backend": "domain",
5842
6229
  "noir-doctor": "meta",
5843
6230
  "noir-writing-skills": "meta",
5844
- "noir-rules": "meta"
6231
+ "noir-rules": "meta",
6232
+ "noir-code-hygiene": "meta"
5845
6233
  };
5846
6234
  }
5847
6235
  });
5848
6236
 
5849
6237
  // src/serve.ts
5850
- import { applyNoirEnv, loadProjectInfo as loadProjectInfo14 } from "@noir-ai/core";
6238
+ import { applyNoirEnv, loadProjectInfo as loadProjectInfo15 } from "@noir-ai/core";
5851
6239
  import { ensureDaemonRunning as ensureDaemonRunning2, startStdioServer } from "@noir-ai/daemon";
5852
6240
  async function serve(opts) {
5853
6241
  applyNoirEnv(process.cwd());
5854
- const project = loadProjectInfo14(process.cwd());
6242
+ if (opts.workspace !== void 0) {
6243
+ await bridgeStdioToWorkspace(opts.workspace, process.cwd());
6244
+ return;
6245
+ }
6246
+ const project = loadProjectInfo15(process.cwd());
5855
6247
  if (opts.stdio) {
5856
6248
  await startStdioServer({ project, transport: "stdio", daemon: false });
5857
6249
  return;
@@ -5877,6 +6269,7 @@ async function serve(opts) {
5877
6269
  var init_serve = __esm({
5878
6270
  "src/serve.ts"() {
5879
6271
  "use strict";
6272
+ init_workspace_bridge();
5880
6273
  }
5881
6274
  });
5882
6275
 
@@ -5886,8 +6279,8 @@ __export(create_exports, {
5886
6279
  create: () => create
5887
6280
  });
5888
6281
  import { resolve as resolve2 } from "path";
5889
- import { resolveAdapter as resolveAdapter5 } from "@noir-ai/adapters";
5890
- import { loadProjectInfo as loadProjectInfo15 } from "@noir-ai/core";
6282
+ import { resolveAdapter as resolveAdapter4 } from "@noir-ai/adapters";
6283
+ import { loadProjectInfo as loadProjectInfo16 } from "@noir-ai/core";
5891
6284
  import { scaffold as scaffold2 } from "@noir-ai/create";
5892
6285
  import { emitSkillsToDir as emitSkillsToDir3 } from "@noir-ai/skills";
5893
6286
  async function create(dir, opts) {
@@ -5911,9 +6304,10 @@ async function create(dir, opts) {
5911
6304
  reportPlannedWrites(res);
5912
6305
  return res;
5913
6306
  }
6307
+ reportEnvHeal(res);
5914
6308
  if (res.noop) return res;
5915
6309
  const host = res.host;
5916
- const adapter = resolveAdapter5(host);
6310
+ const adapter = resolveAdapter4(host);
5917
6311
  const skillsDir = adapter.skillsDir?.({ root });
5918
6312
  if (skillsDir === void 0) {
5919
6313
  process.stderr.write(`host '${host}' has no skill emitter; skipping skills
@@ -5941,7 +6335,7 @@ async function create(dir, opts) {
5941
6335
  }
5942
6336
  let projectInfo;
5943
6337
  try {
5944
- projectInfo = loadProjectInfo15(root);
6338
+ projectInfo = loadProjectInfo16(root);
5945
6339
  } catch {
5946
6340
  projectInfo = void 0;
5947
6341
  }
@@ -5968,8 +6362,8 @@ __export(sync_exports, {
5968
6362
  sync: () => sync
5969
6363
  });
5970
6364
  import { existsSync as existsSync4 } from "fs";
5971
- import { resolveAdapter as resolveAdapter6 } from "@noir-ai/adapters";
5972
- import { loadProjectInfo as loadProjectInfo16, paths as paths3 } from "@noir-ai/core";
6365
+ import { resolveAdapter as resolveAdapter5 } from "@noir-ai/adapters";
6366
+ import { loadProjectInfo as loadProjectInfo17, paths as paths3 } from "@noir-ai/core";
5973
6367
  import { scaffold as scaffold3 } from "@noir-ai/create";
5974
6368
  import { emitSkillsToDir as emitSkillsToDir4 } from "@noir-ai/skills";
5975
6369
  async function sync(root, opts = {}) {
@@ -5978,7 +6372,7 @@ async function sync(root, opts = {}) {
5978
6372
  const conflictOpts = buildConflictOpts({ force: opts.force, interactive });
5979
6373
  let projectInfo;
5980
6374
  try {
5981
- projectInfo = loadProjectInfo16(root);
6375
+ projectInfo = loadProjectInfo17(root);
5982
6376
  } catch {
5983
6377
  projectInfo = void 0;
5984
6378
  }
@@ -5997,10 +6391,11 @@ async function sync(root, opts = {}) {
5997
6391
  reportPlannedWrites(res);
5998
6392
  return res;
5999
6393
  }
6394
+ reportEnvHeal(res);
6000
6395
  if (res.written.length === 0 && res.identical.length > 0) {
6001
6396
  process.stderr.write("Nothing to sync (Noir-managed files are up to date).\n");
6002
6397
  }
6003
- const adapter = resolveAdapter6(host);
6398
+ const adapter = resolveAdapter5(host);
6004
6399
  const skillsDir = adapter.skillsDir?.({ root });
6005
6400
  if (skillsDir === void 0) {
6006
6401
  process.stderr.write(`host '${host}' has no skill emitter; skipping skills
@@ -6041,7 +6436,7 @@ function resolveSyncHost(root, opts) {
6041
6436
  if (opts.host !== void 0) return opts.host;
6042
6437
  if (!existsSync4(paths3.config(root))) return "claude";
6043
6438
  try {
6044
- return loadProjectInfo16(root).config.host;
6439
+ return loadProjectInfo17(root).config.host;
6045
6440
  } catch {
6046
6441
  return "claude";
6047
6442
  }
@@ -6163,6 +6558,8 @@ function createProgram() {
6163
6558
  const nonInteractive = opts.json === true || opts.input === false;
6164
6559
  if (nonInteractive) process.env.NOIR_NON_INTERACTIVE = "1";
6165
6560
  else delete process.env.NOIR_NON_INTERACTIVE;
6561
+ if (opts.quiet === true) process.env.NOIR_QUIET = "1";
6562
+ else delete process.env.NOIR_QUIET;
6166
6563
  emitDeprecationHintsFor(actionCmd, toCliOptions(opts));
6167
6564
  });
6168
6565
  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(
@@ -6268,11 +6665,17 @@ function createProgram() {
6268
6665
  }
6269
6666
  );
6270
6667
  const mcpCmd = program2.command("mcp").description("MCP server control");
6271
- mcpCmd.command("serve").description("run the Noir MCP server (stdio, or via the shared daemon)").option("--stdio", "force the stdio transport").action(async (opts) => {
6272
- await serve({ stdio: opts.stdio === true });
6668
+ mcpCmd.command("serve").description("run the Noir MCP server (stdio, or via the shared daemon)").option("--stdio", "force the stdio transport").option(
6669
+ "--workspace <name>",
6670
+ "serve a shared workspace over stdio, bridging to that workspace daemon"
6671
+ ).action(async (opts) => {
6672
+ await serve({
6673
+ stdio: opts.stdio === true,
6674
+ ...typeof opts.workspace === "string" ? { workspace: opts.workspace } : {}
6675
+ });
6273
6676
  });
6274
6677
  mcpCmd.action(() => {
6275
- throw new NoirCliError(EXIT.USAGE, "Usage: noir mcp serve [--stdio]");
6678
+ throw new NoirCliError(EXIT.USAGE, "Usage: noir mcp serve [--stdio] [--workspace <name>]");
6276
6679
  });
6277
6680
  const daemonGrp = program2.command("daemon").description("control the Noir daemon");
6278
6681
  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(
@@ -6373,10 +6776,16 @@ function createProgram() {
6373
6776
  program2.command("doctor").description("environment + project health").option(
6374
6777
  "--dedup",
6375
6778
  "scan host-context + .noir/ docs for semantic near-duplicates (loads the local embedder)"
6376
- ).action(async (...args) => {
6779
+ ).option("--fix", "re-assert owner-only permissions on .noir/.env and the store DB + directory").action(async (...args) => {
6377
6780
  const g = actionGlobals(args);
6378
- const dedup = trailingCmd(args).opts().dedup === true;
6379
- await doctor({ ...toCliOptions(g), ...dedup ? { dedup: true } : {} });
6781
+ const opts = trailingCmd(args).opts();
6782
+ const dedup = opts.dedup === true;
6783
+ const fix = opts.fix === true;
6784
+ await doctor({
6785
+ ...toCliOptions(g),
6786
+ ...dedup ? { dedup: true } : {},
6787
+ ...fix ? { fix: true } : {}
6788
+ });
6380
6789
  });
6381
6790
  program2.command("status").description("project + daemon + workflow + store snapshot").action(async (...args) => {
6382
6791
  await status(toStatusOptions(actionGlobals(args)));
@@ -6569,7 +6978,7 @@ function createProgram() {
6569
6978
  ...check === void 0 || check.length === 0 ? {} : { check }
6570
6979
  });
6571
6980
  });
6572
- taskGrp.command("research").description("list research findings for the active (or named) task").argument("[id]", "task id (defaults to active)").action(async (...args) => {
6981
+ taskGrp.command("research").description("show research status for the active (or named) task").argument("[id]", "task id (defaults to active)").action(async (...args) => {
6573
6982
  const cmd = trailingCmd(args);
6574
6983
  const g = cmd.optsWithGlobals();
6575
6984
  const first = args[0];
@@ -6816,22 +7225,22 @@ var init_bin = __esm({
6816
7225
  });
6817
7226
 
6818
7227
  // src/daemon-client.ts
6819
- import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
7228
+ import { Client, StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from "@modelcontextprotocol/client";
6820
7229
  import { ContextEngine, createEmbedFn, resolveEmbedderConfig } from "@noir-ai/context";
6821
7230
  import {
6822
- loadProjectInfo as loadProjectInfo17,
7231
+ loadProjectInfo as loadProjectInfo18,
6823
7232
  NOIR_VERSION as NOIR_VERSION5,
6824
7233
  readWorkspaceMarker as readWorkspaceMarker2,
6825
7234
  workspaceStoreDbPath
6826
7235
  } from "@noir-ai/core";
6827
7236
  import {
6828
7237
  ensureDaemonRunning as ensureDaemonRunning3,
6829
- pidAlive as pidAlive3,
6830
- readDaemonToken as readDaemonToken2,
7238
+ pidAlive as pidAlive4,
7239
+ readDaemonToken as readDaemonToken3,
6831
7240
  readProjectDaemonRecord as readProjectDaemonRecord2,
6832
- readWorkspaceDaemonRecord as readWorkspaceDaemonRecord2,
7241
+ readWorkspaceDaemonRecord as readWorkspaceDaemonRecord3,
6833
7242
  resolveGateConfig,
6834
- tokenPath
7243
+ tokenPath as tokenPath2
6835
7244
  } from "@noir-ai/daemon";
6836
7245
  import { createMemoryEngine } from "@noir-ai/memory";
6837
7246
  import { openStore } from "@noir-ai/store";
@@ -6854,7 +7263,7 @@ function failDaemonDown(opts, cause) {
6854
7263
  async function probeDaemon(opts = {}) {
6855
7264
  const expectedProject = opts.project?.id ?? (() => {
6856
7265
  try {
6857
- return loadProjectInfo17(process.cwd()).id;
7266
+ return loadProjectInfo18(process.cwd()).id;
6858
7267
  } catch {
6859
7268
  return void 0;
6860
7269
  }
@@ -6868,7 +7277,7 @@ async function probeDaemon(opts = {}) {
6868
7277
  if (opts.verbose) process.stderr.write("noir: daemon probe: no daemon record\n");
6869
7278
  return { running: false };
6870
7279
  }
6871
- if (!pidAlive3(rec.pid)) {
7280
+ if (!pidAlive4(rec.pid)) {
6872
7281
  if (opts.verbose) process.stderr.write(`noir: daemon probe: pid ${rec.pid} not alive
6873
7282
  `);
6874
7283
  return { running: false };
@@ -6907,7 +7316,7 @@ async function probeDaemon(opts = {}) {
6907
7316
  async function resolveDaemon(opts) {
6908
7317
  let project;
6909
7318
  try {
6910
- project = opts.project ?? loadProjectInfo17(process.cwd());
7319
+ project = opts.project ?? loadProjectInfo18(process.cwd());
6911
7320
  } catch {
6912
7321
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
6913
7322
  }
@@ -6968,8 +7377,8 @@ async function resolveDaemon(opts) {
6968
7377
  return { client, stop };
6969
7378
  }
6970
7379
  function httpTransport(url, scopeKey) {
6971
- const token = scopeKey === null ? null : readDaemonToken2(scopeKey);
6972
- return new StreamableHTTPClientTransport(
7380
+ const token = scopeKey === null ? null : readDaemonToken3(scopeKey);
7381
+ return new StreamableHTTPClientTransport2(
6973
7382
  new URL(url),
6974
7383
  // Verified present: StreamableHTTPClientTransportOptions.requestInit?: RequestInit
6975
7384
  token ? { requestInit: { headers: { Authorization: `Bearer ${token}` } } } : {}
@@ -6977,7 +7386,7 @@ function httpTransport(url, scopeKey) {
6977
7386
  }
6978
7387
  function callerScopeKey(opts) {
6979
7388
  try {
6980
- return (opts.project ?? loadProjectInfo17(process.cwd())).id;
7389
+ return (opts.project ?? loadProjectInfo18(process.cwd())).id;
6981
7390
  } catch {
6982
7391
  return null;
6983
7392
  }
@@ -7007,7 +7416,7 @@ function failWorkspaceConnect(opts, routing, cause) {
7007
7416
  }
7008
7417
  fail(
7009
7418
  EXIT.DAEMON_DOWN,
7010
- `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.`,
7419
+ `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.`,
7011
7420
  opts
7012
7421
  );
7013
7422
  }
@@ -7078,7 +7487,7 @@ async function callDaemonTool(opts, name, args = {}) {
7078
7487
  async function withInProcessRead(opts, fn) {
7079
7488
  let project;
7080
7489
  try {
7081
- project = opts.project ?? loadProjectInfo17(process.cwd());
7490
+ project = opts.project ?? loadProjectInfo18(process.cwd());
7082
7491
  } catch {
7083
7492
  fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
7084
7493
  }
@@ -7125,14 +7534,14 @@ function workspaceRoutingTarget(root) {
7125
7534
  const name = readWorkspaceMarker2(root);
7126
7535
  if (name === null) return null;
7127
7536
  try {
7128
- return { name, projectId: loadProjectInfo17(root).id };
7537
+ return { name, projectId: loadProjectInfo18(root).id };
7129
7538
  } catch {
7130
7539
  return null;
7131
7540
  }
7132
7541
  }
7133
7542
  async function probeWorkspaceDaemon(routing) {
7134
- const rec = readWorkspaceDaemonRecord2(routing.name);
7135
- if (rec === null || !pidAlive3(rec.pid)) return { running: false };
7543
+ const rec = readWorkspaceDaemonRecord3(routing.name);
7544
+ if (rec === null || !pidAlive4(rec.pid)) return { running: false };
7136
7545
  try {
7137
7546
  const res = await fetch(`http://127.0.0.1:${rec.port}/health`, {
7138
7547
  signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
@@ -7215,17 +7624,182 @@ var init_daemon_client = __esm({
7215
7624
  }
7216
7625
  });
7217
7626
 
7627
+ // src/hygiene-scan.ts
7628
+ import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync } from "fs";
7629
+ import { join as join5 } from "path";
7630
+ import { checkHygiene } from "@noir-ai/skills";
7631
+ function readDirEntries(dir) {
7632
+ try {
7633
+ return readdirSync2(dir, { withFileTypes: true });
7634
+ } catch {
7635
+ return [];
7636
+ }
7637
+ }
7638
+ function isDirectory(path) {
7639
+ try {
7640
+ return statSync(path).isDirectory();
7641
+ } catch {
7642
+ return false;
7643
+ }
7644
+ }
7645
+ function hygieneKindOf(path) {
7646
+ if (HYGIENE_MARKDOWN_EXTENSIONS.test(path)) return "markdown";
7647
+ if (HYGIENE_CODE_EXTENSIONS.test(path)) return "code";
7648
+ return null;
7649
+ }
7650
+ function hasScannableLayout(root) {
7651
+ if (isDirectory(join5(root, "docs")) || isDirectory(join5(root, "scripts"))) return true;
7652
+ for (const entry of readDirEntries(join5(root, "packages"))) {
7653
+ if (!entry.isDirectory()) continue;
7654
+ if (isDirectory(join5(root, "packages", entry.name, "src"))) return true;
7655
+ if (isDirectory(join5(root, "packages", entry.name, "test"))) return true;
7656
+ }
7657
+ return false;
7658
+ }
7659
+ function addHygieneFile(rel, walk) {
7660
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
7661
+ if (name.startsWith(".") || HYGIENE_EXCLUDED_FILES.has(name)) return;
7662
+ const kind = hygieneKindOf(rel);
7663
+ if (kind === null) return;
7664
+ if (walk.files.length >= HYGIENE_MAX_FILES) {
7665
+ walk.truncated = true;
7666
+ return;
7667
+ }
7668
+ walk.files.push({ path: rel, kind });
7669
+ }
7670
+ function walkScannable(root, relDir, skip, walk) {
7671
+ if (walk.truncated) return;
7672
+ for (const entry of readDirEntries(join5(root, relDir))) {
7673
+ if (walk.truncated) return;
7674
+ const rel = `${relDir}/${entry.name}`;
7675
+ if (entry.isDirectory()) {
7676
+ if (HYGIENE_SKIP_DIRS.has(entry.name) || entry.name.startsWith(".") || skip.has(rel)) {
7677
+ continue;
7678
+ }
7679
+ walkScannable(root, rel, skip, walk);
7680
+ } else if (entry.isFile()) {
7681
+ addHygieneFile(rel, walk);
7682
+ }
7683
+ }
7684
+ }
7685
+ function collectHygieneFiles(root) {
7686
+ const walk = { files: [], truncated: false };
7687
+ for (const entry of readDirEntries(root)) {
7688
+ if (entry.isFile()) addHygieneFile(entry.name, walk);
7689
+ }
7690
+ walkScannable(root, "docs", HYGIENE_EXCLUDED_DIRS, walk);
7691
+ walkScannable(root, ".claude/skills", NO_SKIP_DIRS, walk);
7692
+ for (const entry of readDirEntries(join5(root, "packages"))) {
7693
+ if (!entry.isDirectory()) continue;
7694
+ walkScannable(root, `packages/${entry.name}/src`, NO_SKIP_DIRS, walk);
7695
+ walkScannable(root, `packages/${entry.name}/test`, NO_SKIP_DIRS, walk);
7696
+ }
7697
+ walkScannable(root, "scripts", NO_SKIP_DIRS, walk);
7698
+ walk.files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
7699
+ return walk;
7700
+ }
7701
+ function hygieneOmissions(result) {
7702
+ const notes = [];
7703
+ if (result.skipped > 0) {
7704
+ notes.push(
7705
+ `${result.skipped} file${result.skipped === 1 ? "" : "s"} over ${HYGIENE_MAX_FILE_BYTES / 1024} KiB skipped`
7706
+ );
7707
+ }
7708
+ if (result.truncated) notes.push(`stopped at ${HYGIENE_MAX_FILES} files`);
7709
+ return notes.length > 0 ? ` (${notes.join("; ")})` : "";
7710
+ }
7711
+ function hygieneCounts(result) {
7712
+ const omitted = hygieneOmissions(result);
7713
+ if (result.scanned === 0) {
7714
+ return `nothing to scan (no packages/*/src, packages/*/test, scripts/ or documents found)${omitted}`;
7715
+ }
7716
+ if (result.findings.length === 0) {
7717
+ return `clean \u2014 ${result.scanned} file${result.scanned === 1 ? "" : "s"} scanned${omitted}`;
7718
+ }
7719
+ const files = new Set(result.findings.map((f) => f.path)).size;
7720
+ return `${result.fail} fail, ${result.warn} warn in ${files} file${files === 1 ? "" : "s"}${omitted}`;
7721
+ }
7722
+ function hygieneDetail(result) {
7723
+ const counts = hygieneCounts(result);
7724
+ if (result.scanned === 0 || result.findings.length === 0) return counts;
7725
+ const named = result.findings.slice(0, HYGIENE_FINDING_CAP);
7726
+ const hidden = result.findings.length - named.length;
7727
+ const where = named.map((f) => `${f.path}:${f.line} ${f.id}`).join("; ");
7728
+ return `${counts} \u2014 ${where}${hidden > 0 ? ` (+${hidden} more)` : ""}`;
7729
+ }
7730
+ function scanOutputHygiene(root) {
7731
+ const empty = {
7732
+ scanned: 0,
7733
+ skipped: 0,
7734
+ truncated: false,
7735
+ findings: [],
7736
+ fail: 0,
7737
+ warn: 0
7738
+ };
7739
+ if (!hasScannableLayout(root)) return empty;
7740
+ const walk = collectHygieneFiles(root);
7741
+ const findings = [];
7742
+ let scanned = 0;
7743
+ let skipped = 0;
7744
+ for (const file of walk.files) {
7745
+ const abs = join5(root, file.path);
7746
+ try {
7747
+ if (statSync(abs).size > HYGIENE_MAX_FILE_BYTES) {
7748
+ skipped++;
7749
+ continue;
7750
+ }
7751
+ const text = readFileSync4(abs, "utf8");
7752
+ scanned++;
7753
+ for (const found of checkHygiene(text, file.kind)) {
7754
+ findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });
7755
+ }
7756
+ } catch {
7757
+ }
7758
+ }
7759
+ findings.sort((a, b) => {
7760
+ if (a.tier !== b.tier) return a.tier === "fail" ? -1 : 1;
7761
+ if (a.path !== b.path) return a.path < b.path ? -1 : 1;
7762
+ return a.line - b.line;
7763
+ });
7764
+ const fail2 = findings.filter((f) => f.tier === "fail").length;
7765
+ return {
7766
+ scanned,
7767
+ skipped,
7768
+ truncated: walk.truncated,
7769
+ findings,
7770
+ fail: fail2,
7771
+ warn: findings.length - fail2
7772
+ };
7773
+ }
7774
+ 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;
7775
+ var init_hygiene_scan = __esm({
7776
+ "src/hygiene-scan.ts"() {
7777
+ "use strict";
7778
+ HYGIENE_FINDING_CAP = 5;
7779
+ HYGIENE_MAX_FILE_BYTES = 512 * 1024;
7780
+ HYGIENE_MAX_FILES = 2e3;
7781
+ HYGIENE_CODE_EXTENSIONS = /\.(?:ts|tsx|js|jsx|mjs|cjs|sh)$/i;
7782
+ HYGIENE_MARKDOWN_EXTENSIONS = /\.(?:md|mdx)$/i;
7783
+ HYGIENE_SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage"]);
7784
+ HYGIENE_EXCLUDED_DIRS = /* @__PURE__ */ new Set(["docs/internal", "docs/decisions", "docs/roadmap"]);
7785
+ HYGIENE_EXCLUDED_FILES = /* @__PURE__ */ new Set(["CHANGELOG.md"]);
7786
+ NO_SKIP_DIRS = /* @__PURE__ */ new Set();
7787
+ }
7788
+ });
7789
+
7218
7790
  // src/commands/doctor.ts
7219
7791
  import { spawnSync as spawnSync3 } from "child_process";
7220
- import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync } from "fs";
7792
+ import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync5, statSync as statSync2 } from "fs";
7221
7793
  import { dirname, join as join6, relative } from "path";
7222
7794
  import { fileURLToPath } from "url";
7223
- import { resolveAdapter as resolveAdapter7 } from "@noir-ai/adapters";
7795
+ import { mcpConfigPathFor as mcpConfigPathFor2, resolveAdapter as resolveAdapter6 } from "@noir-ai/adapters";
7224
7796
  import {
7225
7797
  detectActiveMethod as detectActiveMethod4,
7798
+ ensureOwnerOnly,
7799
+ ensureOwnerOnlyDir,
7226
7800
  latestVersionFromCache as latestVersionFromCache2,
7227
7801
  loadNoirEnv as loadNoirEnv3,
7228
- loadProjectInfo as loadProjectInfo18,
7802
+ loadProjectInfo as loadProjectInfo19,
7229
7803
  NOIR_VERSION as NOIR_VERSION6,
7230
7804
  paths as paths4,
7231
7805
  readInstallRecord as readInstallRecord4,
@@ -7238,7 +7812,7 @@ import {
7238
7812
  readScaffoldVersion,
7239
7813
  render
7240
7814
  } from "@noir-ai/create";
7241
- import { pidAlive as pidAlive4, readProjectDaemonRecord as readProjectDaemonRecord3 } from "@noir-ai/daemon";
7815
+ import { pidAlive as pidAlive5, readProjectDaemonRecord as readProjectDaemonRecord3 } from "@noir-ai/daemon";
7242
7816
  import { resolveModelConfig as resolveModelConfig2 } from "@noir-ai/model";
7243
7817
  async function probeOnnx() {
7244
7818
  try {
@@ -7254,7 +7828,7 @@ function describeOnnx(o) {
7254
7828
  if (/Cannot find (module|package)/i.test(o.reason)) {
7255
7829
  return "not resolvable from CLI probe (best-effort)";
7256
7830
  }
7257
- const r = o.reason.length > 60 ? `${o.reason.slice(0, 59)}\u2026` : o.reason;
7831
+ const r = truncateToWidth(o.reason, 60);
7258
7832
  return `probe failed: ${r}`;
7259
7833
  }
7260
7834
  function fmtUptime(sec) {
@@ -7286,7 +7860,7 @@ function checkConfig(root) {
7286
7860
  };
7287
7861
  }
7288
7862
  try {
7289
- const project = loadProjectInfo18(root);
7863
+ const project = loadProjectInfo19(root);
7290
7864
  return {
7291
7865
  project,
7292
7866
  result: {
@@ -7320,7 +7894,7 @@ async function checkDaemon(checks, project) {
7320
7894
  });
7321
7895
  return;
7322
7896
  }
7323
- if (!pidAlive4(rec.pid)) {
7897
+ if (!pidAlive5(rec.pid)) {
7324
7898
  checks.push({
7325
7899
  name: "daemon",
7326
7900
  status: "warn",
@@ -7403,11 +7977,21 @@ async function checkStore(checks, project, root) {
7403
7977
  }
7404
7978
  }
7405
7979
  }
7980
+ function envModeCause(st) {
7981
+ return st.ctimeMs > st.mtimeMs ? "changed" : "legacy";
7982
+ }
7983
+ function octalMode(mode) {
7984
+ return mode.toString(8).padStart(4, "0");
7985
+ }
7986
+ function describeEnvMode(cause, mode) {
7987
+ const lead = `is ${octalMode(mode)} \u2014 accessible to other accounts; run \`noir sync\` (or \`chmod 600 .noir/.env\`) to make it owner-only.`;
7988
+ 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.`;
7989
+ }
7406
7990
  function checkNoirEnv(checks, root, loaded) {
7407
7991
  const path = join6(paths4.noirDir(root), ".env");
7408
7992
  let st;
7409
7993
  try {
7410
- st = statSync(path);
7994
+ st = statSync2(path);
7411
7995
  } catch {
7412
7996
  return;
7413
7997
  }
@@ -7425,8 +8009,8 @@ function checkNoirEnv(checks, root, loaded) {
7425
8009
  tooOpen ? {
7426
8010
  name: "noir-env",
7427
8011
  status: "warn",
7428
- detail: `${path} is ${mode.toString(8)} \u2014 readable by others; run chmod 600`
7429
- } : { name: "noir-env", status: "ok", detail: `${path} permissions ok (${mode.toString(8)})` }
8012
+ detail: `${path} ${describeEnvMode(envModeCause(st), mode)}`
8013
+ } : { name: "noir-env", status: "ok", detail: `${path} permissions ok (${octalMode(mode)})` }
7430
8014
  );
7431
8015
  for (const [key, source] of Object.entries(loaded.sources)) {
7432
8016
  if (source !== "file") continue;
@@ -7526,7 +8110,7 @@ function checkSeedDrift(checks, root) {
7526
8110
  const seedPath = join6(root, ".noir", ".env.example");
7527
8111
  let bytes;
7528
8112
  try {
7529
- bytes = readFileSync4(seedPath, "utf8");
8113
+ bytes = readFileSync5(seedPath, "utf8");
7530
8114
  } catch {
7531
8115
  return;
7532
8116
  }
@@ -7544,6 +8128,14 @@ function checkRulesMdBudget(checks, root, project) {
7544
8128
  checks.push({ name: "rules budget", status: "warn", detail: "skipped \u2014 not initialized" });
7545
8129
  return null;
7546
8130
  }
8131
+ if (project.config.rules.enabled === false) {
8132
+ checks.push({
8133
+ name: "rules budget",
8134
+ status: "ok",
8135
+ detail: "disabled \u2014 rules.enabled is false in .noir/config.yml (RULES.md is not measured)"
8136
+ });
8137
+ return null;
8138
+ }
7547
8139
  const rulesPath = paths4.rulesMd(root);
7548
8140
  if (!existsSync5(rulesPath)) {
7549
8141
  checks.push({
@@ -7555,7 +8147,7 @@ function checkRulesMdBudget(checks, root, project) {
7555
8147
  }
7556
8148
  let content;
7557
8149
  try {
7558
- content = readFileSync4(rulesPath, "utf8");
8150
+ content = readFileSync5(rulesPath, "utf8");
7559
8151
  } catch (e) {
7560
8152
  checks.push({
7561
8153
  name: "rules budget",
@@ -7599,7 +8191,7 @@ function checkHostArtifacts(checks, root, project) {
7599
8191
  return null;
7600
8192
  }
7601
8193
  const host = project.config.host;
7602
- const adapter = resolveAdapter7(host);
8194
+ const adapter = resolveAdapter6(host);
7603
8195
  const ectx = { root };
7604
8196
  const expected = [];
7605
8197
  const emitsAgentsMd = host === "agents-md" || host === "cursor" || host === "opencode";
@@ -7608,7 +8200,7 @@ function checkHostArtifacts(checks, root, project) {
7608
8200
  }
7609
8201
  if (host === "claude") expected.push("CLAUDE.md");
7610
8202
  else if (host === "gemini") expected.push("GEMINI.md");
7611
- expected.push(relOr(adapter.mcpConfigPath?.(ectx) ?? ".mcp.json", root));
8203
+ expected.push(relOr(mcpConfigPathFor2(host, root), root));
7612
8204
  const missing = expected.filter((p) => !existsSync5(joinRoot(root, p)));
7613
8205
  const status2 = missing.length === 0 ? "ok" : "warn";
7614
8206
  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)`;
@@ -7635,6 +8227,15 @@ function checkNestedNoir(checks, root) {
7635
8227
  });
7636
8228
  return { detected, paths: found };
7637
8229
  }
8230
+ function checkOutputHygiene(checks, root) {
8231
+ const result = scanOutputHygiene(root);
8232
+ checks.push({
8233
+ name: "output hygiene",
8234
+ status: result.fail > 0 ? "fail" : result.warn > 0 ? "warn" : "ok",
8235
+ detail: hygieneDetail(result)
8236
+ });
8237
+ return result;
8238
+ }
7638
8239
  function collectDedupCandidates(root) {
7639
8240
  const rels = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".noir/rules/RULES.md"];
7640
8241
  const out = [];
@@ -7643,7 +8244,7 @@ function collectDedupCandidates(root) {
7643
8244
  if (!existsSync5(abs)) continue;
7644
8245
  let text;
7645
8246
  try {
7646
- text = readFileSync4(abs, "utf8");
8247
+ text = readFileSync5(abs, "utf8");
7647
8248
  } catch {
7648
8249
  continue;
7649
8250
  }
@@ -7703,7 +8304,7 @@ function resolveWorkspacePackagesDir() {
7703
8304
  const pkgPath = join6(here, "package.json");
7704
8305
  if (existsSync5(pkgPath)) {
7705
8306
  try {
7706
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
8307
+ const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
7707
8308
  if (pkg.name === "@noir-ai/cli") {
7708
8309
  const packagesDir = dirname(here);
7709
8310
  if (existsSync5(join6(dirname(packagesDir), "pnpm-workspace.yaml"))) {
@@ -7751,7 +8352,7 @@ function checkPublish(checks, packagesDir, opts = {}) {
7751
8352
  }
7752
8353
  let entries = [];
7753
8354
  try {
7754
- entries = readdirSync2(packagesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
8355
+ entries = readdirSync3(packagesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
7755
8356
  } catch (e) {
7756
8357
  checks.push({
7757
8358
  name: "publish",
@@ -7768,7 +8369,7 @@ function checkPublish(checks, packagesDir, opts = {}) {
7768
8369
  if (!existsSync5(pkgPath)) continue;
7769
8370
  let pkg;
7770
8371
  try {
7771
- pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
8372
+ pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
7772
8373
  } catch (e) {
7773
8374
  issues.push(`${name}/package.json: unparseable (${msg(e)})`);
7774
8375
  checked++;
@@ -7847,6 +8448,17 @@ function renderHuman2(payload, opts) {
7847
8448
  ["Check", "Status", "Detail"],
7848
8449
  opts
7849
8450
  );
8451
+ if (payload.fixes) {
8452
+ const healed = payload.fixes.filter((f) => f.outcome === "healed");
8453
+ if (healed.length > 0) {
8454
+ log(
8455
+ `fixed ${healed.length} permission${healed.length === 1 ? "" : "s"}: ${healed.map((f) => f.path).join(", ")}`,
8456
+ opts
8457
+ );
8458
+ } else {
8459
+ log("nothing to fix \u2014 permissions already owner-only", opts);
8460
+ }
8461
+ }
7850
8462
  const { ok, warn: warnN, fail: fail2 } = payload.summary;
7851
8463
  if (fail2 > 0) {
7852
8464
  error(
@@ -7859,12 +8471,24 @@ function renderHuman2(payload, opts) {
7859
8471
  success(`all ${ok} check${ok === 1 ? "" : "s"} passed`, opts);
7860
8472
  }
7861
8473
  }
8474
+ function fixOwnerOnlyPermissions(root, project) {
8475
+ const fixes = [
8476
+ { path: ".noir/.env", outcome: ensureOwnerOnly(join6(paths4.noirDir(root), ".env")) }
8477
+ ];
8478
+ if (project) {
8479
+ const dbRel = `.noir/store/${project.id}.db`;
8480
+ fixes.push({ path: dbRel, outcome: ensureOwnerOnly(join6(root, dbRel)) });
8481
+ fixes.push({ path: ".noir/store", outcome: ensureOwnerOnlyDir(paths4.storeDir(root)) });
8482
+ }
8483
+ return fixes;
8484
+ }
7862
8485
  async function doctor(opts = {}) {
7863
8486
  const root = process.cwd();
7864
8487
  const checks = [];
7865
8488
  await checkRuntime(checks);
7866
8489
  const { project, result: configResult } = checkConfig(root);
7867
8490
  checks.push(configResult);
8491
+ const fixes = opts.fix === true ? fixOwnerOnlyPermissions(root, project) : void 0;
7868
8492
  await checkDaemon(checks, project);
7869
8493
  const { vecOk } = await checkNativeDeps(checks);
7870
8494
  await checkStore(checks, project, root);
@@ -7877,6 +8501,7 @@ async function doctor(opts = {}) {
7877
8501
  const rules = checkRulesMdBudget(checks, root, project);
7878
8502
  const host = checkHostArtifacts(checks, root, project);
7879
8503
  checkNestedNoir(checks, root);
8504
+ checkOutputHygiene(checks, root);
7880
8505
  if (opts.dedup === true) {
7881
8506
  await checkSemanticDupDoctor(checks, root, project);
7882
8507
  }
@@ -7900,7 +8525,8 @@ async function doctor(opts = {}) {
7900
8525
  method: installCheck.method,
7901
8526
  installedVersion: installCheck.installedVersion,
7902
8527
  latestKnown: installCheck.latestKnown
7903
- }
8528
+ },
8529
+ ...fixes ? { fixes } : {}
7904
8530
  };
7905
8531
  if (opts.json === true) {
7906
8532
  process.stdout.write(`${JSON.stringify({ ok: true, data: payload })}
@@ -7920,17 +8546,17 @@ var init_doctor = __esm({
7920
8546
  "src/commands/doctor.ts"() {
7921
8547
  "use strict";
7922
8548
  init_daemon_client();
8549
+ init_hygiene_scan();
7923
8550
  init_output();
7924
8551
  init_theme();
8552
+ init_width();
7925
8553
  ENV_REFUSAL_PREFIX = ".noir/.env: refusing to load";
7926
8554
  SEMVER_ISH = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
7927
8555
  }
7928
8556
  });
7929
8557
 
7930
- // src/doctor.ts
7931
- init_doctor();
7932
-
7933
8558
  // src/index.ts
8559
+ init_doctor();
7934
8560
  init_init();
7935
8561
  init_serve();
7936
8562
  export {