@noir-ai/cli 1.14.0 → 1.15.0-beta.1

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
@@ -235,16 +235,36 @@ function spinner(text = "", opts = {}) {
235
235
  }
236
236
  return new OraSpinner(text);
237
237
  }
238
+ function failureEnvelope(exitCode, message) {
239
+ return `${JSON.stringify({ ok: false, error: { code: exitCode, message } })}
240
+ `;
241
+ }
238
242
  function fail(exitCode, message, opts = {}) {
239
243
  if (isJsonMode(opts)) {
240
- process.stdout.write(`${JSON.stringify({ ok: false, error: { code: exitCode, message } })}
241
- `);
244
+ process.stdout.write(failureEnvelope(exitCode, message));
242
245
  } else if (message.length > 0) {
243
246
  process.stderr.write(`${message}
244
247
  `);
245
248
  }
246
249
  throw new CommanderError(exitCode, NOIR_ERROR_CODE, message);
247
250
  }
251
+ function failAndExit(exitCode, message, opts = {}) {
252
+ process.exitCode = exitCode;
253
+ const jsonMode = isJsonMode(opts);
254
+ const text = jsonMode ? failureEnvelope(exitCode, message) : message.length > 0 ? `${message}
255
+ ` : "";
256
+ if (text.length === 0) {
257
+ process.exit(exitCode);
258
+ }
259
+ let left = false;
260
+ const leave = () => {
261
+ if (left) return;
262
+ left = true;
263
+ process.exit(exitCode);
264
+ };
265
+ (jsonMode ? process.stdout : process.stderr).write(text, leave);
266
+ setTimeout(leave, EXIT_FLUSH_MS);
267
+ }
248
268
  function requireInteractive(opts, hint) {
249
269
  if (!isInteractive(opts)) {
250
270
  fail(
@@ -285,7 +305,7 @@ function handleError(err) {
285
305
  }
286
306
  process.exitCode = inferExitCode(err);
287
307
  }
288
- var EXIT, NoirCliError, NOIR_ERROR_CODE, NoopSpinner, OraSpinner;
308
+ var EXIT, NoirCliError, NOIR_ERROR_CODE, NoopSpinner, OraSpinner, EXIT_FLUSH_MS;
289
309
  var init_output = __esm({
290
310
  "src/output.ts"() {
291
311
  "use strict";
@@ -364,6 +384,7 @@ var init_output = __esm({
364
384
  this.handle.info(text);
365
385
  }
366
386
  };
387
+ EXIT_FLUSH_MS = 250;
367
388
  }
368
389
  });
369
390
 
@@ -482,8 +503,8 @@ async function contextIndex(opts) {
482
503
  }
483
504
  renderIndex(data, opts.paths, opts);
484
505
  }
485
- function renderIndex(data, paths3, opts) {
486
- const scope = paths3 && paths3.length > 0 ? paths3.join(", ") : ".";
506
+ function renderIndex(data, paths5, opts) {
507
+ const scope = paths5 && paths5.length > 0 ? paths5.join(", ") : ".";
487
508
  const flag = data.degraded ? ` ${badge("warn", "degraded: vectors skipped")}` : "";
488
509
  log(`context index \u2014 ${scope}${flag}`, opts);
489
510
  definitionList(
@@ -1242,12 +1263,12 @@ function describeSource(row) {
1242
1263
  return row.shadowed === true ? ".noir/.env (shadows environment)" : ".noir/.env";
1243
1264
  }
1244
1265
  async function env(opts = {}) {
1245
- const report = resolveEnv(process.cwd(), ambientBaseline ?? process.env);
1266
+ const report2 = resolveEnv(process.cwd(), ambientBaseline ?? process.env);
1246
1267
  if (opts.json === true) {
1247
- json({ ok: true, data: { vars: report.vars, warnings: report.warnings } });
1268
+ json({ ok: true, data: { vars: report2.vars, warnings: report2.warnings } });
1248
1269
  return;
1249
1270
  }
1250
- if (report.vars.length === 0) {
1271
+ if (report2.vars.length === 0) {
1251
1272
  info(
1252
1273
  "nothing to report \u2014 .noir/.env defines no keys in effect and no Noir-relevant environment variable is set",
1253
1274
  opts
@@ -1255,10 +1276,10 @@ async function env(opts = {}) {
1255
1276
  return;
1256
1277
  }
1257
1278
  table(
1258
- report.vars.map((row) => ({
1279
+ report2.vars.map((row) => ({
1259
1280
  KEY: row.key,
1260
1281
  SOURCE: describeSource(row),
1261
- VALUE: report.shapes[row.key] ?? ""
1282
+ VALUE: report2.shapes[row.key] ?? ""
1262
1283
  })),
1263
1284
  ["KEY", "SOURCE", "VALUE"],
1264
1285
  opts
@@ -1276,6 +1297,9 @@ var init_env = __esm({
1276
1297
  "COHERE_API_KEY",
1277
1298
  "OLLAMA_BASE_URL",
1278
1299
  "ANTHROPIC_API_KEY",
1300
+ "ANTHROPIC_AUTH_TOKEN",
1301
+ "ANTHROPIC_BASE_URL",
1302
+ "API_TIMEOUT_MS",
1279
1303
  "NOIR_PROFILE"
1280
1304
  ];
1281
1305
  SHORT_VALUE_MAX = 8;
@@ -2330,7 +2354,7 @@ async function taskVerify(opts) {
2330
2354
  `${JSON.stringify({
2331
2355
  ok: false,
2332
2356
  error: { code: EXIT.ERROR, message: `task verify: ${detail}` },
2333
- // S9 `{ok:false, error}` + a payload — recovery context + evidence nest
2357
+ // `{ok:false, error}` + a payload — recovery context + evidence nest
2334
2358
  // under `data`, not as top-level siblings (same envelope shape as the
2335
2359
  // success path; consumers read `data` for the full picture).
2336
2360
  data: {
@@ -2448,7 +2472,7 @@ var init_task = __esm({
2448
2472
  init_output();
2449
2473
  init_theme();
2450
2474
  PHASE_SKILL = {
2451
- // C3 curation (2026-08-10): intake+clarify merged into brainstorming;
2475
+ // Skill curation: intake+clarify merged into brainstorming;
2452
2476
  // execute → executing-plans; verify → verifying; document → wrap.
2453
2477
  intake: "noir-brainstorming",
2454
2478
  clarify: "noir-brainstorming",
@@ -2717,6 +2741,10 @@ function capitalizeFirst(s) {
2717
2741
  function hasAction(cmd) {
2718
2742
  return cmd._actionHandler != null;
2719
2743
  }
2744
+ function requiredArgLabel(cmd) {
2745
+ const required = cmd.registeredArguments.find((arg) => arg.required);
2746
+ return required?.name();
2747
+ }
2720
2748
  function collectLeaves(cmd, path, out) {
2721
2749
  const children = cmd.commands;
2722
2750
  const isLeaf = hasAction(cmd) && children.length === 0;
@@ -2731,7 +2759,8 @@ function collectLeaves(cmd, path, out) {
2731
2759
  description,
2732
2760
  category: argv[0] ?? "",
2733
2761
  keywords: argv,
2734
- destructive: isDestructive(argv)
2762
+ destructive: isDestructive(argv),
2763
+ needsArg: requiredArgLabel(cmd)
2735
2764
  });
2736
2765
  return;
2737
2766
  }
@@ -2844,7 +2873,15 @@ var init_sections = __esm({
2844
2873
  label: "Save memory",
2845
2874
  hint: "save an observation \xB7 \u26A0 destructive",
2846
2875
  destructive: true,
2847
- needsArg: { prompt: "Memory content:", placeholder: "what you want to remember" }
2876
+ // The value travels as `--content <text>` (memory save declares no
2877
+ // positional), so the flag rides in the dispatch prefix and the
2878
+ // collected value is appended after it.
2879
+ dispatch: ["memory", "save", "--content"],
2880
+ needsArg: {
2881
+ label: "content",
2882
+ prompt: "Memory content:",
2883
+ placeholder: "what you want to remember"
2884
+ }
2848
2885
  },
2849
2886
  {
2850
2887
  id: "memory sessions",
@@ -2868,8 +2905,21 @@ var init_sections = __esm({
2868
2905
  {
2869
2906
  id: "workflow",
2870
2907
  label: "Workflow",
2871
- hint: "next \xB7 status \xB7 advance \xB7 handoff \xB7 wrap",
2908
+ hint: "run \xB7 next \xB7 status \xB7 advance \xB7 handoff \xB7 wrap",
2872
2909
  items: [
2910
+ {
2911
+ id: "run",
2912
+ label: "Ask the host",
2913
+ hint: "ask the host agent a question \xB7 \u26A0 destructive",
2914
+ destructive: true,
2915
+ // The prompt is not a required argument (the command also takes flags
2916
+ // like --list-profiles), so the palette only learns to ask for it here.
2917
+ needsArg: {
2918
+ label: "prompt",
2919
+ prompt: "Prompt for the host:",
2920
+ placeholder: "e.g. summarise the auth flow"
2921
+ }
2922
+ },
2873
2923
  {
2874
2924
  id: "task next",
2875
2925
  label: "Next task",
@@ -3721,6 +3771,15 @@ var init_release = __esm({
3721
3771
  }
3722
3772
  });
3723
3773
 
3774
+ // src/run-timing.ts
3775
+ var INTERRUPT_GRACE_MS;
3776
+ var init_run_timing = __esm({
3777
+ "src/run-timing.ts"() {
3778
+ "use strict";
3779
+ INTERRUPT_GRACE_MS = 5e3;
3780
+ }
3781
+ });
3782
+
3724
3783
  // src/shell-bridge.ts
3725
3784
  import { spawn } from "child_process";
3726
3785
  import { basename } from "path";
@@ -3755,7 +3814,7 @@ function shellKind(shell) {
3755
3814
  function probeCommand(name, shell, opts) {
3756
3815
  const kind = shellKind(shell);
3757
3816
  const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS2;
3758
- return new Promise((resolve2) => {
3817
+ return new Promise((resolve3) => {
3759
3818
  const script = kind ? PROBE_SCRIPTS[kind] : "";
3760
3819
  const flags = kind ? PROBE_FLAGS[kind] : "-lic";
3761
3820
  const probeArgs = kind === "fish" ? [name] : ["noirbridge", name];
@@ -3778,15 +3837,15 @@ function probeCommand(name, shell, opts) {
3778
3837
  } catch {
3779
3838
  }
3780
3839
  }
3781
- resolve2({ stdout, timedOut: true });
3840
+ resolve3({ stdout, timedOut: true });
3782
3841
  }, timeoutMs);
3783
3842
  child.on("error", () => {
3784
3843
  clearTimeout(timer);
3785
- resolve2({ stdout, timedOut: false });
3844
+ resolve3({ stdout, timedOut: false });
3786
3845
  });
3787
3846
  child.on("close", () => {
3788
3847
  clearTimeout(timer);
3789
- resolve2({ stdout, timedOut: false });
3848
+ resolve3({ stdout, timedOut: false });
3790
3849
  });
3791
3850
  });
3792
3851
  }
@@ -3882,18 +3941,23 @@ function usageFrom(v) {
3882
3941
  if (input === void 0 && output === void 0 && cache === void 0) return void 0;
3883
3942
  return { inputTokens: input, outputTokens: output, cacheReadTokens: cache };
3884
3943
  }
3885
- function messageText(message) {
3886
- if (!message) return void 0;
3944
+ function messageContent(message) {
3945
+ if (!message) return {};
3887
3946
  const content = message.content;
3888
- if (typeof content === "string") return content;
3889
- if (Array.isArray(content)) {
3890
- const parts = content.map((block) => {
3891
- const b = asObj(block);
3892
- return b && typeof b.text === "string" ? b.text : null;
3893
- }).filter((t) => t !== null);
3894
- if (parts.length > 0) return parts.join("");
3947
+ if (typeof content === "string") return { text: content };
3948
+ if (!Array.isArray(content)) return {};
3949
+ const parts = [];
3950
+ const tools = [];
3951
+ for (const block of content) {
3952
+ const b = asObj(block);
3953
+ if (!b) continue;
3954
+ if (typeof b.text === "string") parts.push(b.text);
3955
+ else if (b.type === "tool_use" && typeof b.name === "string") tools.push(b.name);
3895
3956
  }
3896
- return void 0;
3957
+ return {
3958
+ ...parts.length > 0 ? { text: parts.join("") } : {},
3959
+ ...tools.length > 0 ? { tools } : {}
3960
+ };
3897
3961
  }
3898
3962
  function parseStreamLine(line) {
3899
3963
  const trimmed = line.trim();
@@ -3912,15 +3976,14 @@ function normalizeStreamEvent(raw) {
3912
3976
  if (r.subtype === "init") {
3913
3977
  return { kind: "init", sessionId: str(r.session_id), model: str(r.model) };
3914
3978
  }
3915
- return { kind: "other" };
3979
+ return { kind: "other", subtype: str(r.subtype) ?? "system" };
3916
3980
  }
3917
3981
  if (type === "assistant") {
3918
3982
  const message = asObj(r.message);
3919
- return {
3920
- kind: "assistant",
3921
- messageId: message ? str(message.id) : void 0,
3922
- text: messageText(message),
3923
- usage: message ? usageFrom(message.usage) : void 0,
3983
+ const content = messageContent(message);
3984
+ const messageId = message ? str(message.id) : void 0;
3985
+ const usage = message ? usageFrom(message.usage) : void 0;
3986
+ const error2 = {
3924
3987
  // An API error message is a diagnostic, not the answer — flag it so the
3925
3988
  // caller never streams it as assistant text (optional-when-true keeps
3926
3989
  // exact-shape consumers of the existing union intact).
@@ -3929,6 +3992,20 @@ function normalizeStreamEvent(raw) {
3929
3992
  ...typeof r.error === "string" ? { errorCategory: r.error } : {}
3930
3993
  } : {}
3931
3994
  };
3995
+ if (content.text !== void 0) {
3996
+ return {
3997
+ kind: "assistant",
3998
+ messageId,
3999
+ text: content.text,
4000
+ usage,
4001
+ ...content.tools === void 0 ? {} : { tools: content.tools },
4002
+ ...error2
4003
+ };
4004
+ }
4005
+ if (content.tools !== void 0) {
4006
+ return { kind: "tool", name: content.tools[0], messageId, usage };
4007
+ }
4008
+ return { kind: "assistant", messageId, usage, ...error2 };
3932
4009
  }
3933
4010
  if (type === "result") {
3934
4011
  return {
@@ -3940,7 +4017,33 @@ function normalizeStreamEvent(raw) {
3940
4017
  usage: usageFrom(r.usage)
3941
4018
  };
3942
4019
  }
3943
- return { kind: "other" };
4020
+ if (type === "stream_event") {
4021
+ return normalizeStreamEventInner(asObj(r.event));
4022
+ }
4023
+ return { kind: "other", ...typeof type === "string" ? { subtype: type } : {} };
4024
+ }
4025
+ function normalizeStreamEventInner(inner) {
4026
+ if (!inner) return { kind: "other", subtype: "stream_event" };
4027
+ const innerType = str(inner.type);
4028
+ if (innerType === "content_block_start") {
4029
+ const block = asObj(inner.content_block);
4030
+ const name = block ? str(block.name) : void 0;
4031
+ if (block && str(block.type) === "tool_use" && name !== void 0) {
4032
+ return { kind: "tool", name };
4033
+ }
4034
+ }
4035
+ if (innerType === "content_block_delta") {
4036
+ const delta = asObj(inner.delta);
4037
+ const text = delta ? str(delta.text) : void 0;
4038
+ if (delta && delta.type === "text_delta" && text !== void 0) {
4039
+ return { kind: "delta", text };
4040
+ }
4041
+ }
4042
+ return { kind: "other", subtype: innerType ?? "stream_event" };
4043
+ }
4044
+ function signalChild(child, signal) {
4045
+ if (child.pid === void 0) return;
4046
+ child.kill(signal);
3944
4047
  }
3945
4048
  function runHost(opts) {
3946
4049
  const spec = resolveHostRun(opts.host, opts.customBinary);
@@ -3956,12 +4059,13 @@ function runHost(opts) {
3956
4059
  false
3957
4060
  );
3958
4061
  }
3959
- function spawnAndConsume(binary, args, opts, shellRun) {
3960
- return new Promise((resolve2, reject) => {
4062
+ function spawnAndConsume(binary, args, opts, shellRun, bridged = false) {
4063
+ return new Promise((resolve3, reject) => {
3961
4064
  const child = spawn2(binary, [...args], {
3962
4065
  stdio: ["ignore", "pipe", "pipe"],
3963
4066
  ...opts.env ? { env: opts.env } : {}
3964
4067
  });
4068
+ opts.onChild?.(child);
3965
4069
  const reducer = new UsageReducer();
3966
4070
  let eventCount = 0;
3967
4071
  let stderrBuf = "";
@@ -3969,10 +4073,40 @@ function spawnAndConsume(binary, args, opts, shellRun) {
3969
4073
  let errorText;
3970
4074
  let errorCategory;
3971
4075
  let spawnError = null;
4076
+ const { signal } = opts;
4077
+ let resend;
4078
+ let offersLeft = Math.ceil(BRIDGE_POLITE_WINDOW_MS / BRIDGE_POLITE_RESEND_MS);
4079
+ const stopResend = () => {
4080
+ if (resend === void 0) return;
4081
+ clearTimeout(resend);
4082
+ resend = void 0;
4083
+ };
4084
+ const offerAgain = () => {
4085
+ if (offersLeft <= 0) return;
4086
+ offersLeft -= 1;
4087
+ resend = setTimeout(() => {
4088
+ resend = void 0;
4089
+ signalChild(child, "SIGTERM");
4090
+ offerAgain();
4091
+ }, BRIDGE_POLITE_RESEND_MS);
4092
+ };
4093
+ const onAbort = () => {
4094
+ signalChild(child, "SIGTERM");
4095
+ if (bridged) offerAgain();
4096
+ };
4097
+ if (signal) {
4098
+ if (signal.aborted) onAbort();
4099
+ else signal.addEventListener("abort", onAbort, { once: true });
4100
+ }
4101
+ const detachAbort = () => {
4102
+ signal?.removeEventListener("abort", onAbort);
4103
+ stopResend();
4104
+ };
3972
4105
  child.on("error", (err) => {
3973
4106
  spawnError = err;
4107
+ detachAbort();
3974
4108
  if (!shellRun && err.code === "ENOENT") {
3975
- void shellFallback(binary, args, opts, resolve2, reject, err);
4109
+ void shellFallback(binary, args, opts, resolve3, reject, err);
3976
4110
  return;
3977
4111
  }
3978
4112
  reject(err);
@@ -3999,41 +4133,43 @@ function spawnAndConsume(binary, args, opts, shellRun) {
3999
4133
  child.stderr.on("data", (chunk) => {
4000
4134
  stderrBuf += chunk;
4001
4135
  });
4002
- child.on("close", (code, signal) => {
4136
+ child.on("close", (code, signal2) => {
4137
+ detachAbort();
4003
4138
  if (spawnError !== null) return;
4004
- const killed = code === null && signal != null;
4005
- resolve2({
4006
- exitCode: killed ? 128 + (signalNumber(signal) ?? 0) : code ?? 0,
4139
+ const killed = code === null && signal2 != null;
4140
+ resolve3({
4141
+ exitCode: killed ? 128 + (signalNumber(signal2) ?? 0) : code ?? 0,
4007
4142
  usage: reducer.snapshot(),
4008
4143
  eventCount,
4009
4144
  stderr: stderrBuf,
4010
4145
  isError: errored || killed,
4011
- errorText: killed && errorText === void 0 ? `terminated by signal ${signal}` : errorText,
4146
+ errorText: killed && errorText === void 0 ? `terminated by signal ${signal2}` : errorText,
4012
4147
  errorCategory
4013
4148
  });
4014
4149
  });
4015
4150
  });
4016
4151
  }
4017
- async function shellFallback(binary, args, opts, resolve2, reject, originalErr) {
4152
+ async function shellFallback(binary, args, opts, resolve3, reject, originalErr) {
4018
4153
  try {
4019
4154
  const res = await resolveCommandViaShell(binary, { env: opts.env ?? process.env });
4020
4155
  if (res.kind === "path") {
4021
- resolve2(await spawnAndConsume(res.path, args, opts, true));
4156
+ resolve3(await spawnAndConsume(res.path, args, opts, true));
4022
4157
  return;
4023
4158
  }
4024
4159
  if (res.kind === "alias" || res.kind === "function") {
4025
4160
  const bridge = buildBridgeArgs(binary, args, res.shell);
4026
- resolve2(await spawnAndConsume(bridge.binary, bridge.args, opts, true));
4161
+ resolve3(await spawnAndConsume(bridge.binary, bridge.args, opts, true, true));
4027
4162
  return;
4028
4163
  }
4029
4164
  } catch {
4030
4165
  }
4031
4166
  reject(originalErr);
4032
4167
  }
4033
- var HOST_BINARY, HOST_FLAGS, UsageReducer;
4168
+ var HOST_BINARY, HOST_FLAGS, UsageReducer, BRIDGE_POLITE_RESEND_MS, BRIDGE_POLITE_WINDOW_MS;
4034
4169
  var init_orchestrator = __esm({
4035
4170
  "src/orchestrator.ts"() {
4036
4171
  "use strict";
4172
+ init_run_timing();
4037
4173
  init_shell_bridge();
4038
4174
  HOST_BINARY = {
4039
4175
  claude: "claude",
@@ -4044,7 +4180,7 @@ var init_orchestrator = __esm({
4044
4180
  // emits AGENTS.md — a file host, not a spawnable CLI
4045
4181
  };
4046
4182
  HOST_FLAGS = {
4047
- claude: ["-p", "--output-format", "stream-json", "--verbose"],
4183
+ claude: ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages"],
4048
4184
  gemini: ["-p", "--output-format", "stream-json"],
4049
4185
  opencode: ["run"],
4050
4186
  cursor: ["-p"],
@@ -4058,27 +4194,29 @@ var init_orchestrator = __esm({
4058
4194
  turns = 0;
4059
4195
  /** Feed one normalized event into the reducer. */
4060
4196
  add(event) {
4061
- if (event.kind === "assistant" && event.messageId && event.usage) {
4062
- const key = event.messageId;
4063
- const input = event.usage.inputTokens ?? 0;
4064
- const output = event.usage.outputTokens ?? 0;
4065
- const prev = this.maxByMessage.get(key);
4066
- if (!prev) {
4067
- this.maxByMessage.set(key, { input, output });
4068
- this.input += input;
4069
- this.output += output;
4070
- } else {
4071
- this.input += Math.max(0, input - prev.input);
4072
- this.output += Math.max(0, output - prev.output);
4073
- this.maxByMessage.set(key, {
4074
- input: Math.max(prev.input, input),
4075
- output: Math.max(prev.output, output)
4076
- });
4077
- }
4078
- } else if (event.kind === "result") {
4197
+ if (event.kind === "result") {
4079
4198
  if (event.totalCostUsd !== void 0) this.costUsd = event.totalCostUsd;
4080
4199
  if (event.numTurns !== void 0) this.turns = event.numTurns;
4200
+ return;
4201
+ }
4202
+ const usage = event.kind === "assistant" || event.kind === "tool" ? event.usage : void 0;
4203
+ const key = event.kind === "assistant" || event.kind === "tool" ? event.messageId : void 0;
4204
+ if (!key || !usage) return;
4205
+ const input = usage.inputTokens ?? 0;
4206
+ const output = usage.outputTokens ?? 0;
4207
+ const prev = this.maxByMessage.get(key);
4208
+ if (!prev) {
4209
+ this.maxByMessage.set(key, { input, output });
4210
+ this.input += input;
4211
+ this.output += output;
4212
+ return;
4081
4213
  }
4214
+ this.input += Math.max(0, input - prev.input);
4215
+ this.output += Math.max(0, output - prev.output);
4216
+ this.maxByMessage.set(key, {
4217
+ input: Math.max(prev.input, input),
4218
+ output: Math.max(prev.output, output)
4219
+ });
4082
4220
  }
4083
4221
  /** The current accumulator snapshot. */
4084
4222
  snapshot() {
@@ -4090,11 +4228,362 @@ var init_orchestrator = __esm({
4090
4228
  };
4091
4229
  }
4092
4230
  };
4231
+ BRIDGE_POLITE_RESEND_MS = 250;
4232
+ BRIDGE_POLITE_WINDOW_MS = INTERRUPT_GRACE_MS;
4233
+ }
4234
+ });
4235
+
4236
+ // src/run-actions.ts
4237
+ import { writeFileSync as writeFileSync3 } from "fs";
4238
+ import { resolve } from "path";
4239
+ import { CommanderError as CommanderError2 } from "commander";
4240
+ function postRunActions(input) {
4241
+ const answer = input.answer.trim();
4242
+ const canContinue = input.canContinue ?? (input.sessionId !== void 0 && input.resume !== void 0);
4243
+ const options = [];
4244
+ if (answer.length > 0) {
4245
+ options.push({
4246
+ value: "memory",
4247
+ label: "Save answer to memory",
4248
+ hint: "distil it into this project's memory (needs the daemon)"
4249
+ });
4250
+ options.push({
4251
+ value: "research",
4252
+ label: "Add as task research",
4253
+ hint: "record it as a finding on the active task"
4254
+ });
4255
+ }
4256
+ options.push({
4257
+ value: "handoff",
4258
+ label: "Write a handoff artifact",
4259
+ hint: "snapshot the task, the next step and a seed for your host"
4260
+ });
4261
+ if (canContinue) {
4262
+ options.push({
4263
+ value: "resume",
4264
+ label: "Continue this session",
4265
+ hint: `ask ${input.host} a follow-up in the same session`,
4266
+ needsValue: {
4267
+ label: "follow-up prompt",
4268
+ placeholder: "e.g. now write the tests for that"
4269
+ }
4270
+ });
4271
+ }
4272
+ if (answer.length > 0) {
4273
+ options.push({
4274
+ value: "save",
4275
+ label: "Save answer to a file",
4276
+ hint: "write the answer text to a path you choose",
4277
+ needsValue: { label: "file path", placeholder: "e.g. notes/answer.md" }
4278
+ });
4279
+ }
4280
+ options.push({
4281
+ value: "dismiss",
4282
+ label: "Dismiss",
4283
+ hint: "nothing further \u2014 the run is already complete"
4284
+ });
4285
+ return options;
4286
+ }
4287
+ async function performPostRunAction(choice, input, value) {
4288
+ const label = ACTION_LABEL[choice];
4289
+ try {
4290
+ switch (choice) {
4291
+ case "memory":
4292
+ await saveToMemory(input);
4293
+ return { ok: true, message: "" };
4294
+ case "research":
4295
+ await recordAsResearch(input);
4296
+ return { ok: true, message: "" };
4297
+ case "handoff":
4298
+ await writeHandoff(input);
4299
+ return { ok: true, message: "" };
4300
+ case "resume":
4301
+ return await continueSession(input, value);
4302
+ case "save":
4303
+ return saveAnswerToFile(input, value);
4304
+ case "dismiss":
4305
+ return { ok: true, message: "" };
4306
+ }
4307
+ } catch (err) {
4308
+ const quoted = err instanceof CommanderError2 || err instanceof NoirCliError ? void 0 : `${label}: ${err instanceof Error ? err.message : String(err)}`;
4309
+ return {
4310
+ ok: false,
4311
+ message: `${label} did not complete \u2014 the run's result stands.`,
4312
+ ...quoted === void 0 ? {} : { detail: quoted }
4313
+ };
4314
+ }
4315
+ }
4316
+ async function offerPostRunActions(input) {
4317
+ if (!isInteractive(input.opts)) return;
4318
+ const options = postRunActions(input);
4319
+ if (options.length <= 1) return;
4320
+ const menu = await openMenu(options);
4321
+ if (menu === void 0) return;
4322
+ const chosen = options.find((o) => o.value === menu.choice);
4323
+ if (chosen === void 0) return;
4324
+ const value = chosen.needsValue === void 0 ? void 0 : await askForValue(menu.clack, chosen, input);
4325
+ if (chosen.needsValue !== void 0 && value === void 0) return;
4326
+ report(await performPostRunAction(menu.choice, input, value), input.opts);
4327
+ }
4328
+ function report(outcome, opts) {
4329
+ const caution = outcome.tone === "caution" || !outcome.ok && outcome.tone !== "confirm";
4330
+ if (outcome.detail !== void 0) warn(outcome.detail, opts);
4331
+ if (outcome.message.length === 0) return;
4332
+ if (caution) warn(outcome.message, opts);
4333
+ else success(outcome.message, opts);
4334
+ }
4335
+ async function openMenu(options) {
4336
+ try {
4337
+ const clack = await import("@clack/prompts");
4338
+ const choice = await clack.select({
4339
+ message: "The run finished. What next?",
4340
+ initialValue: "dismiss",
4341
+ // Spread because the prompt library takes a mutable list; the rows
4342
+ // themselves are the shared descriptors and are never rewritten.
4343
+ options: [...options]
4344
+ });
4345
+ if (clack.isCancel(choice)) return void 0;
4346
+ return { clack, choice };
4347
+ } catch {
4348
+ return void 0;
4349
+ }
4350
+ }
4351
+ async function askForValue(clack, option, input) {
4352
+ const needs = option.needsValue;
4353
+ if (needs === void 0) return void 0;
4354
+ const message = option.value === "save" ? `Write the answer to a file (the raw transcript is at ${input.transcript})` : `What should ${input.host} do next?`;
4355
+ try {
4356
+ const written = await clack.text({ message, placeholder: needs.placeholder });
4357
+ if (clack.isCancel(written)) return void 0;
4358
+ return String(written ?? "");
4359
+ } catch {
4360
+ return void 0;
4361
+ }
4362
+ }
4363
+ async function saveToMemory(input) {
4364
+ await memoryCapture({
4365
+ ...input.opts,
4366
+ content: input.answer,
4367
+ eventType: CAPTURE_EVENT
4368
+ });
4369
+ }
4370
+ async function recordAsResearch(input) {
4371
+ await taskResearchRecord({
4372
+ ...input.opts,
4373
+ type: "discovery",
4374
+ text: researchText(input.answer),
4375
+ source: RESEARCH_SOURCE
4376
+ });
4377
+ }
4378
+ function researchText(answer) {
4379
+ const condensed = answer.replace(/\s+/g, " ").trim();
4380
+ if (condensed.length <= RESEARCH_TEXT_LIMIT) return condensed;
4381
+ return `${condensed.slice(0, RESEARCH_TEXT_LIMIT - 1)}\u2026`;
4382
+ }
4383
+ async function writeHandoff(input) {
4384
+ await handoff({ ...input.opts, write: true });
4385
+ }
4386
+ async function continueSession(input, value) {
4387
+ const { sessionId, resume } = input;
4388
+ if (sessionId === void 0 || resume === void 0) return { ok: true, message: "" };
4389
+ const prompt = (value ?? "").trim();
4390
+ if (prompt.length === 0) {
4391
+ return {
4392
+ ok: true,
4393
+ tone: "caution",
4394
+ message: "No prompt given \u2014 the session was not continued."
4395
+ };
4396
+ }
4397
+ await resume(prompt, sessionId);
4398
+ return { ok: true, message: "" };
4399
+ }
4400
+ function saveAnswerToFile(input, value) {
4401
+ const fallback = `noir-run-${stamp2()}.md`;
4402
+ const chosen = (value ?? "").trim();
4403
+ const target = resolve(chosen.length > 0 ? chosen : fallback);
4404
+ const body = input.answer.endsWith("\n") ? input.answer : `${input.answer}
4405
+ `;
4406
+ try {
4407
+ writeFileSync3(target, body, { mode: 384 });
4408
+ } catch (err) {
4409
+ return {
4410
+ ok: false,
4411
+ tone: "caution",
4412
+ message: `could not write ${target}: ${err instanceof Error ? err.message : String(err)}`
4413
+ };
4414
+ }
4415
+ return { ok: true, message: `Answer written to ${target}` };
4416
+ }
4417
+ function stamp2() {
4418
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4419
+ }
4420
+ var CAPTURE_EVENT, RESEARCH_SOURCE, RESEARCH_TEXT_LIMIT, ACTION_LABEL;
4421
+ var init_run_actions = __esm({
4422
+ "src/run-actions.ts"() {
4423
+ "use strict";
4424
+ init_handoff();
4425
+ init_memory();
4426
+ init_task();
4427
+ init_output();
4428
+ CAPTURE_EVENT = "capture";
4429
+ RESEARCH_SOURCE = "noir run";
4430
+ RESEARCH_TEXT_LIMIT = 220;
4431
+ ACTION_LABEL = {
4432
+ memory: "Saving to memory",
4433
+ research: "Recording the finding",
4434
+ handoff: "Writing the handoff",
4435
+ resume: "Continuing the session",
4436
+ save: "Writing the file",
4437
+ dismiss: "Dismissing"
4438
+ };
4439
+ }
4440
+ });
4441
+
4442
+ // src/run-interrupt.ts
4443
+ import { constants as constants2 } from "os";
4444
+ function exitCodeForSignal(signal) {
4445
+ return 128 + (constants2.signals[signal] ?? 0);
4446
+ }
4447
+ function interruptedNotice(transcript) {
4448
+ return `interrupted \xB7 transcript: ${transcript}`;
4449
+ }
4450
+ var INTERRUPT_SIGNALS, RunInterrupt;
4451
+ var init_run_interrupt = __esm({
4452
+ "src/run-interrupt.ts"() {
4453
+ "use strict";
4454
+ init_orchestrator();
4455
+ init_run_timing();
4456
+ INTERRUPT_SIGNALS = ["SIGINT", "SIGTERM"];
4457
+ RunInterrupt = class {
4458
+ graceMs;
4459
+ exitNow;
4460
+ controller = new AbortController();
4461
+ /** The live child, or undefined between spawns. */
4462
+ child;
4463
+ /** The child the ladder already ran for — a repeat call is not a new ladder. */
4464
+ stopping;
4465
+ grace;
4466
+ signalName;
4467
+ stopRequested = false;
4468
+ forced = false;
4469
+ watching = false;
4470
+ /** The installed listeners, keyed by the signal each answers. */
4471
+ handlers = /* @__PURE__ */ new Map();
4472
+ constructor(opts = {}) {
4473
+ this.graceMs = opts.graceMs ?? INTERRUPT_GRACE_MS;
4474
+ this.exitNow = opts.exitNow ?? ((code) => {
4475
+ if (this.child !== void 0) signalChild(this.child, "SIGKILL");
4476
+ process.exit(code);
4477
+ });
4478
+ }
4479
+ /**
4480
+ * The cancel signal for the spawn. Aborting it is what a caller holding only
4481
+ * the signal can do; the ladder itself does not depend on it, which matters
4482
+ * because the child a run ends up driving is not always the first one spawned.
4483
+ */
4484
+ get signal() {
4485
+ return this.controller.signal;
4486
+ }
4487
+ /** The signal that stopped the run, or undefined when nothing did. */
4488
+ get interruptedBy() {
4489
+ return this.signalName;
4490
+ }
4491
+ /** True once a child had to be forced — it did not honour the polite signal. */
4492
+ get escalated() {
4493
+ return this.forced;
4494
+ }
4495
+ /**
4496
+ * Hand over the child a spawn produced. Called for every spawn of one run: a
4497
+ * command resolved through the shell bridge is its own child, and a stop that
4498
+ * arrived before that child existed has to land on it too.
4499
+ */
4500
+ track(child) {
4501
+ this.child = child;
4502
+ child.once("exit", () => this.forget(child));
4503
+ if (this.stopRequested) this.terminate();
4504
+ }
4505
+ /**
4506
+ * Stop the host. The polite signal goes to the child itself, and a child still
4507
+ * alive after the grace is forced. Repeat calls are the same call: the ladder
4508
+ * runs once per child, so a keystroke held down is not a stream of signals.
4509
+ */
4510
+ terminate() {
4511
+ this.stopRequested = true;
4512
+ if (!this.controller.signal.aborted) this.controller.abort();
4513
+ const child = this.child;
4514
+ if (child === void 0 || child.pid === void 0 || child === this.stopping) return;
4515
+ this.stopping = child;
4516
+ this.clearGrace();
4517
+ signalChild(child, "SIGTERM");
4518
+ this.grace = setTimeout(() => {
4519
+ this.grace = void 0;
4520
+ if (this.child !== child) return;
4521
+ this.forced = true;
4522
+ signalChild(child, "SIGKILL");
4523
+ }, this.graceMs);
4524
+ }
4525
+ /**
4526
+ * Skip the grace and force the host now. The "you asked twice" path: the
4527
+ * terminal leaves at once on a second signal, and the run screen forces and
4528
+ * returns to the dashboard rather than holding a keystroke while the grace
4529
+ * runs out. Does nothing when no stop is in flight.
4530
+ */
4531
+ forceNow() {
4532
+ const child = this.child;
4533
+ if (child === void 0 || child.pid === void 0 || child !== this.stopping) return false;
4534
+ this.forced = true;
4535
+ this.clearGrace();
4536
+ signalChild(child, "SIGKILL");
4537
+ return true;
4538
+ }
4539
+ /**
4540
+ * Install the terminal interrupt handlers. Only a run installs these, and only
4541
+ * for as long as it is driving the host: the process's own handling has to
4542
+ * come back afterwards, or a later command inherits an interrupt contract with
4543
+ * nothing left to interrupt.
4544
+ */
4545
+ watch() {
4546
+ if (this.watching) return;
4547
+ this.watching = true;
4548
+ for (const signal of INTERRUPT_SIGNALS) {
4549
+ const listener = () => this.onSignal(signal);
4550
+ this.handlers.set(signal, listener);
4551
+ process.on(signal, listener);
4552
+ }
4553
+ }
4554
+ /** Put the process's own handling back, whatever the run left behind. */
4555
+ unwatch() {
4556
+ if (!this.watching) return;
4557
+ this.watching = false;
4558
+ for (const [signal, listener] of this.handlers) process.off(signal, listener);
4559
+ this.handlers.clear();
4560
+ }
4561
+ onSignal(signal) {
4562
+ if (this.signalName !== void 0) {
4563
+ this.exitNow(exitCodeForSignal(signal));
4564
+ return;
4565
+ }
4566
+ this.signalName = signal;
4567
+ this.terminate();
4568
+ }
4569
+ /** The child is gone: stop tracking it and disarm its ladder. */
4570
+ forget(child) {
4571
+ if (this.child !== child) return;
4572
+ this.child = void 0;
4573
+ this.stopping = void 0;
4574
+ this.clearGrace();
4575
+ }
4576
+ clearGrace() {
4577
+ if (this.grace === void 0) return;
4578
+ clearTimeout(this.grace);
4579
+ this.grace = void 0;
4580
+ }
4581
+ };
4093
4582
  }
4094
4583
  });
4095
4584
 
4096
4585
  // src/run-profiles.ts
4097
- import { loadProjectInfo as loadProjectInfo11 } from "@noir-ai/core";
4586
+ import { isDeniedEnvKey, loadProjectInfo as loadProjectInfo11 } from "@noir-ai/core";
4098
4587
  function loadRunConfig(root) {
4099
4588
  try {
4100
4589
  return loadProjectInfo11(root).config;
@@ -4129,6 +4618,14 @@ function resolveRunProfile(requested, config, env2) {
4129
4618
  envExpanded[k] = v === null ? void 0 : expandEnvVars(v, env2);
4130
4619
  }
4131
4620
  }
4621
+ for (const key of Object.keys(envExpanded)) {
4622
+ if (isDeniedEnvKey(key)) {
4623
+ return {
4624
+ ok: false,
4625
+ message: `run profile "${name}" refuses process-injection key "${key}" \u2014 a profile env block in .noir/config.yml must not set ${key} (it can inject into the spawned host process). Remove "${key}" from the profile.`
4626
+ };
4627
+ }
4628
+ }
4132
4629
  return {
4133
4630
  ok: true,
4134
4631
  profile: { binary: profile.binary, env: envExpanded, args: profile.args, profileName: name }
@@ -4149,11 +4646,195 @@ var init_run_profiles = __esm({
4149
4646
  }
4150
4647
  });
4151
4648
 
4649
+ // src/run-status.ts
4650
+ function humanizeElapsed(ms) {
4651
+ const total = Math.max(0, Math.floor(ms / 1e3));
4652
+ const seconds = total % 60;
4653
+ const minutes = Math.floor(total / 60) % 60;
4654
+ const hours = Math.floor(total / 3600);
4655
+ if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
4656
+ if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
4657
+ return `${seconds}s`;
4658
+ }
4659
+ function hostStderrTail(stderr, maxLines = HOST_STDERR_TAIL_LINES) {
4660
+ if (maxLines <= 0) return "";
4661
+ const lines = stderr.split("\n").map((line) => line.trimEnd());
4662
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
4663
+ if (lines.length === 0) return "";
4664
+ if (lines.length <= maxLines) return lines.join("\n");
4665
+ const omitted = lines.length - maxLines;
4666
+ return [`\u2026 (${omitted} earlier lines omitted)`, ...lines.slice(-maxLines)].join("\n");
4667
+ }
4668
+ var HOST_STDERR_TAIL_LINES, RunStatusLine;
4669
+ var init_run_status = __esm({
4670
+ "src/run-status.ts"() {
4671
+ "use strict";
4672
+ init_orchestrator();
4673
+ HOST_STDERR_TAIL_LINES = 20;
4674
+ RunStatusLine = class {
4675
+ host;
4676
+ enabled;
4677
+ animated;
4678
+ sharesCursor;
4679
+ write;
4680
+ now;
4681
+ /**
4682
+ * Running token totals for the live line. This is a SECOND accumulator: the
4683
+ * snapshot the run reports at the end is reduced independently from the full
4684
+ * event stream, and both apply the same `max usage per message id` rule, so
4685
+ * the number on screen and the number in the summary agree.
4686
+ */
4687
+ usage = new UsageReducer();
4688
+ startedAt;
4689
+ model;
4690
+ streaming = false;
4691
+ begun = false;
4692
+ ended = false;
4693
+ /** True while the status text is sitting on the row the cursor is currently at. */
4694
+ onRow = false;
4695
+ /** True when the last stdout write ended a line — the only safe moment to move the cursor. */
4696
+ stdoutAtRowStart = true;
4697
+ /** The text currently on the row (empty when the row is not ours). */
4698
+ rendered = "";
4699
+ constructor(opts) {
4700
+ this.host = opts.host;
4701
+ this.enabled = opts.json !== true && opts.quiet !== true;
4702
+ this.animated = this.enabled && opts.stderrIsTty === true;
4703
+ this.sharesCursor = opts.stdoutSharesCursor !== false;
4704
+ this.write = opts.write ?? ((chunk) => void process.stderr.write(chunk));
4705
+ this.now = opts.now ?? (() => performance.now());
4706
+ this.startedAt = this.now();
4707
+ }
4708
+ /**
4709
+ * Claim the row: show the seeded "waiting" state immediately, before the host
4710
+ * has said anything, so the spawn itself is never blank. Calling this twice
4711
+ * is a no-op.
4712
+ */
4713
+ begin() {
4714
+ if (!this.enabled || this.begun) return;
4715
+ this.begun = true;
4716
+ this.startedAt = this.now();
4717
+ if (this.animated) {
4718
+ this.draw();
4719
+ return;
4720
+ }
4721
+ this.write(`${this.render()}
4722
+ `);
4723
+ }
4724
+ /** Feed one normalized host event. Anything unrenderable is ignored. */
4725
+ event(e) {
4726
+ if (!this.enabled || this.ended || !this.begun) return;
4727
+ if (e.kind === "result") {
4728
+ this.end();
4729
+ return;
4730
+ }
4731
+ if (e.kind === "init") {
4732
+ if (e.model !== void 0) this.model = e.model;
4733
+ } else if (e.kind === "assistant" || e.kind === "tool") {
4734
+ this.streaming = true;
4735
+ this.usage.add(e);
4736
+ } else if (e.kind === "delta") {
4737
+ this.streaming = true;
4738
+ } else {
4739
+ return;
4740
+ }
4741
+ this.draw();
4742
+ }
4743
+ /**
4744
+ * Announce the chunk the caller is about to write to STDOUT — before writing
4745
+ * it. Warning the line first lets it finish its row: the answer would
4746
+ * otherwise start glued to the tail of the status text. It also records
4747
+ * whether that write ended a line, which is the only moment the cursor can be
4748
+ * moved without landing inside the answer.
4749
+ */
4750
+ beforeStdout(chunk) {
4751
+ if (chunk.length === 0) return;
4752
+ if (this.animated && this.sharesCursor && this.onRow) {
4753
+ this.write("\n");
4754
+ this.onRow = false;
4755
+ }
4756
+ this.stdoutAtRowStart = chunk.endsWith("\n");
4757
+ }
4758
+ /**
4759
+ * Finish the row the host's answer left open. A failure message printed onto
4760
+ * a half-written answer reads as part of the answer, so a caller about to
4761
+ * report a failure asks for the row to be terminated first. A no-op unless
4762
+ * this line is drawing on a terminal stdout also writes to and the answer
4763
+ * stopped mid-line — the only state where the cursor is inside the answer.
4764
+ */
4765
+ finishRow() {
4766
+ if (!this.animated || !this.sharesCursor || this.stdoutAtRowStart) return;
4767
+ this.write("\n");
4768
+ this.stdoutAtRowStart = true;
4769
+ }
4770
+ /**
4771
+ * Close the line out. On a terminal the line is erased so the run summary
4772
+ * takes its place — unless the answer has since moved the cursor off it, in
4773
+ * which case the text stays as the record of how long the wait was. On a pipe
4774
+ * it becomes the final marker. Safe to call twice.
4775
+ */
4776
+ end() {
4777
+ if (!this.enabled || this.ended) return;
4778
+ this.ended = true;
4779
+ if (!this.animated) {
4780
+ this.write(`${this.render(true)}
4781
+ `);
4782
+ return;
4783
+ }
4784
+ if (!this.onRow || !this.cursorFree()) return;
4785
+ this.write("\r\x1B[K");
4786
+ this.rendered = "";
4787
+ this.onRow = false;
4788
+ }
4789
+ /** Rewrite the current row in place, skipping no-op rewrites and unsafe moments. */
4790
+ draw() {
4791
+ if (!this.animated || !this.cursorFree()) return;
4792
+ const text = this.render();
4793
+ if (text === this.rendered) return;
4794
+ this.write(`\r\x1B[K${text}`);
4795
+ this.rendered = text;
4796
+ this.onRow = true;
4797
+ }
4798
+ /**
4799
+ * Whether the cursor is at the start of a row nobody else is using. On stdout
4800
+ * that is not this terminal there is no shared cursor to disturb; when it is,
4801
+ * only a write that ended a line leaves one free.
4802
+ */
4803
+ cursorFree() {
4804
+ return !this.sharesCursor || this.stdoutAtRowStart;
4805
+ }
4806
+ /**
4807
+ * `final` marks the line the run ends on: a run that never got past the spawn
4808
+ * is over, so saying it is still waiting — directly above whatever the run
4809
+ * reported — would be a lie.
4810
+ */
4811
+ render(final = false) {
4812
+ const parts = [];
4813
+ if (this.streaming) {
4814
+ const u = this.usage.snapshot();
4815
+ parts.push(
4816
+ this.model ?? this.host,
4817
+ this.elapsed(),
4818
+ `\u2193${u.inputTokens.toLocaleString()}\u2191${u.outputTokens.toLocaleString()} tokens`
4819
+ );
4820
+ return `\u25CF ${parts.join(" \xB7 ")}`;
4821
+ }
4822
+ if (this.model !== void 0) return `\u25B6 ${[this.host, this.model, this.elapsed()].join(" \xB7 ")}`;
4823
+ if (final) return `\u25B6 ${this.host} \xB7 no output after ${this.elapsed()}`;
4824
+ return `\u25B6 ${this.host} \xB7 waiting for first event\u2026`;
4825
+ }
4826
+ elapsed() {
4827
+ return humanizeElapsed(this.now() - this.startedAt);
4828
+ }
4829
+ };
4830
+ }
4831
+ });
4832
+
4152
4833
  // src/commands/run.ts
4153
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
4834
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
4154
4835
  import { join as join4 } from "path";
4155
4836
  import { SUPPORTED_HOSTS } from "@noir-ai/adapters";
4156
- import { loadNoirEnv as loadNoirEnv2, NOIR_DIR as NOIR_DIR2, parseConfig } from "@noir-ai/core";
4837
+ import { isDeniedEnvKey as isDeniedEnvKey2, loadNoirEnv as loadNoirEnv2, NOIR_DIR as NOIR_DIR2, parseConfig, paths } from "@noir-ai/core";
4157
4838
  function credentialNote(env2, sources) {
4158
4839
  const named = CREDENTIAL_ENV_VARS.filter((name) => {
4159
4840
  const value = env2[name];
@@ -4173,13 +4854,24 @@ function formatUsage(u) {
4173
4854
  const turns = u.numTurns > 0 ? ` \xB7 ${u.numTurns} turns` : "";
4174
4855
  return `${u.inputTokens.toLocaleString()} in / ${u.outputTokens.toLocaleString()} out${cost}${turns}`;
4175
4856
  }
4176
- function streamEvent(event, opts) {
4177
- if (opts.json === true) return;
4857
+ function answerText(event) {
4178
4858
  if (event.kind === "assistant" && event.text && event.text.length > 0 && event.isError !== true) {
4179
- process.stdout.write(event.text);
4859
+ return event.text;
4860
+ }
4861
+ return void 0;
4862
+ }
4863
+ function streamEvent(event, opts, status2) {
4864
+ if (opts.json === true) return;
4865
+ const text = answerText(event);
4866
+ if (text !== void 0) {
4867
+ status2.beforeStdout(text);
4868
+ process.stdout.write(text);
4180
4869
  }
4181
4870
  }
4182
4871
  async function run(prompt, opts) {
4872
+ await runOnce(prompt, opts, void 0);
4873
+ }
4874
+ async function runOnce(prompt, opts, resumeSessionId) {
4183
4875
  const host = opts.host ?? "claude";
4184
4876
  if (!SUPPORTED_HOSTS.includes(host)) {
4185
4877
  fail(EXIT.USAGE, `unknown host '${host}' (supported: ${SUPPORTED_HOSTS.join(", ")})`, opts);
@@ -4198,7 +4890,7 @@ async function run(prompt, opts) {
4198
4890
  if (!resolved.ok) fail(EXIT.USAGE, resolved.message, opts);
4199
4891
  const profile = resolved.profile;
4200
4892
  const customBinary = opts.command ?? profile.binary;
4201
- const extraArgs = profile.args;
4893
+ const extraArgs = resumeSessionId === void 0 ? profile.args : [...profile.args ?? [], "--resume", resumeSessionId];
4202
4894
  const env2 = profile.env ? mergeEnv(process.env, profile.env) : void 0;
4203
4895
  if (!existsSync2(join4(root, NOIR_DIR2, ".env"))) {
4204
4896
  log(
@@ -4207,6 +4899,37 @@ async function run(prompt, opts) {
4207
4899
  );
4208
4900
  }
4209
4901
  const transcriptLines = [];
4902
+ const answer = [];
4903
+ let sessionId;
4904
+ const binary = customBinary && customBinary.length > 0 ? customBinary : host;
4905
+ const status2 = new RunStatusLine({
4906
+ host: binary,
4907
+ json: opts.json,
4908
+ quiet: opts.quiet,
4909
+ stderrIsTty: process.stderr.isTTY === true,
4910
+ stdoutSharesCursor: process.stdout.isTTY === true
4911
+ });
4912
+ status2.begin();
4913
+ let verdictWritten = false;
4914
+ const claimVerdict = () => {
4915
+ if (verdictWritten) return false;
4916
+ verdictWritten = true;
4917
+ return true;
4918
+ };
4919
+ const interrupt = new RunInterrupt({
4920
+ // A second interrupt leaves at once, and leaves the same verdict behind as
4921
+ // the first: the run owes its consumer an envelope under --json, and this is
4922
+ // the one path where `fail()` cannot write it (there is no caller left to
4923
+ // catch its throw). The child is forced first, so leaving does not leave a
4924
+ // host behind.
4925
+ exitNow: (code) => {
4926
+ interrupt.forceNow();
4927
+ if (claimVerdict()) {
4928
+ failAndExit(code, interruptedNotice(safeTranscript(host, transcriptLines)), opts);
4929
+ }
4930
+ }
4931
+ });
4932
+ interrupt.watch();
4210
4933
  let result;
4211
4934
  try {
4212
4935
  result = await runHost({
@@ -4216,28 +4939,61 @@ async function run(prompt, opts) {
4216
4939
  extraArgs,
4217
4940
  env: env2,
4218
4941
  onLine: (line) => transcriptLines.push(line),
4219
- onEvent: (event) => streamEvent(event, opts)
4942
+ onEvent: (event) => {
4943
+ if (event.kind === "init" && sessionId === void 0) sessionId = event.sessionId;
4944
+ status2.event(event);
4945
+ streamEvent(event, opts, status2);
4946
+ const text = answerText(event);
4947
+ if (text !== void 0) answer.push(text);
4948
+ },
4949
+ signal: interrupt.signal,
4950
+ onChild: (child) => interrupt.track(child)
4220
4951
  });
4221
4952
  } catch (err) {
4222
- const binary = customBinary && customBinary.length > 0 ? customBinary : host;
4953
+ status2.end();
4954
+ status2.finishRow();
4955
+ if (interrupt.interruptedBy !== void 0) {
4956
+ if (claimVerdict()) {
4957
+ failInterrupted(interrupt.interruptedBy, host, transcriptLines, opts);
4958
+ }
4959
+ return;
4960
+ }
4223
4961
  const detail = err instanceof Error ? err.message : String(err);
4224
4962
  const enoent = err?.code === "ENOENT";
4225
4963
  const guidance = enoent ? ` No executable '${binary}' was found. Shell aliases and functions (e.g. from .zshrc) are invisible to noir \u2014 use an executable on PATH, an absolute path, or a launcher script such as ~/.local/bin/${binary}.` : "";
4226
4964
  const subject = customBinary && customBinary.length > 0 ? `custom command '${customBinary}'` : `host '${host}'`;
4227
4965
  fail(EXIT.ERROR, `failed to run ${subject}: ${detail}.${guidance}`, opts);
4966
+ } finally {
4967
+ interrupt.unwatch();
4968
+ }
4969
+ if (interrupt.interruptedBy !== void 0) {
4970
+ status2.end();
4971
+ status2.finishRow();
4972
+ if (claimVerdict()) {
4973
+ failInterrupted(interrupt.interruptedBy, host, transcriptLines, opts);
4974
+ }
4975
+ return;
4228
4976
  }
4229
- const transcript = writeTranscript(host, transcriptLines);
4977
+ const transcript = safeTranscript(host, transcriptLines);
4230
4978
  const failed = result.exitCode !== 0 || result.isError;
4231
4979
  if (failed) {
4232
- const binary = customBinary && customBinary.length > 0 ? customBinary : host;
4233
- const reason = result.errorText && result.errorText.trim().length > 0 ? result.errorText.trim() : result.stderr.trim() || `exit code ${result.exitCode}`;
4234
- const isAuth = result.errorCategory === "authentication_failed" || result.errorCategory === "oauth_org_not_allowed" || /not logged|login|authenticate|invalid api key/i.test(reason);
4980
+ status2.end();
4981
+ status2.finishRow();
4982
+ const stderrTail = hostStderrTail(result.stderr);
4983
+ const reason = result.errorText && result.errorText.trim().length > 0 ? result.errorText.trim() : `exit code ${result.exitCode}`;
4984
+ const isAuth = result.errorCategory === "authentication_failed" || result.errorCategory === "oauth_org_not_allowed" || /not logged|login|authenticate|invalid api key/i.test(`${reason}
4985
+ ${stderrTail}`);
4235
4986
  let message = `host '${binary}' failed (exit ${result.exitCode}): ${reason}`;
4236
4987
  if (isAuth) {
4237
4988
  message += ` Open a terminal and run \`${binary} /login\` (interactive-only \u2014 it cannot run inside \`noir run\`), then retry.`;
4238
4989
  message += credentialNote(env2 ?? process.env, envSources);
4239
4990
  }
4240
4991
  message += ` If you use another profile, pass \`--command <binary>\` or define a run profile under run.profiles. transcript: ${transcript}`;
4992
+ if (opts.json !== true && stderrTail.length > 0) {
4993
+ message += `
4994
+ ${binary} stderr (last ${HOST_STDERR_TAIL_LINES} lines):
4995
+ ${stderrTail}`;
4996
+ }
4241
4997
  fail(EXIT.ERROR, message, opts);
4242
4998
  }
4243
4999
  if (opts.json === true) {
@@ -4250,26 +5006,59 @@ async function run(prompt, opts) {
4250
5006
  usage: result.usage,
4251
5007
  numTurns: result.usage.numTurns,
4252
5008
  events: result.eventCount,
4253
- transcript
5009
+ transcript,
5010
+ // The answer as plain words, so a consumer does not have to reassemble
5011
+ // it from the stream-json transcript.
5012
+ answerText: answer.join(""),
5013
+ ...sessionId === void 0 ? {} : { sessionId }
4254
5014
  }
4255
5015
  });
4256
5016
  return;
4257
5017
  }
5018
+ status2.end();
4258
5019
  process.stdout.write("\n");
4259
5020
  success(`usage: ${formatUsage(result.usage)} (API-equivalent estimate, not billed)`, opts);
4260
5021
  log(`transcript: ${transcript}`, opts);
5022
+ await offerPostRunActions({
5023
+ answer: answer.join(""),
5024
+ transcript,
5025
+ sessionId,
5026
+ host: binary,
5027
+ opts,
5028
+ // Only the first run of a session can be continued: a resumed run is
5029
+ // itself the continuation, so the menu never nests more than one deep.
5030
+ ...resumeSessionId === void 0 && sessionId !== void 0 ? { resume: (nextPrompt, id) => runOnce(nextPrompt, opts, id) } : {}
5031
+ });
4261
5032
  }
4262
5033
  function mergeEnv(base, overlay) {
5034
+ for (const key of Object.keys(overlay)) {
5035
+ if (isDeniedEnvKey2(key)) {
5036
+ fail(
5037
+ EXIT.ERROR,
5038
+ `refusing to run the host: the profile env defines process-injection key "${key}" (it can inject into the spawned host process)`
5039
+ );
5040
+ }
5041
+ }
4263
5042
  const merged = { ...base, ...overlay };
4264
5043
  return Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== void 0));
4265
5044
  }
4266
- function writeTranscript(host, lines) {
5045
+ function failInterrupted(signal, host, lines, opts) {
5046
+ return fail(exitCodeForSignal(signal), interruptedNotice(safeTranscript(host, lines)), opts);
5047
+ }
5048
+ function safeTranscript(host, lines) {
5049
+ try {
5050
+ return writeTranscript(host, lines);
5051
+ } catch {
5052
+ return "(not persisted)";
5053
+ }
5054
+ }
5055
+ function writeTranscript(host, lines, root = process.cwd()) {
4267
5056
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4268
- const dir = join4(process.cwd(), ".noir", "transcripts");
5057
+ const dir = paths.transcriptsDir(root);
4269
5058
  const file = join4(dir, `${host}-${ts}.jsonl`);
4270
5059
  try {
4271
5060
  mkdirSync3(dir, { recursive: true, mode: 448 });
4272
- writeFileSync3(file, `${lines.join("\n")}${lines.length > 0 ? "\n" : ""}`, { mode: 384 });
5061
+ writeFileSync4(file, `${lines.join("\n")}${lines.length > 0 ? "\n" : ""}`, { mode: 384 });
4273
5062
  } catch {
4274
5063
  return "(not persisted)";
4275
5064
  }
@@ -4281,7 +5070,10 @@ var init_run = __esm({
4281
5070
  "use strict";
4282
5071
  init_orchestrator();
4283
5072
  init_output();
5073
+ init_run_actions();
5074
+ init_run_interrupt();
4284
5075
  init_run_profiles();
5076
+ init_run_status();
4285
5077
  CREDENTIAL_ENV_VARS = [
4286
5078
  "ANTHROPIC_API_KEY",
4287
5079
  "ANTHROPIC_AUTH_TOKEN",
@@ -4290,257 +5082,20 @@ var init_run = __esm({
4290
5082
  }
4291
5083
  });
4292
5084
 
4293
- // src/commands/skills.ts
4294
- import { resolveAdapter as resolveAdapter3 } from "@noir-ai/adapters";
4295
- import { loadProjectInfo as loadProjectInfo12 } from "@noir-ai/core";
5085
+ // src/conflict.ts
4296
5086
  import {
4297
- buildRegistry,
4298
- discoverAll,
4299
- emitSkillsToDir,
4300
- lintSkill
4301
- } from "@noir-ai/skills";
4302
- function categoryOf(name) {
4303
- const mapped = CATEGORY[name];
4304
- if (mapped !== void 0) return mapped;
4305
- return name.replace(/^noir-/, "") || "general";
4306
- }
4307
- function toRow(s, kind = "builtin") {
4308
- const description = typeof s.frontmatter.description === "string" ? s.frontmatter.description : "";
4309
- const status2 = s.skillMd.includes("> **Stub:**") ? "stub" : "full";
4310
- const category = s.frontmatter.metadata?.category?.trim() || categoryOf(s.name);
4311
- return { name: s.name, category, description, kind, status: status2 };
4312
- }
4313
- function truncate(text, max) {
4314
- if (text.length <= max) return text;
4315
- const slice = text.slice(0, Math.max(0, max - 1));
4316
- const boundary = slice.lastIndexOf(" ");
4317
- const head = boundary > 0 ? slice.slice(0, boundary) : slice;
4318
- return `${head.trimEnd()}\u2026`;
5087
+ lineDiff
5088
+ } from "@noir-ai/create";
5089
+ function flaggedNonInteractive() {
5090
+ const v = process.env.NOIR_NON_INTERACTIVE;
5091
+ return v !== void 0 && v !== "";
4319
5092
  }
4320
- async function skillsList(opts) {
4321
- let builtins;
4322
- let integrations;
4323
- try {
4324
- const all = discoverAll();
4325
- builtins = all.builtins;
4326
- integrations = all.integrations;
4327
- } catch (err) {
4328
- fail(
4329
- EXIT.ERROR,
4330
- `skills list: could not discover skills (${err instanceof Error ? err.message : String(err)})`,
4331
- opts
4332
- );
5093
+ function buildConflictOpts(input = {}) {
5094
+ if (input.force === true) {
5095
+ return { conflictPolicy: "overwrite" };
4333
5096
  }
4334
- const rows = [
4335
- ...builtins.map((b) => toRow(b, "builtin")),
4336
- ...integrations.map((i) => toRow(i, "integration"))
4337
- ];
4338
- const data = { count: rows.length, skills: rows };
4339
- if (opts.json === true) {
4340
- process.stdout.write(`${JSON.stringify({ ok: true, data })}
4341
- `);
4342
- return;
4343
- }
4344
- const intCount = integrations.length;
4345
- const banner = intCount > 0 ? `noir skills \u2014 ${rows.length} skill${rows.length === 1 ? "" : "s"} (${builtins.length} builtin, ${intCount} integration${intCount === 1 ? "" : "s"})` : `noir skills \u2014 ${rows.length} builtin skill${rows.length === 1 ? "" : "s"}`;
4346
- log(banner, opts);
4347
- table(
4348
- rows.map((r) => ({
4349
- Skill: r.name,
4350
- Kind: r.kind,
4351
- Category: r.category,
4352
- Status: r.status,
4353
- Description: truncate(r.description, 80)
4354
- })),
4355
- ["Skill", "Kind", "Category", "Status", "Description"],
4356
- opts
4357
- );
4358
- }
4359
- async function skillsSync(opts) {
4360
- const root = process.cwd();
4361
- let project;
4362
- try {
4363
- project = loadProjectInfo12(root);
4364
- } catch {
4365
- fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
4366
- }
4367
- const adapter = resolveAdapter3(project.config.host);
4368
- if (!adapter.skillsDir) {
4369
- const data2 = {
4370
- emitted: [],
4371
- references: 0,
4372
- host: project.config.host,
4373
- reason: "no-skill-emitter"
4374
- };
4375
- if (opts.json === true) {
4376
- process.stdout.write(`${JSON.stringify({ ok: true, data: data2 })}
4377
- `);
4378
- return;
4379
- }
4380
- info("This host has no skill emitter; nothing to sync.", opts);
4381
- return;
4382
- }
4383
- const dir = adapter.skillsDir({ root });
4384
- const target = project.config.host;
4385
- const summary = await emitSkillsToDir(dir, { target });
4386
- const pruned = summary.pruned ?? [];
4387
- const data = {
4388
- emitted: summary.emitted,
4389
- references: summary.references,
4390
- dir,
4391
- pruned
4392
- };
4393
- if (opts.json === true) {
4394
- process.stdout.write(`${JSON.stringify({ ok: true, data })}
4395
- `);
4396
- return;
4397
- }
4398
- log(
4399
- `Synced ${summary.emitted.length} Noir skill${summary.emitted.length === 1 ? "" : "s"} to ${dir}.`,
4400
- opts
4401
- );
4402
- if (pruned.length > 0) {
4403
- warn(
4404
- `Pruned ${pruned.length} stale noir-* skill dir${pruned.length === 1 ? "" : "s"}: ${pruned.join(", ")}`,
4405
- opts
4406
- );
4407
- }
4408
- }
4409
- async function skillsLint(opts) {
4410
- let skills;
4411
- try {
4412
- const all = discoverAll();
4413
- skills = [...all.builtins, ...all.integrations].map(lintSkill);
4414
- } catch (err) {
4415
- fail(
4416
- EXIT.ERROR,
4417
- `skills lint: could not lint skills (${err instanceof Error ? err.message : String(err)})`,
4418
- opts
4419
- );
4420
- }
4421
- const errored = skills.filter((s) => s.errors.length > 0);
4422
- const warned = skills.filter((s) => s.warnings.length > 0 && s.errors.length === 0);
4423
- const data = { count: skills.length, errored: errored.length, skills };
4424
- if (errored.length > 0) {
4425
- const message = `skills lint: ${errored.length} skill${errored.length === 1 ? "" : "s"} failed validation`;
4426
- if (opts.json === true) {
4427
- process.stdout.write(
4428
- `${JSON.stringify({ ok: false, error: { code: EXIT.ERROR, message }, data })}
4429
- `
4430
- );
4431
- process.exitCode = EXIT.ERROR;
4432
- return;
4433
- }
4434
- warn(
4435
- `noir skills lint \u2014 ${errored.length} skill${errored.length === 1 ? "" : "s"} FAIL validation:`,
4436
- opts
4437
- );
4438
- for (const s of errored) {
4439
- for (const e of s.errors) warn(` ${s.name}: ${e}`, opts);
4440
- }
4441
- if (warned.length > 0) {
4442
- info(` ${warned.length} skill${warned.length === 1 ? "" : "s"} carry lint warnings:`, opts);
4443
- for (const s of warned) {
4444
- for (const w of s.warnings) info(` ${s.name}: ${w}`, opts);
4445
- }
4446
- }
4447
- fail(EXIT.ERROR, message, opts);
4448
- }
4449
- if (opts.json === true) {
4450
- process.stdout.write(`${JSON.stringify({ ok: true, data })}
4451
- `);
4452
- return;
4453
- }
4454
- log(`noir skills lint \u2014 ${skills.length} skills, all validate clean.`, opts);
4455
- if (warned.length > 0) {
4456
- info(` ${warned.length} skill${warned.length === 1 ? "" : "s"} carry lint warnings:`, opts);
4457
- for (const s of warned) {
4458
- for (const w of s.warnings) info(` ${s.name}: ${w}`, opts);
4459
- }
4460
- }
4461
- }
4462
- async function skillsRegistry(opts) {
4463
- let reg;
4464
- try {
4465
- reg = buildRegistry();
4466
- } catch (err) {
4467
- fail(
4468
- EXIT.ERROR,
4469
- `skills registry: could not build registry (${err instanceof Error ? err.message : String(err)})`,
4470
- opts
4471
- );
4472
- }
4473
- const data = { count: reg.length, skills: reg };
4474
- if (opts.json === true) {
4475
- process.stdout.write(`${JSON.stringify({ ok: true, data })}
4476
- `);
4477
- return;
4478
- }
4479
- log(`noir skills registry \u2014 ${reg.length} skills.`, opts);
4480
- table(
4481
- reg.map((r) => ({
4482
- Skill: r.name,
4483
- Kind: r.kind,
4484
- Category: r.category,
4485
- Version: r.version,
4486
- Status: r.status,
4487
- Refs: String(r.referenceCount),
4488
- Lines: String(r.lines)
4489
- })),
4490
- ["Skill", "Kind", "Category", "Version", "Status", "Refs", "Lines"],
4491
- opts
4492
- );
4493
- }
4494
- var CATEGORY;
4495
- var init_skills = __esm({
4496
- "src/commands/skills.ts"() {
4497
- "use strict";
4498
- init_output();
4499
- CATEGORY = {
4500
- "noir-brainstorming": "discovery",
4501
- "noir-sync": "discovery",
4502
- "noir-checkpoint": "discovery",
4503
- "noir-exploring": "discovery",
4504
- "noir-spec": "spec",
4505
- "noir-planning": "plan",
4506
- "noir-prd": "plan",
4507
- "noir-executing-plans": "execute",
4508
- "noir-test-driven-development": "execute",
4509
- "noir-systematic-debugging": "execute",
4510
- "noir-subagent": "execute",
4511
- "noir-parallel": "execute",
4512
- "noir-verifying": "verify",
4513
- "noir-security": "verify",
4514
- "noir-wrap": "document",
4515
- "noir-readme": "document",
4516
- "noir-shipping": "git",
4517
- "noir-worktree": "git",
4518
- "noir-recall": "memory",
4519
- "noir-remember": "memory",
4520
- "noir-context": "context",
4521
- "noir-frontend": "domain",
4522
- "noir-backend": "domain",
4523
- "noir-doctor": "meta",
4524
- "noir-writing-skills": "meta",
4525
- "noir-rules": "meta"
4526
- };
4527
- }
4528
- });
4529
-
4530
- // src/conflict.ts
4531
- import {
4532
- lineDiff
4533
- } from "@noir-ai/create";
4534
- function flaggedNonInteractive() {
4535
- const v = process.env.NOIR_NON_INTERACTIVE;
4536
- return v !== void 0 && v !== "";
4537
- }
4538
- function buildConflictOpts(input = {}) {
4539
- if (input.force === true) {
4540
- return { conflictPolicy: "overwrite" };
4541
- }
4542
- if (input.interactive === false) {
4543
- return { conflictPolicy: "preserve" };
5097
+ if (input.interactive === false) {
5098
+ return { conflictPolicy: "preserve" };
4544
5099
  }
4545
5100
  if (input.interactive === true) {
4546
5101
  return { conflictPolicy: "preserve", onConflict: clackConflictResolver };
@@ -4636,7 +5191,7 @@ var init_conflict = __esm({
4636
5191
  });
4637
5192
 
4638
5193
  // src/dedup-write.ts
4639
- import { mkdirSync as mkdirSync4, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync4 } from "fs";
5194
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync5 } from "fs";
4640
5195
  import { join as join5 } from "path";
4641
5196
  import { sha256Hex as sha256Hex2, sha256Hex12 } from "@noir-ai/core";
4642
5197
  async function checkWritePathDedup(root, res, opts) {
@@ -4785,14 +5340,14 @@ async function promptDedupAction(root, proposedRel, matchedRel, proposedText, si
4785
5340
  return "preserve";
4786
5341
  } else if (choice === "replace") {
4787
5342
  try {
4788
- writeFileSync4(join5(root, matchedRel), proposedText, "utf8");
5343
+ writeFileSync5(join5(root, matchedRel), proposedText, "utf8");
4789
5344
  } catch {
4790
5345
  }
4791
5346
  return "replace";
4792
5347
  } else if (choice === "mirror") {
4793
5348
  try {
4794
5349
  const existing = readFileSync3(join5(root, matchedRel), "utf8");
4795
- writeFileSync4(
5350
+ writeFileSync5(
4796
5351
  join5(root, matchedRel),
4797
5352
  `${existing}
4798
5353
 
@@ -4831,7 +5386,7 @@ function loadCache(root) {
4831
5386
  function saveCache(root, cache) {
4832
5387
  try {
4833
5388
  mkdirSync4(join5(root, ".noir"), { recursive: true });
4834
- writeFileSync4(cachePath(root), JSON.stringify(cache), "utf8");
5389
+ writeFileSync5(cachePath(root), JSON.stringify(cache), "utf8");
4835
5390
  } catch {
4836
5391
  }
4837
5392
  }
@@ -4853,16 +5408,16 @@ function safeRead(abs) {
4853
5408
  }
4854
5409
  }
4855
5410
  function withTimeout(p, ms) {
4856
- return new Promise((resolve2) => {
4857
- const t = setTimeout(() => resolve2(void 0), ms);
5411
+ return new Promise((resolve3) => {
5412
+ const t = setTimeout(() => resolve3(void 0), ms);
4858
5413
  p.then(
4859
5414
  (v) => {
4860
5415
  clearTimeout(t);
4861
- resolve2(v);
5416
+ resolve3(v);
4862
5417
  },
4863
5418
  () => {
4864
5419
  clearTimeout(t);
4865
- resolve2(void 0);
5420
+ resolve3(void 0);
4866
5421
  }
4867
5422
  );
4868
5423
  });
@@ -4885,13 +5440,16 @@ var init_dedup_write = __esm({
4885
5440
  });
4886
5441
 
4887
5442
  // src/init.ts
4888
- import { resolveAdapter as resolveAdapter4 } from "@noir-ai/adapters";
4889
- import { loadProjectInfo as loadProjectInfo13 } from "@noir-ai/core";
5443
+ 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";
4890
5446
  import { scaffold } from "@noir-ai/create";
4891
- import { emitSkillsToDir as emitSkillsToDir2 } from "@noir-ai/skills";
5447
+ import {
5448
+ emitSkillsToDir
5449
+ } from "@noir-ai/skills";
4892
5450
  async function init(root, opts) {
4893
5451
  assertTransportUrl(opts);
4894
- const host = opts.host ?? "claude";
5452
+ const host = resolveInitHost(root, opts);
4895
5453
  const interactive = resolveInteractive();
4896
5454
  const conflictOpts = buildConflictOpts({ force: opts.force, interactive });
4897
5455
  const dryRun = opts.dryRun === true || opts.preview === true;
@@ -4912,15 +5470,29 @@ async function init(root, opts) {
4912
5470
  return res;
4913
5471
  }
4914
5472
  if (res.noop) return res;
4915
- await emitHostSkills(root, host, conflictOpts, interactive);
5473
+ const skillSummary = await emitHostSkills(root, host, conflictOpts, interactive);
5474
+ res.skillConflicts = skillSummary?.conflicts ?? [];
5475
+ res.preservedSkills = skillSummary?.preserved ?? [];
4916
5476
  let projectInfo;
4917
5477
  try {
4918
- projectInfo = loadProjectInfo13(root);
5478
+ projectInfo = loadProjectInfo12(root);
4919
5479
  } catch {
4920
5480
  projectInfo = void 0;
4921
5481
  }
4922
5482
  const dedup = await checkWritePathDedup(root, res, { interactive, project: projectInfo });
4923
5483
  if (dedup.conflicts.length > 0) res.conflicts.push(...dedup.conflicts);
5484
+ if (res.refreshed.length > 0) {
5485
+ process.stderr.write(
5486
+ `Refreshed ${res.refreshed.length} unedited doc seed(s): ${res.refreshed.join(", ")}
5487
+ `
5488
+ );
5489
+ }
5490
+ if (res.migrationChanged.length > 0) {
5491
+ process.stderr.write(
5492
+ `Migrated ${res.migrationChanged.length} file(s): ${res.migrationChanged.join(", ")}
5493
+ `
5494
+ );
5495
+ }
4924
5496
  process.stderr.write(
4925
5497
  `Noir initialized in ${root} (host: ${host}, transport: ${opts.transport}).
4926
5498
  `
@@ -4930,6 +5502,19 @@ async function init(root, opts) {
4930
5502
  );
4931
5503
  return res;
4932
5504
  }
5505
+ function resolveInitHost(root, opts) {
5506
+ if (opts.host !== void 0) return opts.host;
5507
+ if (!existsSync3(paths2.config(root))) return "claude";
5508
+ try {
5509
+ return loadProjectInfo12(root).config.host;
5510
+ } catch {
5511
+ process.stderr.write(
5512
+ `Could not read the configured host from ${paths2.config(root)}; using 'claude'.
5513
+ `
5514
+ );
5515
+ return "claude";
5516
+ }
5517
+ }
4933
5518
  function reportPlannedWrites(res) {
4934
5519
  log("Dry run \u2014 no files were written.");
4935
5520
  if (res.written.length > 0) {
@@ -4939,6 +5524,10 @@ function reportPlannedWrites(res) {
4939
5524
  log(mode === void 0 ? ` ${p}` : ` ${p} (mode ${mode.toString(8).padStart(3, "0")})`);
4940
5525
  }
4941
5526
  }
5527
+ if (res.refreshed.length > 0) {
5528
+ log("Would refresh (unedited older copy):");
5529
+ for (const p of res.refreshed) log(` ${p}`);
5530
+ }
4942
5531
  if (res.skipped.length > 0) {
4943
5532
  log("Would leave as-is (already present):");
4944
5533
  for (const p of res.skipped) log(` ${p}`);
@@ -4949,12 +5538,12 @@ function reportPlannedWrites(res) {
4949
5538
  }
4950
5539
  }
4951
5540
  async function emitHostSkills(root, host, conflictOpts, interactive) {
4952
- const adapter = resolveAdapter4(host);
5541
+ const adapter = resolveAdapter3(host);
4953
5542
  const skillsDir = adapter.skillsDir?.({ root });
4954
5543
  if (skillsDir === void 0) {
4955
5544
  process.stderr.write(`host '${host}' has no skill emitter; skipping skills
4956
5545
  `);
4957
- return;
5546
+ return void 0;
4958
5547
  }
4959
5548
  const target = host;
4960
5549
  const skillOpts = {
@@ -4966,12 +5555,20 @@ async function emitHostSkills(root, host, conflictOpts, interactive) {
4966
5555
  if (conflictOpts.onConflict !== void 0) {
4967
5556
  skillOpts.onConflict = conflictOpts.onConflict;
4968
5557
  }
4969
- const summary = await emitSkillsToDir2(skillsDir, skillOpts);
5558
+ const summary = await emitSkillsToDir(skillsDir, skillOpts);
4970
5559
  const relDir = skillsDir.replace(`${root}/`, "");
4971
5560
  process.stderr.write(
4972
5561
  `Emitted ${summary.emitted.length} Noir skills to ${relDir}/ (target: ${target}).
4973
5562
  `
4974
5563
  );
5564
+ const stale = preservedStaleLine(summary.preserved ?? []);
5565
+ if (stale !== void 0) process.stderr.write(`${stale}
5566
+ `);
5567
+ return summary;
5568
+ }
5569
+ function preservedStaleLine(preserved) {
5570
+ if (preserved.length === 0) return void 0;
5571
+ return `${preserved.length} skill(s) preserved as stale (interactive TTY required to refresh): ${preserved.join(", ")}`;
4975
5572
  }
4976
5573
  function assertTransportUrl(opts) {
4977
5574
  if (opts.transport === "streamable-http" && opts.url === void 0) {
@@ -5004,6 +5601,251 @@ var init_init = __esm({
5004
5601
  }
5005
5602
  });
5006
5603
 
5604
+ // src/commands/skills.ts
5605
+ import { resolveAdapter as resolveAdapter4 } from "@noir-ai/adapters";
5606
+ import { loadProjectInfo as loadProjectInfo13 } from "@noir-ai/core";
5607
+ import {
5608
+ buildRegistry,
5609
+ discoverAll,
5610
+ emitSkillsToDir as emitSkillsToDir2,
5611
+ lintSkill
5612
+ } from "@noir-ai/skills";
5613
+ function categoryOf(name) {
5614
+ const mapped = CATEGORY[name];
5615
+ if (mapped !== void 0) return mapped;
5616
+ return name.replace(/^noir-/, "") || "general";
5617
+ }
5618
+ function toRow(s, kind = "builtin") {
5619
+ const description = typeof s.frontmatter.description === "string" ? s.frontmatter.description : "";
5620
+ const status2 = s.skillMd.includes("> **Stub:**") ? "stub" : "full";
5621
+ const category = s.frontmatter.metadata?.category?.trim() || categoryOf(s.name);
5622
+ return { name: s.name, category, description, kind, status: status2 };
5623
+ }
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
+ async function skillsList(opts) {
5632
+ let builtins;
5633
+ let integrations;
5634
+ try {
5635
+ const all = discoverAll();
5636
+ builtins = all.builtins;
5637
+ integrations = all.integrations;
5638
+ } catch (err) {
5639
+ fail(
5640
+ EXIT.ERROR,
5641
+ `skills list: could not discover skills (${err instanceof Error ? err.message : String(err)})`,
5642
+ opts
5643
+ );
5644
+ }
5645
+ const rows = [
5646
+ ...builtins.map((b) => toRow(b, "builtin")),
5647
+ ...integrations.map((i) => toRow(i, "integration"))
5648
+ ];
5649
+ const data = { count: rows.length, skills: rows };
5650
+ if (opts.json === true) {
5651
+ process.stdout.write(`${JSON.stringify({ ok: true, data })}
5652
+ `);
5653
+ return;
5654
+ }
5655
+ const intCount = integrations.length;
5656
+ const banner = intCount > 0 ? `noir skills \u2014 ${rows.length} skill${rows.length === 1 ? "" : "s"} (${builtins.length} builtin, ${intCount} integration${intCount === 1 ? "" : "s"})` : `noir skills \u2014 ${rows.length} builtin skill${rows.length === 1 ? "" : "s"}`;
5657
+ log(banner, opts);
5658
+ table(
5659
+ rows.map((r) => ({
5660
+ Skill: r.name,
5661
+ Kind: r.kind,
5662
+ Category: r.category,
5663
+ Status: r.status,
5664
+ Description: truncate(r.description, 80)
5665
+ })),
5666
+ ["Skill", "Kind", "Category", "Status", "Description"],
5667
+ opts
5668
+ );
5669
+ }
5670
+ async function skillsSync(opts) {
5671
+ const root = process.cwd();
5672
+ let project;
5673
+ try {
5674
+ project = loadProjectInfo13(root);
5675
+ } catch {
5676
+ fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
5677
+ }
5678
+ const adapter = resolveAdapter4(project.config.host);
5679
+ if (!adapter.skillsDir) {
5680
+ const data2 = {
5681
+ emitted: [],
5682
+ references: 0,
5683
+ host: project.config.host,
5684
+ reason: "no-skill-emitter"
5685
+ };
5686
+ if (opts.json === true) {
5687
+ process.stdout.write(`${JSON.stringify({ ok: true, data: data2 })}
5688
+ `);
5689
+ return;
5690
+ }
5691
+ info("This host has no skill emitter; nothing to sync.", opts);
5692
+ return;
5693
+ }
5694
+ const dir = adapter.skillsDir({ root });
5695
+ const target = project.config.host;
5696
+ const summary = await emitSkillsToDir2(dir, { target });
5697
+ const pruned = summary.pruned ?? [];
5698
+ const preserved = summary.preserved ?? [];
5699
+ const data = {
5700
+ emitted: summary.emitted,
5701
+ references: summary.references,
5702
+ dir,
5703
+ pruned,
5704
+ // What the count above does NOT cover: skills whose files were preserved
5705
+ // (stale on disk, by design, in a non-interactive run). Same field `noir
5706
+ // init --json` carries, so a consumer reads one shape from every emitter.
5707
+ preserved
5708
+ };
5709
+ if (opts.json === true) {
5710
+ process.stdout.write(`${JSON.stringify({ ok: true, data })}
5711
+ `);
5712
+ return;
5713
+ }
5714
+ log(
5715
+ `Synced ${summary.emitted.length} Noir skill${summary.emitted.length === 1 ? "" : "s"} to ${dir}.`,
5716
+ opts
5717
+ );
5718
+ if (pruned.length > 0) {
5719
+ warn(
5720
+ `Pruned ${pruned.length} stale noir-* skill dir${pruned.length === 1 ? "" : "s"}: ${pruned.join(", ")}`,
5721
+ opts
5722
+ );
5723
+ }
5724
+ const stale = preservedStaleLine(preserved);
5725
+ if (stale !== void 0) warn(stale, opts);
5726
+ }
5727
+ async function skillsLint(opts) {
5728
+ let skills;
5729
+ try {
5730
+ const all = discoverAll();
5731
+ skills = [...all.builtins, ...all.integrations].map(lintSkill);
5732
+ } catch (err) {
5733
+ fail(
5734
+ EXIT.ERROR,
5735
+ `skills lint: could not lint skills (${err instanceof Error ? err.message : String(err)})`,
5736
+ opts
5737
+ );
5738
+ }
5739
+ const errored = skills.filter((s) => s.errors.length > 0);
5740
+ const warned = skills.filter((s) => s.warnings.length > 0 && s.errors.length === 0);
5741
+ const data = { count: skills.length, errored: errored.length, skills };
5742
+ if (errored.length > 0) {
5743
+ const message = `skills lint: ${errored.length} skill${errored.length === 1 ? "" : "s"} failed validation`;
5744
+ if (opts.json === true) {
5745
+ process.stdout.write(
5746
+ `${JSON.stringify({ ok: false, error: { code: EXIT.ERROR, message }, data })}
5747
+ `
5748
+ );
5749
+ process.exitCode = EXIT.ERROR;
5750
+ return;
5751
+ }
5752
+ warn(
5753
+ `noir skills lint \u2014 ${errored.length} skill${errored.length === 1 ? "" : "s"} FAIL validation:`,
5754
+ opts
5755
+ );
5756
+ for (const s of errored) {
5757
+ for (const e of s.errors) warn(` ${s.name}: ${e}`, opts);
5758
+ }
5759
+ if (warned.length > 0) {
5760
+ info(` ${warned.length} skill${warned.length === 1 ? "" : "s"} carry lint warnings:`, opts);
5761
+ for (const s of warned) {
5762
+ for (const w of s.warnings) info(` ${s.name}: ${w}`, opts);
5763
+ }
5764
+ }
5765
+ fail(EXIT.ERROR, message, opts);
5766
+ }
5767
+ if (opts.json === true) {
5768
+ process.stdout.write(`${JSON.stringify({ ok: true, data })}
5769
+ `);
5770
+ return;
5771
+ }
5772
+ log(`noir skills lint \u2014 ${skills.length} skills, all validate clean.`, opts);
5773
+ if (warned.length > 0) {
5774
+ info(` ${warned.length} skill${warned.length === 1 ? "" : "s"} carry lint warnings:`, opts);
5775
+ for (const s of warned) {
5776
+ for (const w of s.warnings) info(` ${s.name}: ${w}`, opts);
5777
+ }
5778
+ }
5779
+ }
5780
+ async function skillsRegistry(opts) {
5781
+ let reg;
5782
+ try {
5783
+ reg = buildRegistry();
5784
+ } catch (err) {
5785
+ fail(
5786
+ EXIT.ERROR,
5787
+ `skills registry: could not build registry (${err instanceof Error ? err.message : String(err)})`,
5788
+ opts
5789
+ );
5790
+ }
5791
+ const data = { count: reg.length, skills: reg };
5792
+ if (opts.json === true) {
5793
+ process.stdout.write(`${JSON.stringify({ ok: true, data })}
5794
+ `);
5795
+ return;
5796
+ }
5797
+ log(`noir skills registry \u2014 ${reg.length} skills.`, opts);
5798
+ table(
5799
+ reg.map((r) => ({
5800
+ Skill: r.name,
5801
+ Kind: r.kind,
5802
+ Category: r.category,
5803
+ Version: r.version,
5804
+ Status: r.status,
5805
+ Refs: String(r.referenceCount),
5806
+ Lines: String(r.lines)
5807
+ })),
5808
+ ["Skill", "Kind", "Category", "Version", "Status", "Refs", "Lines"],
5809
+ opts
5810
+ );
5811
+ }
5812
+ var CATEGORY;
5813
+ var init_skills = __esm({
5814
+ "src/commands/skills.ts"() {
5815
+ "use strict";
5816
+ init_init();
5817
+ init_output();
5818
+ CATEGORY = {
5819
+ "noir-brainstorming": "discovery",
5820
+ "noir-sync": "discovery",
5821
+ "noir-checkpoint": "discovery",
5822
+ "noir-exploring": "discovery",
5823
+ "noir-spec": "spec",
5824
+ "noir-planning": "plan",
5825
+ "noir-prd": "plan",
5826
+ "noir-executing-plans": "execute",
5827
+ "noir-test-driven-development": "execute",
5828
+ "noir-systematic-debugging": "execute",
5829
+ "noir-subagent": "execute",
5830
+ "noir-parallel": "execute",
5831
+ "noir-verifying": "verify",
5832
+ "noir-security": "verify",
5833
+ "noir-wrap": "document",
5834
+ "noir-readme": "document",
5835
+ "noir-shipping": "git",
5836
+ "noir-worktree": "git",
5837
+ "noir-recall": "memory",
5838
+ "noir-remember": "memory",
5839
+ "noir-context": "context",
5840
+ "noir-frontend": "domain",
5841
+ "noir-backend": "domain",
5842
+ "noir-doctor": "meta",
5843
+ "noir-writing-skills": "meta",
5844
+ "noir-rules": "meta"
5845
+ };
5846
+ }
5847
+ });
5848
+
5007
5849
  // src/serve.ts
5008
5850
  import { applyNoirEnv, loadProjectInfo as loadProjectInfo14 } from "@noir-ai/core";
5009
5851
  import { ensureDaemonRunning as ensureDaemonRunning2, startStdioServer } from "@noir-ai/daemon";
@@ -5043,22 +5885,21 @@ var create_exports = {};
5043
5885
  __export(create_exports, {
5044
5886
  create: () => create
5045
5887
  });
5046
- import { resolve } from "path";
5888
+ import { resolve as resolve2 } from "path";
5047
5889
  import { resolveAdapter as resolveAdapter5 } from "@noir-ai/adapters";
5048
5890
  import { loadProjectInfo as loadProjectInfo15 } from "@noir-ai/core";
5049
5891
  import { scaffold as scaffold2 } from "@noir-ai/create";
5050
5892
  import { emitSkillsToDir as emitSkillsToDir3 } from "@noir-ai/skills";
5051
5893
  async function create(dir, opts) {
5052
5894
  assertTransportUrl(opts);
5053
- const root = resolve(dir ?? process.cwd());
5054
- const host = opts.host ?? "claude";
5895
+ const root = resolve2(dir ?? process.cwd());
5055
5896
  const interactive = resolveInteractive();
5056
5897
  const conflictOpts = buildConflictOpts({ force: opts.force, interactive });
5057
5898
  const dryRun = opts.dryRun === true || opts.preview === true;
5058
5899
  const res = await scaffold2({
5059
5900
  root,
5060
5901
  mode: "create",
5061
- host,
5902
+ ...opts.host !== void 0 ? { host: opts.host } : {},
5062
5903
  transport: opts.transport,
5063
5904
  interactive,
5064
5905
  ...opts.url !== void 0 ? { url: opts.url } : {},
@@ -5071,6 +5912,7 @@ async function create(dir, opts) {
5071
5912
  return res;
5072
5913
  }
5073
5914
  if (res.noop) return res;
5915
+ const host = res.host;
5074
5916
  const adapter = resolveAdapter5(host);
5075
5917
  const skillsDir = adapter.skillsDir?.({ root });
5076
5918
  if (skillsDir === void 0) {
@@ -5093,6 +5935,9 @@ async function create(dir, opts) {
5093
5935
  `Emitted ${summary.emitted.length} Noir skills to ${relDir}/ (target: ${target}).
5094
5936
  `
5095
5937
  );
5938
+ const stale = preservedStaleLine(summary.preserved ?? []);
5939
+ if (stale !== void 0) process.stderr.write(`${stale}
5940
+ `);
5096
5941
  }
5097
5942
  let projectInfo;
5098
5943
  try {
@@ -5122,9 +5967,9 @@ var sync_exports = {};
5122
5967
  __export(sync_exports, {
5123
5968
  sync: () => sync
5124
5969
  });
5125
- import { existsSync as existsSync3 } from "fs";
5970
+ import { existsSync as existsSync4 } from "fs";
5126
5971
  import { resolveAdapter as resolveAdapter6 } from "@noir-ai/adapters";
5127
- import { loadProjectInfo as loadProjectInfo16, paths } from "@noir-ai/core";
5972
+ import { loadProjectInfo as loadProjectInfo16, paths as paths3 } from "@noir-ai/core";
5128
5973
  import { scaffold as scaffold3 } from "@noir-ai/create";
5129
5974
  import { emitSkillsToDir as emitSkillsToDir4 } from "@noir-ai/skills";
5130
5975
  async function sync(root, opts = {}) {
@@ -5184,6 +6029,9 @@ async function sync(root, opts = {}) {
5184
6029
  `
5185
6030
  );
5186
6031
  }
6032
+ const stale = preservedStaleLine(summary.preserved ?? []);
6033
+ if (stale !== void 0) process.stderr.write(`${stale}
6034
+ `);
5187
6035
  }
5188
6036
  const dedup = await checkWritePathDedup(root, res, { interactive, project: projectInfo });
5189
6037
  if (dedup.conflicts.length > 0) res.conflicts.push(...dedup.conflicts);
@@ -5191,7 +6039,7 @@ async function sync(root, opts = {}) {
5191
6039
  }
5192
6040
  function resolveSyncHost(root, opts) {
5193
6041
  if (opts.host !== void 0) return opts.host;
5194
- if (!existsSync3(paths.config(root))) return "claude";
6042
+ if (!existsSync4(paths3.config(root))) return "claude";
5195
6043
  try {
5196
6044
  return loadProjectInfo16(root).config.host;
5197
6045
  } catch {
@@ -5321,13 +6169,15 @@ function createProgram() {
5321
6169
  "--upgrade",
5322
6170
  "run scaffold migrations before re-emitting (re-run on an existing project)"
5323
6171
  ).option("--force", "re-scaffold even if already initialized (bypasses the already-init no-op)").option("--dry-run", "report planned writes without writing anything").option("--preview", "alias for --dry-run").addOption(
5324
- // S10: target host. Defaults to `'claude'` (the regression anchor). The
5325
- // chosen host is forwarded to scaffold() + skills emission via
5326
- // resolveAdapter. The choice list mirrors `SUPPORTED_HOSTS` so a new host
5327
- // lands here automatically; commander rejects anything else as usage=2.
6172
+ // Target host. When the flag is absent the host is read from an existing
6173
+ // `.noir/config.yml`, falling back to `'claude'` (so a fresh project is
6174
+ // unaffected). The chosen host is forwarded to scaffold() + skills
6175
+ // emission via resolveAdapter. The choice list mirrors `SUPPORTED_HOSTS`
6176
+ // so a new host lands here automatically; commander rejects anything else
6177
+ // as usage=2.
5328
6178
  new Option(
5329
6179
  "--host <id>",
5330
- "target agentic CLI (default: claude) \u2014 drives host-side emission"
6180
+ "target agentic CLI (default: the host in .noir/config.yml, else claude)"
5331
6181
  ).choices(SUPPORTED_HOSTS2)
5332
6182
  ).action(
5333
6183
  async (opts, cmd) => {
@@ -5390,7 +6240,7 @@ function createProgram() {
5390
6240
  "strip-replace managed regions (discard hand-edits inside <!-- noir:* --> markers)"
5391
6241
  )
5392
6242
  ).addOption(
5393
- // S10: optional `--host` override. When omitted, sync reads host from
6243
+ // Optional `--host` override. When omitted, sync reads host from
5394
6244
  // `.noir/config.yml` (whatever `noir init --host <id>` persisted). The
5395
6245
  // override is rarely needed — documented as advanced.
5396
6246
  new Option(
@@ -5946,7 +6796,7 @@ var init_bin = __esm({
5946
6796
  }
5947
6797
  },
5948
6798
  // The grouped home menu resolves its sections against the LIVE palette
5949
- // registry (home-consolidation S1/S2) — the same source the TUI palette
6799
+ // registry — the same source the TUI palette
5950
6800
  // uses — so the menu cannot drift from the commander tree.
5951
6801
  commands: buildPaletteCommands(createProgram())
5952
6802
  };
@@ -6367,7 +7217,7 @@ var init_daemon_client = __esm({
6367
7217
 
6368
7218
  // src/commands/doctor.ts
6369
7219
  import { spawnSync as spawnSync3 } from "child_process";
6370
- import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync } from "fs";
7220
+ import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync } from "fs";
6371
7221
  import { dirname, join as join6, relative } from "path";
6372
7222
  import { fileURLToPath } from "url";
6373
7223
  import { resolveAdapter as resolveAdapter7 } from "@noir-ai/adapters";
@@ -6377,11 +7227,17 @@ import {
6377
7227
  loadNoirEnv as loadNoirEnv3,
6378
7228
  loadProjectInfo as loadProjectInfo18,
6379
7229
  NOIR_VERSION as NOIR_VERSION6,
6380
- paths as paths2,
7230
+ paths as paths4,
6381
7231
  readInstallRecord as readInstallRecord4,
6382
7232
  readUpdateCache as readUpdateCache3
6383
7233
  } from "@noir-ai/core";
6384
- import { CURRENT_SCAFFOLD_VERSION, readScaffoldVersion } from "@noir-ai/create";
7234
+ import {
7235
+ CURRENT_SCAFFOLD_VERSION,
7236
+ isStaleSeed,
7237
+ loadTemplate,
7238
+ readScaffoldVersion,
7239
+ render
7240
+ } from "@noir-ai/create";
6385
7241
  import { pidAlive as pidAlive4, readProjectDaemonRecord as readProjectDaemonRecord3 } from "@noir-ai/daemon";
6386
7242
  import { resolveModelConfig as resolveModelConfig2 } from "@noir-ai/model";
6387
7243
  async function probeOnnx() {
@@ -6418,8 +7274,8 @@ async function checkRuntime(checks) {
6418
7274
  });
6419
7275
  }
6420
7276
  function checkConfig(root) {
6421
- const configPath = paths2.config(root);
6422
- if (!existsSync4(configPath)) {
7277
+ const configPath = paths4.config(root);
7278
+ if (!existsSync5(configPath)) {
6423
7279
  return {
6424
7280
  project: void 0,
6425
7281
  result: {
@@ -6518,8 +7374,8 @@ async function checkStore(checks, project, root) {
6518
7374
  checks.push({ name: "store", status: "warn", detail: "skipped \u2014 not initialized" });
6519
7375
  return;
6520
7376
  }
6521
- const dbPath = paths2.storeDb(root, project.id);
6522
- if (!existsSync4(dbPath)) {
7377
+ const dbPath = paths4.storeDb(root, project.id);
7378
+ if (!existsSync5(dbPath)) {
6523
7379
  checks.push({
6524
7380
  name: "store",
6525
7381
  status: "warn",
@@ -6534,7 +7390,7 @@ async function checkStore(checks, project, root) {
6534
7390
  checks.push({
6535
7391
  name: "store",
6536
7392
  status: "ok",
6537
- detail: `opens readonly (db: ${paths2.storeDb(root, project.id)})`
7393
+ detail: `opens readonly (db: ${paths4.storeDb(root, project.id)})`
6538
7394
  });
6539
7395
  } catch (e) {
6540
7396
  checks.push({ name: "store", status: "fail", detail: `open failed: ${msg(e)}` });
@@ -6548,7 +7404,7 @@ async function checkStore(checks, project, root) {
6548
7404
  }
6549
7405
  }
6550
7406
  function checkNoirEnv(checks, root, loaded) {
6551
- const path = join6(paths2.noirDir(root), ".env");
7407
+ const path = join6(paths4.noirDir(root), ".env");
6552
7408
  let st;
6553
7409
  try {
6554
7410
  st = statSync(path);
@@ -6627,9 +7483,10 @@ function checkProvider(checks, project, sources) {
6627
7483
  for (const name of names) {
6628
7484
  const p = resolved.providers[name];
6629
7485
  if (!p) continue;
6630
- const fromFile = p.apiKeyEnv !== void 0 && sources[p.apiKeyEnv] === "file";
6631
- const key = p.hasKey ? fromFile ? "key present (from .noir/.env)" : "key present" : p.apiKeyEnv ? `missing ${p.apiKeyEnv}` : "anonymous";
6632
- if (!p.hasKey && p.apiKeyEnv) missing = true;
7486
+ const fromFile = p.apiKeyEnv !== void 0 && sources[p.apiKeyEnv] === "file" || p.authTokenEnv !== void 0 && sources[p.authTokenEnv] === "file";
7487
+ const credentialName = p.apiKeyEnv ?? p.authTokenEnv;
7488
+ const key = p.hasKey ? fromFile ? "key present (from .noir/.env)" : "key present" : credentialName ? `missing ${credentialName}` : "anonymous";
7489
+ if (!p.hasKey && credentialName) missing = true;
6633
7490
  parts.push(`${name}/${p.model ?? "?"} (${key})`);
6634
7491
  }
6635
7492
  checks.push({
@@ -6665,13 +7522,30 @@ function checkScaffoldVersion(checks, root) {
6665
7522
  checks.push({ name: "scaffold version", status: status2, detail });
6666
7523
  return { onDisk, current, drift };
6667
7524
  }
7525
+ function checkSeedDrift(checks, root) {
7526
+ const seedPath = join6(root, ".noir", ".env.example");
7527
+ let bytes;
7528
+ try {
7529
+ bytes = readFileSync4(seedPath, "utf8");
7530
+ } catch {
7531
+ return;
7532
+ }
7533
+ const currentRender = render(loadTemplate("env.example.tmpl"), {});
7534
+ if (isStaleSeed("envExample", bytes, currentRender)) {
7535
+ checks.push({
7536
+ name: "doc seed",
7537
+ status: "warn",
7538
+ detail: `.noir/.env.example is an unedited copy of an older Noir \u2014 run \`noir init --upgrade\` to refresh it`
7539
+ });
7540
+ }
7541
+ }
6668
7542
  function checkRulesMdBudget(checks, root, project) {
6669
7543
  if (!project) {
6670
7544
  checks.push({ name: "rules budget", status: "warn", detail: "skipped \u2014 not initialized" });
6671
7545
  return null;
6672
7546
  }
6673
- const rulesPath = paths2.rulesMd(root);
6674
- if (!existsSync4(rulesPath)) {
7547
+ const rulesPath = paths4.rulesMd(root);
7548
+ if (!existsSync5(rulesPath)) {
6675
7549
  checks.push({
6676
7550
  name: "rules budget",
6677
7551
  status: "ok",
@@ -6735,7 +7609,7 @@ function checkHostArtifacts(checks, root, project) {
6735
7609
  if (host === "claude") expected.push("CLAUDE.md");
6736
7610
  else if (host === "gemini") expected.push("GEMINI.md");
6737
7611
  expected.push(relOr(adapter.mcpConfigPath?.(ectx) ?? ".mcp.json", root));
6738
- const missing = expected.filter((p) => !existsSync4(joinRoot(root, p)));
7612
+ const missing = expected.filter((p) => !existsSync5(joinRoot(root, p)));
6739
7613
  const status2 = missing.length === 0 ? "ok" : "warn";
6740
7614
  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)`;
6741
7615
  checks.push({ name: "host artifacts", status: status2, detail });
@@ -6752,7 +7626,7 @@ function checkNestedNoir(checks, root) {
6752
7626
  ".noir/.npmignore",
6753
7627
  ".noir/.prettierignore"
6754
7628
  ];
6755
- const found = candidates.filter((rel) => existsSync4(join6(root, rel)));
7629
+ const found = candidates.filter((rel) => existsSync5(join6(root, rel)));
6756
7630
  const detected = found.length > 0;
6757
7631
  checks.push({
6758
7632
  name: "nested .noir",
@@ -6766,7 +7640,7 @@ function collectDedupCandidates(root) {
6766
7640
  const out = [];
6767
7641
  for (const rel of rels) {
6768
7642
  const abs = join6(root, rel);
6769
- if (!existsSync4(abs)) continue;
7643
+ if (!existsSync5(abs)) continue;
6770
7644
  let text;
6771
7645
  try {
6772
7646
  text = readFileSync4(abs, "utf8");
@@ -6827,12 +7701,12 @@ function resolveWorkspacePackagesDir() {
6827
7701
  let here = dirname(fileURLToPath(import.meta.url));
6828
7702
  for (let i = 0; i < 8; i++) {
6829
7703
  const pkgPath = join6(here, "package.json");
6830
- if (existsSync4(pkgPath)) {
7704
+ if (existsSync5(pkgPath)) {
6831
7705
  try {
6832
7706
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
6833
7707
  if (pkg.name === "@noir-ai/cli") {
6834
7708
  const packagesDir = dirname(here);
6835
- if (existsSync4(join6(dirname(packagesDir), "pnpm-workspace.yaml"))) {
7709
+ if (existsSync5(join6(dirname(packagesDir), "pnpm-workspace.yaml"))) {
6836
7710
  return packagesDir;
6837
7711
  }
6838
7712
  return null;
@@ -6891,7 +7765,7 @@ function checkPublish(checks, packagesDir, opts = {}) {
6891
7765
  let cliDir = null;
6892
7766
  for (const name of entries) {
6893
7767
  const pkgPath = join6(packagesDir, name, "package.json");
6894
- if (!existsSync4(pkgPath)) continue;
7768
+ if (!existsSync5(pkgPath)) continue;
6895
7769
  let pkg;
6896
7770
  try {
6897
7771
  pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
@@ -6999,6 +7873,7 @@ async function doctor(opts = {}) {
6999
7873
  checkProvider(checks, project, noirEnv.sources);
7000
7874
  checkNoirEnv(checks, root, noirEnv);
7001
7875
  const scaffold4 = checkScaffoldVersion(checks, root);
7876
+ checkSeedDrift(checks, root);
7002
7877
  const rules = checkRulesMdBudget(checks, root, project);
7003
7878
  const host = checkHostArtifacts(checks, root, project);
7004
7879
  checkNestedNoir(checks, root);