@noir-ai/cli 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import {
2
2
  DEFAULT_UPDATE_CONFIG,
3
3
  buildUpdateNotice,
4
4
  runAsyncUpdateCheck
5
- } from "./chunk-2RK6AFZQ.js";
5
+ } from "./chunk-TPNJEN4B.js";
6
6
  import {
7
7
  EXIT,
8
8
  NoirCliError,
@@ -12,6 +12,7 @@ import {
12
12
  definitionList,
13
13
  error,
14
14
  fail,
15
+ failAndExit,
15
16
  handleError,
16
17
  info,
17
18
  isInteractive,
@@ -25,7 +26,7 @@ import {
25
26
  tip,
26
27
  useColor,
27
28
  warn
28
- } from "./chunk-ESZTOQEC.js";
29
+ } from "./chunk-KETMM3BA.js";
29
30
 
30
31
  // src/commands/status.ts
31
32
  import { loadProjectInfo as loadProjectInfo14, NOIR_VERSION as NOIR_VERSION5 } from "@noir-ai/core";
@@ -174,8 +175,8 @@ async function contextIndex(opts) {
174
175
  }
175
176
  renderIndex(data, opts.paths, opts);
176
177
  }
177
- function renderIndex(data, paths2, opts) {
178
- const scope = paths2 && paths2.length > 0 ? paths2.join(", ") : ".";
178
+ function renderIndex(data, paths4, opts) {
179
+ const scope = paths4 && paths4.length > 0 ? paths4.join(", ") : ".";
179
180
  const flag = data.degraded ? ` ${badge("warn", "degraded: vectors skipped")}` : "";
180
181
  log(`context index \u2014 ${scope}${flag}`, opts);
181
182
  definitionList(
@@ -870,7 +871,13 @@ import {
870
871
  readInstallRecord,
871
872
  readUpdateCache
872
873
  } from "@noir-ai/core";
873
- import { CURRENT_SCAFFOLD_VERSION, readScaffoldVersion } from "@noir-ai/create";
874
+ import {
875
+ CURRENT_SCAFFOLD_VERSION,
876
+ isStaleSeed,
877
+ loadTemplate,
878
+ readScaffoldVersion,
879
+ render
880
+ } from "@noir-ai/create";
874
881
  import { pidAlive as pidAlive3, readProjectDaemonRecord as readProjectDaemonRecord2 } from "@noir-ai/daemon";
875
882
  import { resolveModelConfig } from "@noir-ai/model";
876
883
  async function probeOnnx() {
@@ -1117,9 +1124,10 @@ function checkProvider(checks, project, sources) {
1117
1124
  for (const name of names) {
1118
1125
  const p = resolved.providers[name];
1119
1126
  if (!p) continue;
1120
- const fromFile = p.apiKeyEnv !== void 0 && sources[p.apiKeyEnv] === "file";
1121
- const key = p.hasKey ? fromFile ? "key present (from .noir/.env)" : "key present" : p.apiKeyEnv ? `missing ${p.apiKeyEnv}` : "anonymous";
1122
- if (!p.hasKey && p.apiKeyEnv) missing = true;
1127
+ const fromFile = p.apiKeyEnv !== void 0 && sources[p.apiKeyEnv] === "file" || p.authTokenEnv !== void 0 && sources[p.authTokenEnv] === "file";
1128
+ const credentialName = p.apiKeyEnv ?? p.authTokenEnv;
1129
+ const key = p.hasKey ? fromFile ? "key present (from .noir/.env)" : "key present" : credentialName ? `missing ${credentialName}` : "anonymous";
1130
+ if (!p.hasKey && credentialName) missing = true;
1123
1131
  parts.push(`${name}/${p.model ?? "?"} (${key})`);
1124
1132
  }
1125
1133
  checks.push({
@@ -1155,6 +1163,23 @@ function checkScaffoldVersion(checks, root) {
1155
1163
  checks.push({ name: "scaffold version", status: status2, detail });
1156
1164
  return { onDisk, current, drift };
1157
1165
  }
1166
+ function checkSeedDrift(checks, root) {
1167
+ const seedPath = join2(root, ".noir", ".env.example");
1168
+ let bytes;
1169
+ try {
1170
+ bytes = readFileSync2(seedPath, "utf8");
1171
+ } catch {
1172
+ return;
1173
+ }
1174
+ const currentRender = render(loadTemplate("env.example.tmpl"), {});
1175
+ if (isStaleSeed("envExample", bytes, currentRender)) {
1176
+ checks.push({
1177
+ name: "doc seed",
1178
+ status: "warn",
1179
+ detail: `.noir/.env.example is an unedited copy of an older Noir \u2014 run \`noir init --upgrade\` to refresh it`
1180
+ });
1181
+ }
1182
+ }
1158
1183
  function checkRulesMdBudget(checks, root, project) {
1159
1184
  if (!project) {
1160
1185
  checks.push({ name: "rules budget", status: "warn", detail: "skipped \u2014 not initialized" });
@@ -1490,6 +1515,7 @@ async function doctor(opts = {}) {
1490
1515
  checkProvider(checks, project, noirEnv.sources);
1491
1516
  checkNoirEnv(checks, root, noirEnv);
1492
1517
  const scaffold2 = checkScaffoldVersion(checks, root);
1518
+ checkSeedDrift(checks, root);
1493
1519
  const rules = checkRulesMdBudget(checks, root, project);
1494
1520
  const host = checkHostArtifacts(checks, root, project);
1495
1521
  checkNestedNoir(checks, root);
@@ -1542,6 +1568,9 @@ var CURATED_AMBIENT_KEYS = [
1542
1568
  "COHERE_API_KEY",
1543
1569
  "OLLAMA_BASE_URL",
1544
1570
  "ANTHROPIC_API_KEY",
1571
+ "ANTHROPIC_AUTH_TOKEN",
1572
+ "ANTHROPIC_BASE_URL",
1573
+ "API_TIMEOUT_MS",
1545
1574
  "NOIR_PROFILE"
1546
1575
  ];
1547
1576
  var ambientBaseline;
@@ -1597,12 +1626,12 @@ function describeSource(row) {
1597
1626
  return row.shadowed === true ? ".noir/.env (shadows environment)" : ".noir/.env";
1598
1627
  }
1599
1628
  async function env(opts = {}) {
1600
- const report = resolveEnv(process.cwd(), ambientBaseline ?? process.env);
1629
+ const report2 = resolveEnv(process.cwd(), ambientBaseline ?? process.env);
1601
1630
  if (opts.json === true) {
1602
- json({ ok: true, data: { vars: report.vars, warnings: report.warnings } });
1631
+ json({ ok: true, data: { vars: report2.vars, warnings: report2.warnings } });
1603
1632
  return;
1604
1633
  }
1605
- if (report.vars.length === 0) {
1634
+ if (report2.vars.length === 0) {
1606
1635
  info(
1607
1636
  "nothing to report \u2014 .noir/.env defines no keys in effect and no Noir-relevant environment variable is set",
1608
1637
  opts
@@ -1610,10 +1639,10 @@ async function env(opts = {}) {
1610
1639
  return;
1611
1640
  }
1612
1641
  table(
1613
- report.vars.map((row) => ({
1642
+ report2.vars.map((row) => ({
1614
1643
  KEY: row.key,
1615
1644
  SOURCE: describeSource(row),
1616
- VALUE: report.shapes[row.key] ?? ""
1645
+ VALUE: report2.shapes[row.key] ?? ""
1617
1646
  })),
1618
1647
  ["KEY", "SOURCE", "VALUE"],
1619
1648
  opts
@@ -1641,7 +1670,7 @@ import {
1641
1670
  writeDecisionStub
1642
1671
  } from "@noir-ai/workflow";
1643
1672
  var PHASE_SKILL = {
1644
- // C3 curation (2026-08-10): intake+clarify merged into brainstorming;
1673
+ // Skill curation: intake+clarify merged into brainstorming;
1645
1674
  // execute → executing-plans; verify → verifying; document → wrap.
1646
1675
  intake: "noir-brainstorming",
1647
1676
  clarify: "noir-brainstorming",
@@ -2053,7 +2082,7 @@ async function taskVerify(opts) {
2053
2082
  `${JSON.stringify({
2054
2083
  ok: false,
2055
2084
  error: { code: EXIT.ERROR, message: `task verify: ${detail}` },
2056
- // S9 `{ok:false, error}` + a payload — recovery context + evidence nest
2085
+ // `{ok:false, error}` + a payload — recovery context + evidence nest
2057
2086
  // under `data`, not as top-level siblings (same envelope shape as the
2058
2087
  // success path; consumers read `data` for the full picture).
2059
2088
  data: {
@@ -2416,6 +2445,14 @@ function isDestructive(argv) {
2416
2445
  return prefix.every((tok, i) => argv[i] === tok);
2417
2446
  });
2418
2447
  }
2448
+ function runPromptFrom(argv) {
2449
+ if (argv[0] !== "run") return null;
2450
+ const rest = argv.slice(1);
2451
+ if (rest.length === 0) return null;
2452
+ if (rest.some((token) => token.startsWith("-"))) return null;
2453
+ const prompt = rest.join(" ").trim();
2454
+ return prompt.length > 0 ? prompt : null;
2455
+ }
2419
2456
  function capitalizeFirst(s) {
2420
2457
  if (s.length === 0) return s;
2421
2458
  return `${s[0]?.toUpperCase() ?? ""}${s.slice(1)}`;
@@ -2423,6 +2460,10 @@ function capitalizeFirst(s) {
2423
2460
  function hasAction(cmd) {
2424
2461
  return cmd._actionHandler != null;
2425
2462
  }
2463
+ function requiredArgLabel(cmd) {
2464
+ const required = cmd.registeredArguments.find((arg) => arg.required);
2465
+ return required?.name();
2466
+ }
2426
2467
  function collectLeaves(cmd, path, out) {
2427
2468
  const children = cmd.commands;
2428
2469
  const isLeaf = hasAction(cmd) && children.length === 0;
@@ -2437,7 +2478,8 @@ function collectLeaves(cmd, path, out) {
2437
2478
  description,
2438
2479
  category: argv[0] ?? "",
2439
2480
  keywords: argv,
2440
- destructive: isDestructive(argv)
2481
+ destructive: isDestructive(argv),
2482
+ needsArg: requiredArgLabel(cmd)
2441
2483
  });
2442
2484
  return;
2443
2485
  }
@@ -2503,7 +2545,15 @@ var HOME_SECTIONS = [
2503
2545
  label: "Save memory",
2504
2546
  hint: "save an observation \xB7 \u26A0 destructive",
2505
2547
  destructive: true,
2506
- needsArg: { prompt: "Memory content:", placeholder: "what you want to remember" }
2548
+ // The value travels as `--content <text>` (memory save declares no
2549
+ // positional), so the flag rides in the dispatch prefix and the
2550
+ // collected value is appended after it.
2551
+ dispatch: ["memory", "save", "--content"],
2552
+ needsArg: {
2553
+ label: "content",
2554
+ prompt: "Memory content:",
2555
+ placeholder: "what you want to remember"
2556
+ }
2507
2557
  },
2508
2558
  {
2509
2559
  id: "memory sessions",
@@ -2527,8 +2577,21 @@ var HOME_SECTIONS = [
2527
2577
  {
2528
2578
  id: "workflow",
2529
2579
  label: "Workflow",
2530
- hint: "next \xB7 status \xB7 advance \xB7 handoff \xB7 wrap",
2580
+ hint: "run \xB7 next \xB7 status \xB7 advance \xB7 handoff \xB7 wrap",
2531
2581
  items: [
2582
+ {
2583
+ id: "run",
2584
+ label: "Ask the host",
2585
+ hint: "ask the host agent a question \xB7 \u26A0 destructive",
2586
+ destructive: true,
2587
+ // The prompt is not a required argument (the command also takes flags
2588
+ // like --list-profiles), so the palette only learns to ask for it here.
2589
+ needsArg: {
2590
+ label: "prompt",
2591
+ prompt: "Prompt for the host:",
2592
+ placeholder: "e.g. summarise the auth flow"
2593
+ }
2594
+ },
2532
2595
  {
2533
2596
  id: "task next",
2534
2597
  label: "Next task",
@@ -3362,16 +3425,19 @@ async function release(opts) {
3362
3425
  }
3363
3426
 
3364
3427
  // src/commands/run.ts
3365
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
3428
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
3366
3429
  import { join as join4 } from "path";
3367
3430
  import { SUPPORTED_HOSTS } from "@noir-ai/adapters";
3368
- import { loadNoirEnv as loadNoirEnv3, NOIR_DIR as NOIR_DIR2, parseConfig } from "@noir-ai/core";
3431
+ import { isDeniedEnvKey as isDeniedEnvKey2, loadNoirEnv as loadNoirEnv3, NOIR_DIR as NOIR_DIR2, parseConfig, paths as paths2 } from "@noir-ai/core";
3369
3432
 
3370
3433
  // src/orchestrator.ts
3371
3434
  import { spawn as spawn2 } from "child_process";
3372
3435
  import { constants } from "os";
3373
3436
  import { createInterface } from "readline";
3374
3437
 
3438
+ // src/run-timing.ts
3439
+ var INTERRUPT_GRACE_MS = 5e3;
3440
+
3375
3441
  // src/shell-bridge.ts
3376
3442
  import { spawn } from "child_process";
3377
3443
  import { basename } from "path";
@@ -3419,7 +3485,7 @@ var PROBE_SCRIPTS = {
3419
3485
  function probeCommand(name, shell, opts) {
3420
3486
  const kind = shellKind(shell);
3421
3487
  const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS2;
3422
- return new Promise((resolve) => {
3488
+ return new Promise((resolve2) => {
3423
3489
  const script = kind ? PROBE_SCRIPTS[kind] : "";
3424
3490
  const flags = kind ? PROBE_FLAGS[kind] : "-lic";
3425
3491
  const probeArgs = kind === "fish" ? [name] : ["noirbridge", name];
@@ -3442,15 +3508,15 @@ function probeCommand(name, shell, opts) {
3442
3508
  } catch {
3443
3509
  }
3444
3510
  }
3445
- resolve({ stdout, timedOut: true });
3511
+ resolve2({ stdout, timedOut: true });
3446
3512
  }, timeoutMs);
3447
3513
  child.on("error", () => {
3448
3514
  clearTimeout(timer);
3449
- resolve({ stdout, timedOut: false });
3515
+ resolve2({ stdout, timedOut: false });
3450
3516
  });
3451
3517
  child.on("close", () => {
3452
3518
  clearTimeout(timer);
3453
- resolve({ stdout, timedOut: false });
3519
+ resolve2({ stdout, timedOut: false });
3454
3520
  });
3455
3521
  });
3456
3522
  }
@@ -3506,7 +3572,7 @@ var HOST_BINARY = {
3506
3572
  // emits AGENTS.md — a file host, not a spawnable CLI
3507
3573
  };
3508
3574
  var HOST_FLAGS = {
3509
- claude: ["-p", "--output-format", "stream-json", "--verbose"],
3575
+ claude: ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages"],
3510
3576
  gemini: ["-p", "--output-format", "stream-json"],
3511
3577
  opencode: ["run"],
3512
3578
  cursor: ["-p"],
@@ -3539,18 +3605,23 @@ function usageFrom(v) {
3539
3605
  if (input === void 0 && output === void 0 && cache === void 0) return void 0;
3540
3606
  return { inputTokens: input, outputTokens: output, cacheReadTokens: cache };
3541
3607
  }
3542
- function messageText(message) {
3543
- if (!message) return void 0;
3608
+ function messageContent(message) {
3609
+ if (!message) return {};
3544
3610
  const content = message.content;
3545
- if (typeof content === "string") return content;
3546
- if (Array.isArray(content)) {
3547
- const parts = content.map((block) => {
3548
- const b = asObj(block);
3549
- return b && typeof b.text === "string" ? b.text : null;
3550
- }).filter((t) => t !== null);
3551
- if (parts.length > 0) return parts.join("");
3611
+ if (typeof content === "string") return { text: content };
3612
+ if (!Array.isArray(content)) return {};
3613
+ const parts = [];
3614
+ const tools = [];
3615
+ for (const block of content) {
3616
+ const b = asObj(block);
3617
+ if (!b) continue;
3618
+ if (typeof b.text === "string") parts.push(b.text);
3619
+ else if (b.type === "tool_use" && typeof b.name === "string") tools.push(b.name);
3552
3620
  }
3553
- return void 0;
3621
+ return {
3622
+ ...parts.length > 0 ? { text: parts.join("") } : {},
3623
+ ...tools.length > 0 ? { tools } : {}
3624
+ };
3554
3625
  }
3555
3626
  function parseStreamLine(line) {
3556
3627
  const trimmed = line.trim();
@@ -3569,15 +3640,14 @@ function normalizeStreamEvent(raw) {
3569
3640
  if (r.subtype === "init") {
3570
3641
  return { kind: "init", sessionId: str(r.session_id), model: str(r.model) };
3571
3642
  }
3572
- return { kind: "other" };
3643
+ return { kind: "other", subtype: str(r.subtype) ?? "system" };
3573
3644
  }
3574
3645
  if (type === "assistant") {
3575
3646
  const message = asObj(r.message);
3576
- return {
3577
- kind: "assistant",
3578
- messageId: message ? str(message.id) : void 0,
3579
- text: messageText(message),
3580
- usage: message ? usageFrom(message.usage) : void 0,
3647
+ const content = messageContent(message);
3648
+ const messageId = message ? str(message.id) : void 0;
3649
+ const usage = message ? usageFrom(message.usage) : void 0;
3650
+ const error2 = {
3581
3651
  // An API error message is a diagnostic, not the answer — flag it so the
3582
3652
  // caller never streams it as assistant text (optional-when-true keeps
3583
3653
  // exact-shape consumers of the existing union intact).
@@ -3586,6 +3656,20 @@ function normalizeStreamEvent(raw) {
3586
3656
  ...typeof r.error === "string" ? { errorCategory: r.error } : {}
3587
3657
  } : {}
3588
3658
  };
3659
+ if (content.text !== void 0) {
3660
+ return {
3661
+ kind: "assistant",
3662
+ messageId,
3663
+ text: content.text,
3664
+ usage,
3665
+ ...content.tools === void 0 ? {} : { tools: content.tools },
3666
+ ...error2
3667
+ };
3668
+ }
3669
+ if (content.tools !== void 0) {
3670
+ return { kind: "tool", name: content.tools[0], messageId, usage };
3671
+ }
3672
+ return { kind: "assistant", messageId, usage, ...error2 };
3589
3673
  }
3590
3674
  if (type === "result") {
3591
3675
  return {
@@ -3597,7 +3681,29 @@ function normalizeStreamEvent(raw) {
3597
3681
  usage: usageFrom(r.usage)
3598
3682
  };
3599
3683
  }
3600
- return { kind: "other" };
3684
+ if (type === "stream_event") {
3685
+ return normalizeStreamEventInner(asObj(r.event));
3686
+ }
3687
+ return { kind: "other", ...typeof type === "string" ? { subtype: type } : {} };
3688
+ }
3689
+ function normalizeStreamEventInner(inner) {
3690
+ if (!inner) return { kind: "other", subtype: "stream_event" };
3691
+ const innerType = str(inner.type);
3692
+ if (innerType === "content_block_start") {
3693
+ const block = asObj(inner.content_block);
3694
+ const name = block ? str(block.name) : void 0;
3695
+ if (block && str(block.type) === "tool_use" && name !== void 0) {
3696
+ return { kind: "tool", name };
3697
+ }
3698
+ }
3699
+ if (innerType === "content_block_delta") {
3700
+ const delta = asObj(inner.delta);
3701
+ const text = delta ? str(delta.text) : void 0;
3702
+ if (delta && delta.type === "text_delta" && text !== void 0) {
3703
+ return { kind: "delta", text };
3704
+ }
3705
+ }
3706
+ return { kind: "other", subtype: innerType ?? "stream_event" };
3601
3707
  }
3602
3708
  var UsageReducer = class {
3603
3709
  maxByMessage = /* @__PURE__ */ new Map();
@@ -3607,27 +3713,29 @@ var UsageReducer = class {
3607
3713
  turns = 0;
3608
3714
  /** Feed one normalized event into the reducer. */
3609
3715
  add(event) {
3610
- if (event.kind === "assistant" && event.messageId && event.usage) {
3611
- const key = event.messageId;
3612
- const input = event.usage.inputTokens ?? 0;
3613
- const output = event.usage.outputTokens ?? 0;
3614
- const prev = this.maxByMessage.get(key);
3615
- if (!prev) {
3616
- this.maxByMessage.set(key, { input, output });
3617
- this.input += input;
3618
- this.output += output;
3619
- } else {
3620
- this.input += Math.max(0, input - prev.input);
3621
- this.output += Math.max(0, output - prev.output);
3622
- this.maxByMessage.set(key, {
3623
- input: Math.max(prev.input, input),
3624
- output: Math.max(prev.output, output)
3625
- });
3626
- }
3627
- } else if (event.kind === "result") {
3716
+ if (event.kind === "result") {
3628
3717
  if (event.totalCostUsd !== void 0) this.costUsd = event.totalCostUsd;
3629
3718
  if (event.numTurns !== void 0) this.turns = event.numTurns;
3719
+ return;
3720
+ }
3721
+ const usage = event.kind === "assistant" || event.kind === "tool" ? event.usage : void 0;
3722
+ const key = event.kind === "assistant" || event.kind === "tool" ? event.messageId : void 0;
3723
+ if (!key || !usage) return;
3724
+ const input = usage.inputTokens ?? 0;
3725
+ const output = usage.outputTokens ?? 0;
3726
+ const prev = this.maxByMessage.get(key);
3727
+ if (!prev) {
3728
+ this.maxByMessage.set(key, { input, output });
3729
+ this.input += input;
3730
+ this.output += output;
3731
+ return;
3630
3732
  }
3733
+ this.input += Math.max(0, input - prev.input);
3734
+ this.output += Math.max(0, output - prev.output);
3735
+ this.maxByMessage.set(key, {
3736
+ input: Math.max(prev.input, input),
3737
+ output: Math.max(prev.output, output)
3738
+ });
3631
3739
  }
3632
3740
  /** The current accumulator snapshot. */
3633
3741
  snapshot() {
@@ -3639,6 +3747,12 @@ var UsageReducer = class {
3639
3747
  };
3640
3748
  }
3641
3749
  };
3750
+ function signalChild(child, signal) {
3751
+ if (child.pid === void 0) return;
3752
+ child.kill(signal);
3753
+ }
3754
+ var BRIDGE_POLITE_RESEND_MS = 250;
3755
+ var BRIDGE_POLITE_WINDOW_MS = INTERRUPT_GRACE_MS;
3642
3756
  function runHost(opts) {
3643
3757
  const spec = resolveHostRun(opts.host, opts.customBinary);
3644
3758
  if (!spec) {
@@ -3653,12 +3767,13 @@ function runHost(opts) {
3653
3767
  false
3654
3768
  );
3655
3769
  }
3656
- function spawnAndConsume(binary, args, opts, shellRun) {
3657
- return new Promise((resolve, reject) => {
3770
+ function spawnAndConsume(binary, args, opts, shellRun, bridged = false) {
3771
+ return new Promise((resolve2, reject) => {
3658
3772
  const child = spawn2(binary, [...args], {
3659
3773
  stdio: ["ignore", "pipe", "pipe"],
3660
3774
  ...opts.env ? { env: opts.env } : {}
3661
3775
  });
3776
+ opts.onChild?.(child);
3662
3777
  const reducer = new UsageReducer();
3663
3778
  let eventCount = 0;
3664
3779
  let stderrBuf = "";
@@ -3666,10 +3781,40 @@ function spawnAndConsume(binary, args, opts, shellRun) {
3666
3781
  let errorText;
3667
3782
  let errorCategory;
3668
3783
  let spawnError = null;
3784
+ const { signal } = opts;
3785
+ let resend;
3786
+ let offersLeft = Math.ceil(BRIDGE_POLITE_WINDOW_MS / BRIDGE_POLITE_RESEND_MS);
3787
+ const stopResend = () => {
3788
+ if (resend === void 0) return;
3789
+ clearTimeout(resend);
3790
+ resend = void 0;
3791
+ };
3792
+ const offerAgain = () => {
3793
+ if (offersLeft <= 0) return;
3794
+ offersLeft -= 1;
3795
+ resend = setTimeout(() => {
3796
+ resend = void 0;
3797
+ signalChild(child, "SIGTERM");
3798
+ offerAgain();
3799
+ }, BRIDGE_POLITE_RESEND_MS);
3800
+ };
3801
+ const onAbort = () => {
3802
+ signalChild(child, "SIGTERM");
3803
+ if (bridged) offerAgain();
3804
+ };
3805
+ if (signal) {
3806
+ if (signal.aborted) onAbort();
3807
+ else signal.addEventListener("abort", onAbort, { once: true });
3808
+ }
3809
+ const detachAbort = () => {
3810
+ signal?.removeEventListener("abort", onAbort);
3811
+ stopResend();
3812
+ };
3669
3813
  child.on("error", (err) => {
3670
3814
  spawnError = err;
3815
+ detachAbort();
3671
3816
  if (!shellRun && err.code === "ENOENT") {
3672
- void shellFallback(binary, args, opts, resolve, reject, err);
3817
+ void shellFallback(binary, args, opts, resolve2, reject, err);
3673
3818
  return;
3674
3819
  }
3675
3820
  reject(err);
@@ -3696,31 +3841,32 @@ function spawnAndConsume(binary, args, opts, shellRun) {
3696
3841
  child.stderr.on("data", (chunk) => {
3697
3842
  stderrBuf += chunk;
3698
3843
  });
3699
- child.on("close", (code, signal) => {
3844
+ child.on("close", (code, signal2) => {
3845
+ detachAbort();
3700
3846
  if (spawnError !== null) return;
3701
- const killed = code === null && signal != null;
3702
- resolve({
3703
- exitCode: killed ? 128 + (signalNumber(signal) ?? 0) : code ?? 0,
3847
+ const killed = code === null && signal2 != null;
3848
+ resolve2({
3849
+ exitCode: killed ? 128 + (signalNumber(signal2) ?? 0) : code ?? 0,
3704
3850
  usage: reducer.snapshot(),
3705
3851
  eventCount,
3706
3852
  stderr: stderrBuf,
3707
3853
  isError: errored || killed,
3708
- errorText: killed && errorText === void 0 ? `terminated by signal ${signal}` : errorText,
3854
+ errorText: killed && errorText === void 0 ? `terminated by signal ${signal2}` : errorText,
3709
3855
  errorCategory
3710
3856
  });
3711
3857
  });
3712
3858
  });
3713
3859
  }
3714
- async function shellFallback(binary, args, opts, resolve, reject, originalErr) {
3860
+ async function shellFallback(binary, args, opts, resolve2, reject, originalErr) {
3715
3861
  try {
3716
3862
  const res = await resolveCommandViaShell(binary, { env: opts.env ?? process.env });
3717
3863
  if (res.kind === "path") {
3718
- resolve(await spawnAndConsume(res.path, args, opts, true));
3864
+ resolve2(await spawnAndConsume(res.path, args, opts, true));
3719
3865
  return;
3720
3866
  }
3721
3867
  if (res.kind === "alias" || res.kind === "function") {
3722
3868
  const bridge = buildBridgeArgs(binary, args, res.shell);
3723
- resolve(await spawnAndConsume(bridge.binary, bridge.args, opts, true));
3869
+ resolve2(await spawnAndConsume(bridge.binary, bridge.args, opts, true, true));
3724
3870
  return;
3725
3871
  }
3726
3872
  } catch {
@@ -3728,8 +3874,339 @@ async function shellFallback(binary, args, opts, resolve, reject, originalErr) {
3728
3874
  reject(originalErr);
3729
3875
  }
3730
3876
 
3877
+ // src/run-actions.ts
3878
+ import { writeFileSync as writeFileSync3 } from "fs";
3879
+ import { resolve } from "path";
3880
+ import { CommanderError } from "commander";
3881
+ var CAPTURE_EVENT = "capture";
3882
+ var RESEARCH_SOURCE = "noir run";
3883
+ var RESEARCH_TEXT_LIMIT = 220;
3884
+ var ACTION_LABEL = {
3885
+ memory: "Saving to memory",
3886
+ research: "Recording the finding",
3887
+ handoff: "Writing the handoff",
3888
+ resume: "Continuing the session",
3889
+ save: "Writing the file",
3890
+ dismiss: "Dismissing"
3891
+ };
3892
+ function postRunActions(input) {
3893
+ const answer = input.answer.trim();
3894
+ const canContinue = input.canContinue ?? (input.sessionId !== void 0 && input.resume !== void 0);
3895
+ const options = [];
3896
+ if (answer.length > 0) {
3897
+ options.push({
3898
+ value: "memory",
3899
+ label: "Save answer to memory",
3900
+ hint: "distil it into this project's memory (needs the daemon)"
3901
+ });
3902
+ options.push({
3903
+ value: "research",
3904
+ label: "Add as task research",
3905
+ hint: "record it as a finding on the active task"
3906
+ });
3907
+ }
3908
+ options.push({
3909
+ value: "handoff",
3910
+ label: "Write a handoff artifact",
3911
+ hint: "snapshot the task, the next step and a seed for your host"
3912
+ });
3913
+ if (canContinue) {
3914
+ options.push({
3915
+ value: "resume",
3916
+ label: "Continue this session",
3917
+ hint: `ask ${input.host} a follow-up in the same session`,
3918
+ needsValue: {
3919
+ label: "follow-up prompt",
3920
+ placeholder: "e.g. now write the tests for that"
3921
+ }
3922
+ });
3923
+ }
3924
+ if (answer.length > 0) {
3925
+ options.push({
3926
+ value: "save",
3927
+ label: "Save answer to a file",
3928
+ hint: "write the answer text to a path you choose",
3929
+ needsValue: { label: "file path", placeholder: "e.g. notes/answer.md" }
3930
+ });
3931
+ }
3932
+ options.push({
3933
+ value: "dismiss",
3934
+ label: "Dismiss",
3935
+ hint: "nothing further \u2014 the run is already complete"
3936
+ });
3937
+ return options;
3938
+ }
3939
+ async function performPostRunAction(choice, input, value) {
3940
+ const label = ACTION_LABEL[choice];
3941
+ try {
3942
+ switch (choice) {
3943
+ case "memory":
3944
+ await saveToMemory(input);
3945
+ return { ok: true, message: "" };
3946
+ case "research":
3947
+ await recordAsResearch(input);
3948
+ return { ok: true, message: "" };
3949
+ case "handoff":
3950
+ await writeHandoff(input);
3951
+ return { ok: true, message: "" };
3952
+ case "resume":
3953
+ return await continueSession(input, value);
3954
+ case "save":
3955
+ return saveAnswerToFile(input, value);
3956
+ case "dismiss":
3957
+ return { ok: true, message: "" };
3958
+ }
3959
+ } catch (err) {
3960
+ const quoted = err instanceof CommanderError || err instanceof NoirCliError ? void 0 : `${label}: ${err instanceof Error ? err.message : String(err)}`;
3961
+ return {
3962
+ ok: false,
3963
+ message: `${label} did not complete \u2014 the run's result stands.`,
3964
+ ...quoted === void 0 ? {} : { detail: quoted }
3965
+ };
3966
+ }
3967
+ }
3968
+ async function offerPostRunActions(input) {
3969
+ if (!isInteractive(input.opts)) return;
3970
+ const options = postRunActions(input);
3971
+ if (options.length <= 1) return;
3972
+ const menu = await openMenu(options);
3973
+ if (menu === void 0) return;
3974
+ const chosen = options.find((o) => o.value === menu.choice);
3975
+ if (chosen === void 0) return;
3976
+ const value = chosen.needsValue === void 0 ? void 0 : await askForValue(menu.clack, chosen, input);
3977
+ if (chosen.needsValue !== void 0 && value === void 0) return;
3978
+ report(await performPostRunAction(menu.choice, input, value), input.opts);
3979
+ }
3980
+ function report(outcome, opts) {
3981
+ const caution = outcome.tone === "caution" || !outcome.ok && outcome.tone !== "confirm";
3982
+ if (outcome.detail !== void 0) warn(outcome.detail, opts);
3983
+ if (outcome.message.length === 0) return;
3984
+ if (caution) warn(outcome.message, opts);
3985
+ else success(outcome.message, opts);
3986
+ }
3987
+ async function openMenu(options) {
3988
+ try {
3989
+ const clack = await import("@clack/prompts");
3990
+ const choice = await clack.select({
3991
+ message: "The run finished. What next?",
3992
+ initialValue: "dismiss",
3993
+ // Spread because the prompt library takes a mutable list; the rows
3994
+ // themselves are the shared descriptors and are never rewritten.
3995
+ options: [...options]
3996
+ });
3997
+ if (clack.isCancel(choice)) return void 0;
3998
+ return { clack, choice };
3999
+ } catch {
4000
+ return void 0;
4001
+ }
4002
+ }
4003
+ async function askForValue(clack, option, input) {
4004
+ const needs = option.needsValue;
4005
+ if (needs === void 0) return void 0;
4006
+ const message = option.value === "save" ? `Write the answer to a file (the raw transcript is at ${input.transcript})` : `What should ${input.host} do next?`;
4007
+ try {
4008
+ const written = await clack.text({ message, placeholder: needs.placeholder });
4009
+ if (clack.isCancel(written)) return void 0;
4010
+ return String(written ?? "");
4011
+ } catch {
4012
+ return void 0;
4013
+ }
4014
+ }
4015
+ async function saveToMemory(input) {
4016
+ await memoryCapture({
4017
+ ...input.opts,
4018
+ content: input.answer,
4019
+ eventType: CAPTURE_EVENT
4020
+ });
4021
+ }
4022
+ async function recordAsResearch(input) {
4023
+ await taskResearchRecord({
4024
+ ...input.opts,
4025
+ type: "discovery",
4026
+ text: researchText(input.answer),
4027
+ source: RESEARCH_SOURCE
4028
+ });
4029
+ }
4030
+ function researchText(answer) {
4031
+ const condensed = answer.replace(/\s+/g, " ").trim();
4032
+ if (condensed.length <= RESEARCH_TEXT_LIMIT) return condensed;
4033
+ return `${condensed.slice(0, RESEARCH_TEXT_LIMIT - 1)}\u2026`;
4034
+ }
4035
+ async function writeHandoff(input) {
4036
+ await handoff({ ...input.opts, write: true });
4037
+ }
4038
+ async function continueSession(input, value) {
4039
+ const { sessionId, resume } = input;
4040
+ if (sessionId === void 0 || resume === void 0) return { ok: true, message: "" };
4041
+ const prompt = (value ?? "").trim();
4042
+ if (prompt.length === 0) {
4043
+ return {
4044
+ ok: true,
4045
+ tone: "caution",
4046
+ message: "No prompt given \u2014 the session was not continued."
4047
+ };
4048
+ }
4049
+ await resume(prompt, sessionId);
4050
+ return { ok: true, message: "" };
4051
+ }
4052
+ function saveAnswerToFile(input, value) {
4053
+ const fallback = `noir-run-${stamp2()}.md`;
4054
+ const chosen = (value ?? "").trim();
4055
+ const target = resolve(chosen.length > 0 ? chosen : fallback);
4056
+ const body = input.answer.endsWith("\n") ? input.answer : `${input.answer}
4057
+ `;
4058
+ try {
4059
+ writeFileSync3(target, body, { mode: 384 });
4060
+ } catch (err) {
4061
+ return {
4062
+ ok: false,
4063
+ tone: "caution",
4064
+ message: `could not write ${target}: ${err instanceof Error ? err.message : String(err)}`
4065
+ };
4066
+ }
4067
+ return { ok: true, message: `Answer written to ${target}` };
4068
+ }
4069
+ function stamp2() {
4070
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4071
+ }
4072
+
4073
+ // src/run-interrupt.ts
4074
+ import { constants as constants2 } from "os";
4075
+ var INTERRUPT_SIGNALS = ["SIGINT", "SIGTERM"];
4076
+ function exitCodeForSignal(signal) {
4077
+ return 128 + (constants2.signals[signal] ?? 0);
4078
+ }
4079
+ function interruptedNotice(transcript) {
4080
+ return `interrupted \xB7 transcript: ${transcript}`;
4081
+ }
4082
+ var RunInterrupt = class {
4083
+ graceMs;
4084
+ exitNow;
4085
+ controller = new AbortController();
4086
+ /** The live child, or undefined between spawns. */
4087
+ child;
4088
+ /** The child the ladder already ran for — a repeat call is not a new ladder. */
4089
+ stopping;
4090
+ grace;
4091
+ signalName;
4092
+ stopRequested = false;
4093
+ forced = false;
4094
+ watching = false;
4095
+ /** The installed listeners, keyed by the signal each answers. */
4096
+ handlers = /* @__PURE__ */ new Map();
4097
+ constructor(opts = {}) {
4098
+ this.graceMs = opts.graceMs ?? INTERRUPT_GRACE_MS;
4099
+ this.exitNow = opts.exitNow ?? ((code) => {
4100
+ if (this.child !== void 0) signalChild(this.child, "SIGKILL");
4101
+ process.exit(code);
4102
+ });
4103
+ }
4104
+ /**
4105
+ * The cancel signal for the spawn. Aborting it is what a caller holding only
4106
+ * the signal can do; the ladder itself does not depend on it, which matters
4107
+ * because the child a run ends up driving is not always the first one spawned.
4108
+ */
4109
+ get signal() {
4110
+ return this.controller.signal;
4111
+ }
4112
+ /** The signal that stopped the run, or undefined when nothing did. */
4113
+ get interruptedBy() {
4114
+ return this.signalName;
4115
+ }
4116
+ /** True once a child had to be forced — it did not honour the polite signal. */
4117
+ get escalated() {
4118
+ return this.forced;
4119
+ }
4120
+ /**
4121
+ * Hand over the child a spawn produced. Called for every spawn of one run: a
4122
+ * command resolved through the shell bridge is its own child, and a stop that
4123
+ * arrived before that child existed has to land on it too.
4124
+ */
4125
+ track(child) {
4126
+ this.child = child;
4127
+ child.once("exit", () => this.forget(child));
4128
+ if (this.stopRequested) this.terminate();
4129
+ }
4130
+ /**
4131
+ * Stop the host. The polite signal goes to the child itself, and a child still
4132
+ * alive after the grace is forced. Repeat calls are the same call: the ladder
4133
+ * runs once per child, so a keystroke held down is not a stream of signals.
4134
+ */
4135
+ terminate() {
4136
+ this.stopRequested = true;
4137
+ if (!this.controller.signal.aborted) this.controller.abort();
4138
+ const child = this.child;
4139
+ if (child === void 0 || child.pid === void 0 || child === this.stopping) return;
4140
+ this.stopping = child;
4141
+ this.clearGrace();
4142
+ signalChild(child, "SIGTERM");
4143
+ this.grace = setTimeout(() => {
4144
+ this.grace = void 0;
4145
+ if (this.child !== child) return;
4146
+ this.forced = true;
4147
+ signalChild(child, "SIGKILL");
4148
+ }, this.graceMs);
4149
+ }
4150
+ /**
4151
+ * Skip the grace and force the host now. The "you asked twice" path: the
4152
+ * terminal leaves at once on a second signal, and the run screen forces and
4153
+ * returns to the dashboard rather than holding a keystroke while the grace
4154
+ * runs out. Does nothing when no stop is in flight.
4155
+ */
4156
+ forceNow() {
4157
+ const child = this.child;
4158
+ if (child === void 0 || child.pid === void 0 || child !== this.stopping) return false;
4159
+ this.forced = true;
4160
+ this.clearGrace();
4161
+ signalChild(child, "SIGKILL");
4162
+ return true;
4163
+ }
4164
+ /**
4165
+ * Install the terminal interrupt handlers. Only a run installs these, and only
4166
+ * for as long as it is driving the host: the process's own handling has to
4167
+ * come back afterwards, or a later command inherits an interrupt contract with
4168
+ * nothing left to interrupt.
4169
+ */
4170
+ watch() {
4171
+ if (this.watching) return;
4172
+ this.watching = true;
4173
+ for (const signal of INTERRUPT_SIGNALS) {
4174
+ const listener = () => this.onSignal(signal);
4175
+ this.handlers.set(signal, listener);
4176
+ process.on(signal, listener);
4177
+ }
4178
+ }
4179
+ /** Put the process's own handling back, whatever the run left behind. */
4180
+ unwatch() {
4181
+ if (!this.watching) return;
4182
+ this.watching = false;
4183
+ for (const [signal, listener] of this.handlers) process.off(signal, listener);
4184
+ this.handlers.clear();
4185
+ }
4186
+ onSignal(signal) {
4187
+ if (this.signalName !== void 0) {
4188
+ this.exitNow(exitCodeForSignal(signal));
4189
+ return;
4190
+ }
4191
+ this.signalName = signal;
4192
+ this.terminate();
4193
+ }
4194
+ /** The child is gone: stop tracking it and disarm its ladder. */
4195
+ forget(child) {
4196
+ if (this.child !== child) return;
4197
+ this.child = void 0;
4198
+ this.stopping = void 0;
4199
+ this.clearGrace();
4200
+ }
4201
+ clearGrace() {
4202
+ if (this.grace === void 0) return;
4203
+ clearTimeout(this.grace);
4204
+ this.grace = void 0;
4205
+ }
4206
+ };
4207
+
3731
4208
  // src/run-profiles.ts
3732
- import { loadProjectInfo as loadProjectInfo9 } from "@noir-ai/core";
4209
+ import { isDeniedEnvKey, loadProjectInfo as loadProjectInfo9 } from "@noir-ai/core";
3733
4210
  function loadRunConfig(root) {
3734
4211
  try {
3735
4212
  return loadProjectInfo9(root).config;
@@ -3764,6 +4241,14 @@ function resolveRunProfile(requested, config, env2) {
3764
4241
  envExpanded[k] = v === null ? void 0 : expandEnvVars(v, env2);
3765
4242
  }
3766
4243
  }
4244
+ for (const key of Object.keys(envExpanded)) {
4245
+ if (isDeniedEnvKey(key)) {
4246
+ return {
4247
+ ok: false,
4248
+ 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.`
4249
+ };
4250
+ }
4251
+ }
3767
4252
  return {
3768
4253
  ok: true,
3769
4254
  profile: { binary: profile.binary, env: envExpanded, args: profile.args, profileName: name }
@@ -3779,6 +4264,183 @@ function listProfiles(config) {
3779
4264
  }));
3780
4265
  }
3781
4266
 
4267
+ // src/run-status.ts
4268
+ var HOST_STDERR_TAIL_LINES = 20;
4269
+ function humanizeElapsed(ms) {
4270
+ const total = Math.max(0, Math.floor(ms / 1e3));
4271
+ const seconds = total % 60;
4272
+ const minutes = Math.floor(total / 60) % 60;
4273
+ const hours = Math.floor(total / 3600);
4274
+ if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
4275
+ if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
4276
+ return `${seconds}s`;
4277
+ }
4278
+ var RunStatusLine = class {
4279
+ host;
4280
+ enabled;
4281
+ animated;
4282
+ sharesCursor;
4283
+ write;
4284
+ now;
4285
+ /**
4286
+ * Running token totals for the live line. This is a SECOND accumulator: the
4287
+ * snapshot the run reports at the end is reduced independently from the full
4288
+ * event stream, and both apply the same `max usage per message id` rule, so
4289
+ * the number on screen and the number in the summary agree.
4290
+ */
4291
+ usage = new UsageReducer();
4292
+ startedAt;
4293
+ model;
4294
+ streaming = false;
4295
+ begun = false;
4296
+ ended = false;
4297
+ /** True while the status text is sitting on the row the cursor is currently at. */
4298
+ onRow = false;
4299
+ /** True when the last stdout write ended a line — the only safe moment to move the cursor. */
4300
+ stdoutAtRowStart = true;
4301
+ /** The text currently on the row (empty when the row is not ours). */
4302
+ rendered = "";
4303
+ constructor(opts) {
4304
+ this.host = opts.host;
4305
+ this.enabled = opts.json !== true && opts.quiet !== true;
4306
+ this.animated = this.enabled && opts.stderrIsTty === true;
4307
+ this.sharesCursor = opts.stdoutSharesCursor !== false;
4308
+ this.write = opts.write ?? ((chunk) => void process.stderr.write(chunk));
4309
+ this.now = opts.now ?? (() => performance.now());
4310
+ this.startedAt = this.now();
4311
+ }
4312
+ /**
4313
+ * Claim the row: show the seeded "waiting" state immediately, before the host
4314
+ * has said anything, so the spawn itself is never blank. Calling this twice
4315
+ * is a no-op.
4316
+ */
4317
+ begin() {
4318
+ if (!this.enabled || this.begun) return;
4319
+ this.begun = true;
4320
+ this.startedAt = this.now();
4321
+ if (this.animated) {
4322
+ this.draw();
4323
+ return;
4324
+ }
4325
+ this.write(`${this.render()}
4326
+ `);
4327
+ }
4328
+ /** Feed one normalized host event. Anything unrenderable is ignored. */
4329
+ event(e) {
4330
+ if (!this.enabled || this.ended || !this.begun) return;
4331
+ if (e.kind === "result") {
4332
+ this.end();
4333
+ return;
4334
+ }
4335
+ if (e.kind === "init") {
4336
+ if (e.model !== void 0) this.model = e.model;
4337
+ } else if (e.kind === "assistant" || e.kind === "tool") {
4338
+ this.streaming = true;
4339
+ this.usage.add(e);
4340
+ } else if (e.kind === "delta") {
4341
+ this.streaming = true;
4342
+ } else {
4343
+ return;
4344
+ }
4345
+ this.draw();
4346
+ }
4347
+ /**
4348
+ * Announce the chunk the caller is about to write to STDOUT — before writing
4349
+ * it. Warning the line first lets it finish its row: the answer would
4350
+ * otherwise start glued to the tail of the status text. It also records
4351
+ * whether that write ended a line, which is the only moment the cursor can be
4352
+ * moved without landing inside the answer.
4353
+ */
4354
+ beforeStdout(chunk) {
4355
+ if (chunk.length === 0) return;
4356
+ if (this.animated && this.sharesCursor && this.onRow) {
4357
+ this.write("\n");
4358
+ this.onRow = false;
4359
+ }
4360
+ this.stdoutAtRowStart = chunk.endsWith("\n");
4361
+ }
4362
+ /**
4363
+ * Finish the row the host's answer left open. A failure message printed onto
4364
+ * a half-written answer reads as part of the answer, so a caller about to
4365
+ * report a failure asks for the row to be terminated first. A no-op unless
4366
+ * this line is drawing on a terminal stdout also writes to and the answer
4367
+ * stopped mid-line — the only state where the cursor is inside the answer.
4368
+ */
4369
+ finishRow() {
4370
+ if (!this.animated || !this.sharesCursor || this.stdoutAtRowStart) return;
4371
+ this.write("\n");
4372
+ this.stdoutAtRowStart = true;
4373
+ }
4374
+ /**
4375
+ * Close the line out. On a terminal the line is erased so the run summary
4376
+ * takes its place — unless the answer has since moved the cursor off it, in
4377
+ * which case the text stays as the record of how long the wait was. On a pipe
4378
+ * it becomes the final marker. Safe to call twice.
4379
+ */
4380
+ end() {
4381
+ if (!this.enabled || this.ended) return;
4382
+ this.ended = true;
4383
+ if (!this.animated) {
4384
+ this.write(`${this.render(true)}
4385
+ `);
4386
+ return;
4387
+ }
4388
+ if (!this.onRow || !this.cursorFree()) return;
4389
+ this.write("\r\x1B[K");
4390
+ this.rendered = "";
4391
+ this.onRow = false;
4392
+ }
4393
+ /** Rewrite the current row in place, skipping no-op rewrites and unsafe moments. */
4394
+ draw() {
4395
+ if (!this.animated || !this.cursorFree()) return;
4396
+ const text = this.render();
4397
+ if (text === this.rendered) return;
4398
+ this.write(`\r\x1B[K${text}`);
4399
+ this.rendered = text;
4400
+ this.onRow = true;
4401
+ }
4402
+ /**
4403
+ * Whether the cursor is at the start of a row nobody else is using. On stdout
4404
+ * that is not this terminal there is no shared cursor to disturb; when it is,
4405
+ * only a write that ended a line leaves one free.
4406
+ */
4407
+ cursorFree() {
4408
+ return !this.sharesCursor || this.stdoutAtRowStart;
4409
+ }
4410
+ /**
4411
+ * `final` marks the line the run ends on: a run that never got past the spawn
4412
+ * is over, so saying it is still waiting — directly above whatever the run
4413
+ * reported — would be a lie.
4414
+ */
4415
+ render(final = false) {
4416
+ const parts = [];
4417
+ if (this.streaming) {
4418
+ const u = this.usage.snapshot();
4419
+ parts.push(
4420
+ this.model ?? this.host,
4421
+ this.elapsed(),
4422
+ `\u2193${u.inputTokens.toLocaleString()}\u2191${u.outputTokens.toLocaleString()} tokens`
4423
+ );
4424
+ return `\u25CF ${parts.join(" \xB7 ")}`;
4425
+ }
4426
+ if (this.model !== void 0) return `\u25B6 ${[this.host, this.model, this.elapsed()].join(" \xB7 ")}`;
4427
+ if (final) return `\u25B6 ${this.host} \xB7 no output after ${this.elapsed()}`;
4428
+ return `\u25B6 ${this.host} \xB7 waiting for first event\u2026`;
4429
+ }
4430
+ elapsed() {
4431
+ return humanizeElapsed(this.now() - this.startedAt);
4432
+ }
4433
+ };
4434
+ function hostStderrTail(stderr, maxLines = HOST_STDERR_TAIL_LINES) {
4435
+ if (maxLines <= 0) return "";
4436
+ const lines = stderr.split("\n").map((line) => line.trimEnd());
4437
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
4438
+ if (lines.length === 0) return "";
4439
+ if (lines.length <= maxLines) return lines.join("\n");
4440
+ const omitted = lines.length - maxLines;
4441
+ return [`\u2026 (${omitted} earlier lines omitted)`, ...lines.slice(-maxLines)].join("\n");
4442
+ }
4443
+
3782
4444
  // src/commands/run.ts
3783
4445
  var CREDENTIAL_ENV_VARS = [
3784
4446
  "ANTHROPIC_API_KEY",
@@ -3804,13 +4466,24 @@ function formatUsage(u) {
3804
4466
  const turns = u.numTurns > 0 ? ` \xB7 ${u.numTurns} turns` : "";
3805
4467
  return `${u.inputTokens.toLocaleString()} in / ${u.outputTokens.toLocaleString()} out${cost}${turns}`;
3806
4468
  }
3807
- function streamEvent(event, opts) {
3808
- if (opts.json === true) return;
4469
+ function answerText(event) {
3809
4470
  if (event.kind === "assistant" && event.text && event.text.length > 0 && event.isError !== true) {
3810
- process.stdout.write(event.text);
4471
+ return event.text;
4472
+ }
4473
+ return void 0;
4474
+ }
4475
+ function streamEvent(event, opts, status2) {
4476
+ if (opts.json === true) return;
4477
+ const text = answerText(event);
4478
+ if (text !== void 0) {
4479
+ status2.beforeStdout(text);
4480
+ process.stdout.write(text);
3811
4481
  }
3812
4482
  }
3813
4483
  async function run(prompt, opts) {
4484
+ await runOnce(prompt, opts, void 0);
4485
+ }
4486
+ async function runOnce(prompt, opts, resumeSessionId) {
3814
4487
  const host = opts.host ?? "claude";
3815
4488
  if (!SUPPORTED_HOSTS.includes(host)) {
3816
4489
  fail(EXIT.USAGE, `unknown host '${host}' (supported: ${SUPPORTED_HOSTS.join(", ")})`, opts);
@@ -3829,7 +4502,7 @@ async function run(prompt, opts) {
3829
4502
  if (!resolved.ok) fail(EXIT.USAGE, resolved.message, opts);
3830
4503
  const profile = resolved.profile;
3831
4504
  const customBinary = opts.command ?? profile.binary;
3832
- const extraArgs = profile.args;
4505
+ const extraArgs = resumeSessionId === void 0 ? profile.args : [...profile.args ?? [], "--resume", resumeSessionId];
3833
4506
  const env2 = profile.env ? mergeEnv(process.env, profile.env) : void 0;
3834
4507
  if (!existsSync3(join4(root, NOIR_DIR2, ".env"))) {
3835
4508
  log(
@@ -3838,6 +4511,37 @@ async function run(prompt, opts) {
3838
4511
  );
3839
4512
  }
3840
4513
  const transcriptLines = [];
4514
+ const answer = [];
4515
+ let sessionId;
4516
+ const binary = customBinary && customBinary.length > 0 ? customBinary : host;
4517
+ const status2 = new RunStatusLine({
4518
+ host: binary,
4519
+ json: opts.json,
4520
+ quiet: opts.quiet,
4521
+ stderrIsTty: process.stderr.isTTY === true,
4522
+ stdoutSharesCursor: process.stdout.isTTY === true
4523
+ });
4524
+ status2.begin();
4525
+ let verdictWritten = false;
4526
+ const claimVerdict = () => {
4527
+ if (verdictWritten) return false;
4528
+ verdictWritten = true;
4529
+ return true;
4530
+ };
4531
+ const interrupt = new RunInterrupt({
4532
+ // A second interrupt leaves at once, and leaves the same verdict behind as
4533
+ // the first: the run owes its consumer an envelope under --json, and this is
4534
+ // the one path where `fail()` cannot write it (there is no caller left to
4535
+ // catch its throw). The child is forced first, so leaving does not leave a
4536
+ // host behind.
4537
+ exitNow: (code) => {
4538
+ interrupt.forceNow();
4539
+ if (claimVerdict()) {
4540
+ failAndExit(code, interruptedNotice(safeTranscript(host, transcriptLines)), opts);
4541
+ }
4542
+ }
4543
+ });
4544
+ interrupt.watch();
3841
4545
  let result;
3842
4546
  try {
3843
4547
  result = await runHost({
@@ -3847,28 +4551,61 @@ async function run(prompt, opts) {
3847
4551
  extraArgs,
3848
4552
  env: env2,
3849
4553
  onLine: (line) => transcriptLines.push(line),
3850
- onEvent: (event) => streamEvent(event, opts)
4554
+ onEvent: (event) => {
4555
+ if (event.kind === "init" && sessionId === void 0) sessionId = event.sessionId;
4556
+ status2.event(event);
4557
+ streamEvent(event, opts, status2);
4558
+ const text = answerText(event);
4559
+ if (text !== void 0) answer.push(text);
4560
+ },
4561
+ signal: interrupt.signal,
4562
+ onChild: (child) => interrupt.track(child)
3851
4563
  });
3852
4564
  } catch (err) {
3853
- const binary = customBinary && customBinary.length > 0 ? customBinary : host;
4565
+ status2.end();
4566
+ status2.finishRow();
4567
+ if (interrupt.interruptedBy !== void 0) {
4568
+ if (claimVerdict()) {
4569
+ failInterrupted(interrupt.interruptedBy, host, transcriptLines, opts);
4570
+ }
4571
+ return;
4572
+ }
3854
4573
  const detail = err instanceof Error ? err.message : String(err);
3855
4574
  const enoent = err?.code === "ENOENT";
3856
4575
  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}.` : "";
3857
4576
  const subject = customBinary && customBinary.length > 0 ? `custom command '${customBinary}'` : `host '${host}'`;
3858
4577
  fail(EXIT.ERROR, `failed to run ${subject}: ${detail}.${guidance}`, opts);
4578
+ } finally {
4579
+ interrupt.unwatch();
4580
+ }
4581
+ if (interrupt.interruptedBy !== void 0) {
4582
+ status2.end();
4583
+ status2.finishRow();
4584
+ if (claimVerdict()) {
4585
+ failInterrupted(interrupt.interruptedBy, host, transcriptLines, opts);
4586
+ }
4587
+ return;
3859
4588
  }
3860
- const transcript = writeTranscript(host, transcriptLines);
4589
+ const transcript = safeTranscript(host, transcriptLines);
3861
4590
  const failed = result.exitCode !== 0 || result.isError;
3862
4591
  if (failed) {
3863
- const binary = customBinary && customBinary.length > 0 ? customBinary : host;
3864
- const reason = result.errorText && result.errorText.trim().length > 0 ? result.errorText.trim() : result.stderr.trim() || `exit code ${result.exitCode}`;
3865
- const isAuth = result.errorCategory === "authentication_failed" || result.errorCategory === "oauth_org_not_allowed" || /not logged|login|authenticate|invalid api key/i.test(reason);
4592
+ status2.end();
4593
+ status2.finishRow();
4594
+ const stderrTail = hostStderrTail(result.stderr);
4595
+ const reason = result.errorText && result.errorText.trim().length > 0 ? result.errorText.trim() : `exit code ${result.exitCode}`;
4596
+ const isAuth = result.errorCategory === "authentication_failed" || result.errorCategory === "oauth_org_not_allowed" || /not logged|login|authenticate|invalid api key/i.test(`${reason}
4597
+ ${stderrTail}`);
3866
4598
  let message = `host '${binary}' failed (exit ${result.exitCode}): ${reason}`;
3867
4599
  if (isAuth) {
3868
4600
  message += ` Open a terminal and run \`${binary} /login\` (interactive-only \u2014 it cannot run inside \`noir run\`), then retry.`;
3869
4601
  message += credentialNote(env2 ?? process.env, envSources);
3870
4602
  }
3871
4603
  message += ` If you use another profile, pass \`--command <binary>\` or define a run profile under run.profiles. transcript: ${transcript}`;
4604
+ if (opts.json !== true && stderrTail.length > 0) {
4605
+ message += `
4606
+ ${binary} stderr (last ${HOST_STDERR_TAIL_LINES} lines):
4607
+ ${stderrTail}`;
4608
+ }
3872
4609
  fail(EXIT.ERROR, message, opts);
3873
4610
  }
3874
4611
  if (opts.json === true) {
@@ -3881,26 +4618,59 @@ async function run(prompt, opts) {
3881
4618
  usage: result.usage,
3882
4619
  numTurns: result.usage.numTurns,
3883
4620
  events: result.eventCount,
3884
- transcript
4621
+ transcript,
4622
+ // The answer as plain words, so a consumer does not have to reassemble
4623
+ // it from the stream-json transcript.
4624
+ answerText: answer.join(""),
4625
+ ...sessionId === void 0 ? {} : { sessionId }
3885
4626
  }
3886
4627
  });
3887
4628
  return;
3888
4629
  }
4630
+ status2.end();
3889
4631
  process.stdout.write("\n");
3890
4632
  success(`usage: ${formatUsage(result.usage)} (API-equivalent estimate, not billed)`, opts);
3891
4633
  log(`transcript: ${transcript}`, opts);
4634
+ await offerPostRunActions({
4635
+ answer: answer.join(""),
4636
+ transcript,
4637
+ sessionId,
4638
+ host: binary,
4639
+ opts,
4640
+ // Only the first run of a session can be continued: a resumed run is
4641
+ // itself the continuation, so the menu never nests more than one deep.
4642
+ ...resumeSessionId === void 0 && sessionId !== void 0 ? { resume: (nextPrompt, id) => runOnce(nextPrompt, opts, id) } : {}
4643
+ });
3892
4644
  }
3893
4645
  function mergeEnv(base, overlay) {
4646
+ for (const key of Object.keys(overlay)) {
4647
+ if (isDeniedEnvKey2(key)) {
4648
+ fail(
4649
+ EXIT.ERROR,
4650
+ `refusing to run the host: the profile env defines process-injection key "${key}" (it can inject into the spawned host process)`
4651
+ );
4652
+ }
4653
+ }
3894
4654
  const merged = { ...base, ...overlay };
3895
4655
  return Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== void 0));
3896
4656
  }
3897
- function writeTranscript(host, lines) {
4657
+ function failInterrupted(signal, host, lines, opts) {
4658
+ return fail(exitCodeForSignal(signal), interruptedNotice(safeTranscript(host, lines)), opts);
4659
+ }
4660
+ function safeTranscript(host, lines) {
4661
+ try {
4662
+ return writeTranscript(host, lines);
4663
+ } catch {
4664
+ return "(not persisted)";
4665
+ }
4666
+ }
4667
+ function writeTranscript(host, lines, root = process.cwd()) {
3898
4668
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3899
- const dir = join4(process.cwd(), ".noir", "transcripts");
4669
+ const dir = paths2.transcriptsDir(root);
3900
4670
  const file = join4(dir, `${host}-${ts}.jsonl`);
3901
4671
  try {
3902
4672
  mkdirSync2(dir, { recursive: true, mode: 448 });
3903
- writeFileSync3(file, `${lines.join("\n")}${lines.length > 0 ? "\n" : ""}`, { mode: 384 });
4673
+ writeFileSync4(file, `${lines.join("\n")}${lines.length > 0 ? "\n" : ""}`, { mode: 384 });
3904
4674
  } catch {
3905
4675
  return "(not persisted)";
3906
4676
  }
@@ -3908,252 +4678,35 @@ function writeTranscript(host, lines) {
3908
4678
  }
3909
4679
 
3910
4680
  // src/commands/skills.ts
3911
- import { resolveAdapter as resolveAdapter4 } from "@noir-ai/adapters";
3912
- import { loadProjectInfo as loadProjectInfo10 } from "@noir-ai/core";
4681
+ import { resolveAdapter as resolveAdapter5 } from "@noir-ai/adapters";
4682
+ import { loadProjectInfo as loadProjectInfo11 } from "@noir-ai/core";
3913
4683
  import {
3914
4684
  buildRegistry,
3915
4685
  discoverAll,
3916
- emitSkillsToDir,
4686
+ emitSkillsToDir as emitSkillsToDir2,
3917
4687
  lintSkill
3918
4688
  } from "@noir-ai/skills";
3919
- var CATEGORY = {
3920
- "noir-brainstorming": "discovery",
3921
- "noir-sync": "discovery",
3922
- "noir-checkpoint": "discovery",
3923
- "noir-exploring": "discovery",
3924
- "noir-spec": "spec",
3925
- "noir-planning": "plan",
3926
- "noir-prd": "plan",
3927
- "noir-executing-plans": "execute",
3928
- "noir-test-driven-development": "execute",
3929
- "noir-systematic-debugging": "execute",
3930
- "noir-subagent": "execute",
3931
- "noir-parallel": "execute",
3932
- "noir-verifying": "verify",
3933
- "noir-security": "verify",
3934
- "noir-wrap": "document",
3935
- "noir-readme": "document",
3936
- "noir-shipping": "git",
3937
- "noir-worktree": "git",
3938
- "noir-recall": "memory",
3939
- "noir-remember": "memory",
3940
- "noir-context": "context",
3941
- "noir-frontend": "domain",
3942
- "noir-backend": "domain",
3943
- "noir-doctor": "meta",
3944
- "noir-writing-skills": "meta",
3945
- "noir-rules": "meta"
3946
- };
3947
- function categoryOf(name) {
3948
- const mapped = CATEGORY[name];
3949
- if (mapped !== void 0) return mapped;
3950
- return name.replace(/^noir-/, "") || "general";
3951
- }
3952
- function toRow(s, kind = "builtin") {
3953
- const description = typeof s.frontmatter.description === "string" ? s.frontmatter.description : "";
3954
- const status2 = s.skillMd.includes("> **Stub:**") ? "stub" : "full";
3955
- const category = s.frontmatter.metadata?.category?.trim() || categoryOf(s.name);
3956
- return { name: s.name, category, description, kind, status: status2 };
3957
- }
3958
- function truncate(text, max) {
3959
- if (text.length <= max) return text;
3960
- const slice = text.slice(0, Math.max(0, max - 1));
3961
- const boundary = slice.lastIndexOf(" ");
3962
- const head = boundary > 0 ? slice.slice(0, boundary) : slice;
3963
- return `${head.trimEnd()}\u2026`;
4689
+
4690
+ // src/init.ts
4691
+ import { existsSync as existsSync4 } from "fs";
4692
+ import { resolveAdapter as resolveAdapter4 } from "@noir-ai/adapters";
4693
+ import { loadProjectInfo as loadProjectInfo10, paths as paths3 } from "@noir-ai/core";
4694
+ import { scaffold } from "@noir-ai/create";
4695
+ import {
4696
+ emitSkillsToDir
4697
+ } from "@noir-ai/skills";
4698
+
4699
+ // src/conflict.ts
4700
+ import {
4701
+ lineDiff
4702
+ } from "@noir-ai/create";
4703
+ function flaggedNonInteractive() {
4704
+ const v = process.env.NOIR_NON_INTERACTIVE;
4705
+ return v !== void 0 && v !== "";
3964
4706
  }
3965
- async function skillsList(opts) {
3966
- let builtins;
3967
- let integrations;
3968
- try {
3969
- const all = discoverAll();
3970
- builtins = all.builtins;
3971
- integrations = all.integrations;
3972
- } catch (err) {
3973
- fail(
3974
- EXIT.ERROR,
3975
- `skills list: could not discover skills (${err instanceof Error ? err.message : String(err)})`,
3976
- opts
3977
- );
3978
- }
3979
- const rows = [
3980
- ...builtins.map((b) => toRow(b, "builtin")),
3981
- ...integrations.map((i) => toRow(i, "integration"))
3982
- ];
3983
- const data = { count: rows.length, skills: rows };
3984
- if (opts.json === true) {
3985
- process.stdout.write(`${JSON.stringify({ ok: true, data })}
3986
- `);
3987
- return;
3988
- }
3989
- const intCount = integrations.length;
3990
- 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"}`;
3991
- log(banner, opts);
3992
- table(
3993
- rows.map((r) => ({
3994
- Skill: r.name,
3995
- Kind: r.kind,
3996
- Category: r.category,
3997
- Status: r.status,
3998
- Description: truncate(r.description, 80)
3999
- })),
4000
- ["Skill", "Kind", "Category", "Status", "Description"],
4001
- opts
4002
- );
4003
- }
4004
- async function skillsSync(opts) {
4005
- const root = process.cwd();
4006
- let project;
4007
- try {
4008
- project = loadProjectInfo10(root);
4009
- } catch {
4010
- fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
4011
- }
4012
- const adapter = resolveAdapter4(project.config.host);
4013
- if (!adapter.skillsDir) {
4014
- const data2 = {
4015
- emitted: [],
4016
- references: 0,
4017
- host: project.config.host,
4018
- reason: "no-skill-emitter"
4019
- };
4020
- if (opts.json === true) {
4021
- process.stdout.write(`${JSON.stringify({ ok: true, data: data2 })}
4022
- `);
4023
- return;
4024
- }
4025
- info("This host has no skill emitter; nothing to sync.", opts);
4026
- return;
4027
- }
4028
- const dir = adapter.skillsDir({ root });
4029
- const target = project.config.host;
4030
- const summary = await emitSkillsToDir(dir, { target });
4031
- const pruned = summary.pruned ?? [];
4032
- const data = {
4033
- emitted: summary.emitted,
4034
- references: summary.references,
4035
- dir,
4036
- pruned
4037
- };
4038
- if (opts.json === true) {
4039
- process.stdout.write(`${JSON.stringify({ ok: true, data })}
4040
- `);
4041
- return;
4042
- }
4043
- log(
4044
- `Synced ${summary.emitted.length} Noir skill${summary.emitted.length === 1 ? "" : "s"} to ${dir}.`,
4045
- opts
4046
- );
4047
- if (pruned.length > 0) {
4048
- warn(
4049
- `Pruned ${pruned.length} stale noir-* skill dir${pruned.length === 1 ? "" : "s"}: ${pruned.join(", ")}`,
4050
- opts
4051
- );
4052
- }
4053
- }
4054
- async function skillsLint(opts) {
4055
- let skills;
4056
- try {
4057
- const all = discoverAll();
4058
- skills = [...all.builtins, ...all.integrations].map(lintSkill);
4059
- } catch (err) {
4060
- fail(
4061
- EXIT.ERROR,
4062
- `skills lint: could not lint skills (${err instanceof Error ? err.message : String(err)})`,
4063
- opts
4064
- );
4065
- }
4066
- const errored = skills.filter((s) => s.errors.length > 0);
4067
- const warned = skills.filter((s) => s.warnings.length > 0 && s.errors.length === 0);
4068
- const data = { count: skills.length, errored: errored.length, skills };
4069
- if (errored.length > 0) {
4070
- const message = `skills lint: ${errored.length} skill${errored.length === 1 ? "" : "s"} failed validation`;
4071
- if (opts.json === true) {
4072
- process.stdout.write(
4073
- `${JSON.stringify({ ok: false, error: { code: EXIT.ERROR, message }, data })}
4074
- `
4075
- );
4076
- process.exitCode = EXIT.ERROR;
4077
- return;
4078
- }
4079
- warn(
4080
- `noir skills lint \u2014 ${errored.length} skill${errored.length === 1 ? "" : "s"} FAIL validation:`,
4081
- opts
4082
- );
4083
- for (const s of errored) {
4084
- for (const e of s.errors) warn(` ${s.name}: ${e}`, opts);
4085
- }
4086
- if (warned.length > 0) {
4087
- info(` ${warned.length} skill${warned.length === 1 ? "" : "s"} carry lint warnings:`, opts);
4088
- for (const s of warned) {
4089
- for (const w of s.warnings) info(` ${s.name}: ${w}`, opts);
4090
- }
4091
- }
4092
- fail(EXIT.ERROR, message, opts);
4093
- }
4094
- if (opts.json === true) {
4095
- process.stdout.write(`${JSON.stringify({ ok: true, data })}
4096
- `);
4097
- return;
4098
- }
4099
- log(`noir skills lint \u2014 ${skills.length} skills, all validate clean.`, opts);
4100
- if (warned.length > 0) {
4101
- info(` ${warned.length} skill${warned.length === 1 ? "" : "s"} carry lint warnings:`, opts);
4102
- for (const s of warned) {
4103
- for (const w of s.warnings) info(` ${s.name}: ${w}`, opts);
4104
- }
4105
- }
4106
- }
4107
- async function skillsRegistry(opts) {
4108
- let reg;
4109
- try {
4110
- reg = buildRegistry();
4111
- } catch (err) {
4112
- fail(
4113
- EXIT.ERROR,
4114
- `skills registry: could not build registry (${err instanceof Error ? err.message : String(err)})`,
4115
- opts
4116
- );
4117
- }
4118
- const data = { count: reg.length, skills: reg };
4119
- if (opts.json === true) {
4120
- process.stdout.write(`${JSON.stringify({ ok: true, data })}
4121
- `);
4122
- return;
4123
- }
4124
- log(`noir skills registry \u2014 ${reg.length} skills.`, opts);
4125
- table(
4126
- reg.map((r) => ({
4127
- Skill: r.name,
4128
- Kind: r.kind,
4129
- Category: r.category,
4130
- Version: r.version,
4131
- Status: r.status,
4132
- Refs: String(r.referenceCount),
4133
- Lines: String(r.lines)
4134
- })),
4135
- ["Skill", "Kind", "Category", "Version", "Status", "Refs", "Lines"],
4136
- opts
4137
- );
4138
- }
4139
-
4140
- // src/init.ts
4141
- import { resolveAdapter as resolveAdapter5 } from "@noir-ai/adapters";
4142
- import { loadProjectInfo as loadProjectInfo11 } from "@noir-ai/core";
4143
- import { scaffold } from "@noir-ai/create";
4144
- import { emitSkillsToDir as emitSkillsToDir2 } from "@noir-ai/skills";
4145
-
4146
- // src/conflict.ts
4147
- import {
4148
- lineDiff
4149
- } from "@noir-ai/create";
4150
- function flaggedNonInteractive() {
4151
- const v = process.env.NOIR_NON_INTERACTIVE;
4152
- return v !== void 0 && v !== "";
4153
- }
4154
- function buildConflictOpts(input = {}) {
4155
- if (input.force === true) {
4156
- return { conflictPolicy: "overwrite" };
4707
+ function buildConflictOpts(input = {}) {
4708
+ if (input.force === true) {
4709
+ return { conflictPolicy: "overwrite" };
4157
4710
  }
4158
4711
  if (input.interactive === false) {
4159
4712
  return { conflictPolicy: "preserve" };
@@ -4245,7 +4798,7 @@ function labelFor(resolution) {
4245
4798
  }
4246
4799
 
4247
4800
  // src/dedup-write.ts
4248
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync4 } from "fs";
4801
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync5 } from "fs";
4249
4802
  import { join as join5 } from "path";
4250
4803
  import { sha256Hex as sha256Hex2, sha256Hex12 } from "@noir-ai/core";
4251
4804
  var HOST_CONTEXT_RELS = /* @__PURE__ */ new Set(["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".noir/rules/RULES.md"]);
@@ -4399,14 +4952,14 @@ async function promptDedupAction(root, proposedRel, matchedRel, proposedText, si
4399
4952
  return "preserve";
4400
4953
  } else if (choice === "replace") {
4401
4954
  try {
4402
- writeFileSync4(join5(root, matchedRel), proposedText, "utf8");
4955
+ writeFileSync5(join5(root, matchedRel), proposedText, "utf8");
4403
4956
  } catch {
4404
4957
  }
4405
4958
  return "replace";
4406
4959
  } else if (choice === "mirror") {
4407
4960
  try {
4408
4961
  const existing = readFileSync4(join5(root, matchedRel), "utf8");
4409
- writeFileSync4(
4962
+ writeFileSync5(
4410
4963
  join5(root, matchedRel),
4411
4964
  `${existing}
4412
4965
 
@@ -4445,7 +4998,7 @@ function loadCache(root) {
4445
4998
  function saveCache(root, cache) {
4446
4999
  try {
4447
5000
  mkdirSync3(join5(root, ".noir"), { recursive: true });
4448
- writeFileSync4(cachePath(root), JSON.stringify(cache), "utf8");
5001
+ writeFileSync5(cachePath(root), JSON.stringify(cache), "utf8");
4449
5002
  } catch {
4450
5003
  }
4451
5004
  }
@@ -4467,16 +5020,16 @@ function safeRead(abs) {
4467
5020
  }
4468
5021
  }
4469
5022
  function withTimeout(p, ms) {
4470
- return new Promise((resolve) => {
4471
- const t = setTimeout(() => resolve(void 0), ms);
5023
+ return new Promise((resolve2) => {
5024
+ const t = setTimeout(() => resolve2(void 0), ms);
4472
5025
  p.then(
4473
5026
  (v) => {
4474
5027
  clearTimeout(t);
4475
- resolve(v);
5028
+ resolve2(v);
4476
5029
  },
4477
5030
  () => {
4478
5031
  clearTimeout(t);
4479
- resolve(void 0);
5032
+ resolve2(void 0);
4480
5033
  }
4481
5034
  );
4482
5035
  });
@@ -4488,7 +5041,7 @@ function errMsg(e) {
4488
5041
  // src/init.ts
4489
5042
  async function init(root, opts) {
4490
5043
  assertTransportUrl(opts);
4491
- const host = opts.host ?? "claude";
5044
+ const host = resolveInitHost(root, opts);
4492
5045
  const interactive = resolveInteractive();
4493
5046
  const conflictOpts = buildConflictOpts({ force: opts.force, interactive });
4494
5047
  const dryRun = opts.dryRun === true || opts.preview === true;
@@ -4509,15 +5062,29 @@ async function init(root, opts) {
4509
5062
  return res;
4510
5063
  }
4511
5064
  if (res.noop) return res;
4512
- await emitHostSkills(root, host, conflictOpts, interactive);
5065
+ const skillSummary = await emitHostSkills(root, host, conflictOpts, interactive);
5066
+ res.skillConflicts = skillSummary?.conflicts ?? [];
5067
+ res.preservedSkills = skillSummary?.preserved ?? [];
4513
5068
  let projectInfo;
4514
5069
  try {
4515
- projectInfo = loadProjectInfo11(root);
5070
+ projectInfo = loadProjectInfo10(root);
4516
5071
  } catch {
4517
5072
  projectInfo = void 0;
4518
5073
  }
4519
5074
  const dedup = await checkWritePathDedup(root, res, { interactive, project: projectInfo });
4520
5075
  if (dedup.conflicts.length > 0) res.conflicts.push(...dedup.conflicts);
5076
+ if (res.refreshed.length > 0) {
5077
+ process.stderr.write(
5078
+ `Refreshed ${res.refreshed.length} unedited doc seed(s): ${res.refreshed.join(", ")}
5079
+ `
5080
+ );
5081
+ }
5082
+ if (res.migrationChanged.length > 0) {
5083
+ process.stderr.write(
5084
+ `Migrated ${res.migrationChanged.length} file(s): ${res.migrationChanged.join(", ")}
5085
+ `
5086
+ );
5087
+ }
4521
5088
  process.stderr.write(
4522
5089
  `Noir initialized in ${root} (host: ${host}, transport: ${opts.transport}).
4523
5090
  `
@@ -4527,6 +5094,19 @@ async function init(root, opts) {
4527
5094
  );
4528
5095
  return res;
4529
5096
  }
5097
+ function resolveInitHost(root, opts) {
5098
+ if (opts.host !== void 0) return opts.host;
5099
+ if (!existsSync4(paths3.config(root))) return "claude";
5100
+ try {
5101
+ return loadProjectInfo10(root).config.host;
5102
+ } catch {
5103
+ process.stderr.write(
5104
+ `Could not read the configured host from ${paths3.config(root)}; using 'claude'.
5105
+ `
5106
+ );
5107
+ return "claude";
5108
+ }
5109
+ }
4530
5110
  function reportPlannedWrites(res) {
4531
5111
  log("Dry run \u2014 no files were written.");
4532
5112
  if (res.written.length > 0) {
@@ -4536,6 +5116,10 @@ function reportPlannedWrites(res) {
4536
5116
  log(mode === void 0 ? ` ${p}` : ` ${p} (mode ${mode.toString(8).padStart(3, "0")})`);
4537
5117
  }
4538
5118
  }
5119
+ if (res.refreshed.length > 0) {
5120
+ log("Would refresh (unedited older copy):");
5121
+ for (const p of res.refreshed) log(` ${p}`);
5122
+ }
4539
5123
  if (res.skipped.length > 0) {
4540
5124
  log("Would leave as-is (already present):");
4541
5125
  for (const p of res.skipped) log(` ${p}`);
@@ -4546,12 +5130,12 @@ function reportPlannedWrites(res) {
4546
5130
  }
4547
5131
  }
4548
5132
  async function emitHostSkills(root, host, conflictOpts, interactive) {
4549
- const adapter = resolveAdapter5(host);
5133
+ const adapter = resolveAdapter4(host);
4550
5134
  const skillsDir = adapter.skillsDir?.({ root });
4551
5135
  if (skillsDir === void 0) {
4552
5136
  process.stderr.write(`host '${host}' has no skill emitter; skipping skills
4553
5137
  `);
4554
- return;
5138
+ return void 0;
4555
5139
  }
4556
5140
  const target = host;
4557
5141
  const skillOpts = {
@@ -4563,12 +5147,20 @@ async function emitHostSkills(root, host, conflictOpts, interactive) {
4563
5147
  if (conflictOpts.onConflict !== void 0) {
4564
5148
  skillOpts.onConflict = conflictOpts.onConflict;
4565
5149
  }
4566
- const summary = await emitSkillsToDir2(skillsDir, skillOpts);
5150
+ const summary = await emitSkillsToDir(skillsDir, skillOpts);
4567
5151
  const relDir = skillsDir.replace(`${root}/`, "");
4568
5152
  process.stderr.write(
4569
5153
  `Emitted ${summary.emitted.length} Noir skills to ${relDir}/ (target: ${target}).
4570
5154
  `
4571
5155
  );
5156
+ const stale = preservedStaleLine(summary.preserved ?? []);
5157
+ if (stale !== void 0) process.stderr.write(`${stale}
5158
+ `);
5159
+ return summary;
5160
+ }
5161
+ function preservedStaleLine(preserved) {
5162
+ if (preserved.length === 0) return void 0;
5163
+ return `${preserved.length} skill(s) preserved as stale (interactive TTY required to refresh): ${preserved.join(", ")}`;
4572
5164
  }
4573
5165
  function assertTransportUrl(opts) {
4574
5166
  if (opts.transport === "streamable-http" && opts.url === void 0) {
@@ -4593,6 +5185,235 @@ function assertLocalhostUrl(raw) {
4593
5185
  }
4594
5186
  }
4595
5187
 
5188
+ // src/commands/skills.ts
5189
+ var CATEGORY = {
5190
+ "noir-brainstorming": "discovery",
5191
+ "noir-sync": "discovery",
5192
+ "noir-checkpoint": "discovery",
5193
+ "noir-exploring": "discovery",
5194
+ "noir-spec": "spec",
5195
+ "noir-planning": "plan",
5196
+ "noir-prd": "plan",
5197
+ "noir-executing-plans": "execute",
5198
+ "noir-test-driven-development": "execute",
5199
+ "noir-systematic-debugging": "execute",
5200
+ "noir-subagent": "execute",
5201
+ "noir-parallel": "execute",
5202
+ "noir-verifying": "verify",
5203
+ "noir-security": "verify",
5204
+ "noir-wrap": "document",
5205
+ "noir-readme": "document",
5206
+ "noir-shipping": "git",
5207
+ "noir-worktree": "git",
5208
+ "noir-recall": "memory",
5209
+ "noir-remember": "memory",
5210
+ "noir-context": "context",
5211
+ "noir-frontend": "domain",
5212
+ "noir-backend": "domain",
5213
+ "noir-doctor": "meta",
5214
+ "noir-writing-skills": "meta",
5215
+ "noir-rules": "meta"
5216
+ };
5217
+ function categoryOf(name) {
5218
+ const mapped = CATEGORY[name];
5219
+ if (mapped !== void 0) return mapped;
5220
+ return name.replace(/^noir-/, "") || "general";
5221
+ }
5222
+ function toRow(s, kind = "builtin") {
5223
+ const description = typeof s.frontmatter.description === "string" ? s.frontmatter.description : "";
5224
+ const status2 = s.skillMd.includes("> **Stub:**") ? "stub" : "full";
5225
+ const category = s.frontmatter.metadata?.category?.trim() || categoryOf(s.name);
5226
+ return { name: s.name, category, description, kind, status: status2 };
5227
+ }
5228
+ function truncate(text, max) {
5229
+ if (text.length <= max) return text;
5230
+ const slice = text.slice(0, Math.max(0, max - 1));
5231
+ const boundary = slice.lastIndexOf(" ");
5232
+ const head = boundary > 0 ? slice.slice(0, boundary) : slice;
5233
+ return `${head.trimEnd()}\u2026`;
5234
+ }
5235
+ async function skillsList(opts) {
5236
+ let builtins;
5237
+ let integrations;
5238
+ try {
5239
+ const all = discoverAll();
5240
+ builtins = all.builtins;
5241
+ integrations = all.integrations;
5242
+ } catch (err) {
5243
+ fail(
5244
+ EXIT.ERROR,
5245
+ `skills list: could not discover skills (${err instanceof Error ? err.message : String(err)})`,
5246
+ opts
5247
+ );
5248
+ }
5249
+ const rows = [
5250
+ ...builtins.map((b) => toRow(b, "builtin")),
5251
+ ...integrations.map((i) => toRow(i, "integration"))
5252
+ ];
5253
+ const data = { count: rows.length, skills: rows };
5254
+ if (opts.json === true) {
5255
+ process.stdout.write(`${JSON.stringify({ ok: true, data })}
5256
+ `);
5257
+ return;
5258
+ }
5259
+ const intCount = integrations.length;
5260
+ 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"}`;
5261
+ log(banner, opts);
5262
+ table(
5263
+ rows.map((r) => ({
5264
+ Skill: r.name,
5265
+ Kind: r.kind,
5266
+ Category: r.category,
5267
+ Status: r.status,
5268
+ Description: truncate(r.description, 80)
5269
+ })),
5270
+ ["Skill", "Kind", "Category", "Status", "Description"],
5271
+ opts
5272
+ );
5273
+ }
5274
+ async function skillsSync(opts) {
5275
+ const root = process.cwd();
5276
+ let project;
5277
+ try {
5278
+ project = loadProjectInfo11(root);
5279
+ } catch {
5280
+ fail(EXIT.ERROR, "Noir is not initialized in this directory. Run `noir init` first.", opts);
5281
+ }
5282
+ const adapter = resolveAdapter5(project.config.host);
5283
+ if (!adapter.skillsDir) {
5284
+ const data2 = {
5285
+ emitted: [],
5286
+ references: 0,
5287
+ host: project.config.host,
5288
+ reason: "no-skill-emitter"
5289
+ };
5290
+ if (opts.json === true) {
5291
+ process.stdout.write(`${JSON.stringify({ ok: true, data: data2 })}
5292
+ `);
5293
+ return;
5294
+ }
5295
+ info("This host has no skill emitter; nothing to sync.", opts);
5296
+ return;
5297
+ }
5298
+ const dir = adapter.skillsDir({ root });
5299
+ const target = project.config.host;
5300
+ const summary = await emitSkillsToDir2(dir, { target });
5301
+ const pruned = summary.pruned ?? [];
5302
+ const preserved = summary.preserved ?? [];
5303
+ const data = {
5304
+ emitted: summary.emitted,
5305
+ references: summary.references,
5306
+ dir,
5307
+ pruned,
5308
+ // What the count above does NOT cover: skills whose files were preserved
5309
+ // (stale on disk, by design, in a non-interactive run). Same field `noir
5310
+ // init --json` carries, so a consumer reads one shape from every emitter.
5311
+ preserved
5312
+ };
5313
+ if (opts.json === true) {
5314
+ process.stdout.write(`${JSON.stringify({ ok: true, data })}
5315
+ `);
5316
+ return;
5317
+ }
5318
+ log(
5319
+ `Synced ${summary.emitted.length} Noir skill${summary.emitted.length === 1 ? "" : "s"} to ${dir}.`,
5320
+ opts
5321
+ );
5322
+ if (pruned.length > 0) {
5323
+ warn(
5324
+ `Pruned ${pruned.length} stale noir-* skill dir${pruned.length === 1 ? "" : "s"}: ${pruned.join(", ")}`,
5325
+ opts
5326
+ );
5327
+ }
5328
+ const stale = preservedStaleLine(preserved);
5329
+ if (stale !== void 0) warn(stale, opts);
5330
+ }
5331
+ async function skillsLint(opts) {
5332
+ let skills;
5333
+ try {
5334
+ const all = discoverAll();
5335
+ skills = [...all.builtins, ...all.integrations].map(lintSkill);
5336
+ } catch (err) {
5337
+ fail(
5338
+ EXIT.ERROR,
5339
+ `skills lint: could not lint skills (${err instanceof Error ? err.message : String(err)})`,
5340
+ opts
5341
+ );
5342
+ }
5343
+ const errored = skills.filter((s) => s.errors.length > 0);
5344
+ const warned = skills.filter((s) => s.warnings.length > 0 && s.errors.length === 0);
5345
+ const data = { count: skills.length, errored: errored.length, skills };
5346
+ if (errored.length > 0) {
5347
+ const message = `skills lint: ${errored.length} skill${errored.length === 1 ? "" : "s"} failed validation`;
5348
+ if (opts.json === true) {
5349
+ process.stdout.write(
5350
+ `${JSON.stringify({ ok: false, error: { code: EXIT.ERROR, message }, data })}
5351
+ `
5352
+ );
5353
+ process.exitCode = EXIT.ERROR;
5354
+ return;
5355
+ }
5356
+ warn(
5357
+ `noir skills lint \u2014 ${errored.length} skill${errored.length === 1 ? "" : "s"} FAIL validation:`,
5358
+ opts
5359
+ );
5360
+ for (const s of errored) {
5361
+ for (const e of s.errors) warn(` ${s.name}: ${e}`, opts);
5362
+ }
5363
+ if (warned.length > 0) {
5364
+ info(` ${warned.length} skill${warned.length === 1 ? "" : "s"} carry lint warnings:`, opts);
5365
+ for (const s of warned) {
5366
+ for (const w of s.warnings) info(` ${s.name}: ${w}`, opts);
5367
+ }
5368
+ }
5369
+ fail(EXIT.ERROR, message, opts);
5370
+ }
5371
+ if (opts.json === true) {
5372
+ process.stdout.write(`${JSON.stringify({ ok: true, data })}
5373
+ `);
5374
+ return;
5375
+ }
5376
+ log(`noir skills lint \u2014 ${skills.length} skills, all validate clean.`, opts);
5377
+ if (warned.length > 0) {
5378
+ info(` ${warned.length} skill${warned.length === 1 ? "" : "s"} carry lint warnings:`, opts);
5379
+ for (const s of warned) {
5380
+ for (const w of s.warnings) info(` ${s.name}: ${w}`, opts);
5381
+ }
5382
+ }
5383
+ }
5384
+ async function skillsRegistry(opts) {
5385
+ let reg;
5386
+ try {
5387
+ reg = buildRegistry();
5388
+ } catch (err) {
5389
+ fail(
5390
+ EXIT.ERROR,
5391
+ `skills registry: could not build registry (${err instanceof Error ? err.message : String(err)})`,
5392
+ opts
5393
+ );
5394
+ }
5395
+ const data = { count: reg.length, skills: reg };
5396
+ if (opts.json === true) {
5397
+ process.stdout.write(`${JSON.stringify({ ok: true, data })}
5398
+ `);
5399
+ return;
5400
+ }
5401
+ log(`noir skills registry \u2014 ${reg.length} skills.`, opts);
5402
+ table(
5403
+ reg.map((r) => ({
5404
+ Skill: r.name,
5405
+ Kind: r.kind,
5406
+ Category: r.category,
5407
+ Version: r.version,
5408
+ Status: r.status,
5409
+ Refs: String(r.referenceCount),
5410
+ Lines: String(r.lines)
5411
+ })),
5412
+ ["Skill", "Kind", "Category", "Version", "Status", "Refs", "Lines"],
5413
+ opts
5414
+ );
5415
+ }
5416
+
4596
5417
  // src/serve.ts
4597
5418
  import { applyNoirEnv, loadProjectInfo as loadProjectInfo12 } from "@noir-ai/core";
4598
5419
  import { ensureDaemonRunning as ensureDaemonRunning2, startStdioServer } from "@noir-ai/daemon";
@@ -4734,13 +5555,15 @@ function createProgram() {
4734
5555
  "--upgrade",
4735
5556
  "run scaffold migrations before re-emitting (re-run on an existing project)"
4736
5557
  ).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(
4737
- // S10: target host. Defaults to `'claude'` (the regression anchor). The
4738
- // chosen host is forwarded to scaffold() + skills emission via
4739
- // resolveAdapter. The choice list mirrors `SUPPORTED_HOSTS` so a new host
4740
- // lands here automatically; commander rejects anything else as usage=2.
5558
+ // Target host. When the flag is absent the host is read from an existing
5559
+ // `.noir/config.yml`, falling back to `'claude'` (so a fresh project is
5560
+ // unaffected). The chosen host is forwarded to scaffold() + skills
5561
+ // emission via resolveAdapter. The choice list mirrors `SUPPORTED_HOSTS`
5562
+ // so a new host lands here automatically; commander rejects anything else
5563
+ // as usage=2.
4741
5564
  new Option(
4742
5565
  "--host <id>",
4743
- "target agentic CLI (default: claude) \u2014 drives host-side emission"
5566
+ "target agentic CLI (default: the host in .noir/config.yml, else claude)"
4744
5567
  ).choices(SUPPORTED_HOSTS2)
4745
5568
  ).action(
4746
5569
  async (opts, cmd) => {
@@ -4773,7 +5596,7 @@ function createProgram() {
4773
5596
  const force = opts.force === true;
4774
5597
  const host = parseHost(opts.host);
4775
5598
  const dryRun = opts.dryRun === true || opts.preview === true;
4776
- const { create } = await import("./create-FD4E626Z.js");
5599
+ const { create } = await import("./create-6KDIFPUL.js");
4777
5600
  const result = await create(dir, {
4778
5601
  transport,
4779
5602
  url: opts.url,
@@ -4803,7 +5626,7 @@ function createProgram() {
4803
5626
  "strip-replace managed regions (discard hand-edits inside <!-- noir:* --> markers)"
4804
5627
  )
4805
5628
  ).addOption(
4806
- // S10: optional `--host` override. When omitted, sync reads host from
5629
+ // Optional `--host` override. When omitted, sync reads host from
4807
5630
  // `.noir/config.yml` (whatever `noir init --host <id>` persisted). The
4808
5631
  // override is rarely needed — documented as advanced.
4809
5632
  new Option(
@@ -4812,7 +5635,7 @@ function createProgram() {
4812
5635
  ).choices(SUPPORTED_HOSTS2)
4813
5636
  ).action(
4814
5637
  async (opts, cmd) => {
4815
- const { sync } = await import("./sync-N633SQSS.js");
5638
+ const { sync } = await import("./sync-AII5WCRO.js");
4816
5639
  const host = parseHost(opts.host);
4817
5640
  const force = opts.force === true;
4818
5641
  const merge = opts.merge === true;
@@ -5198,7 +6021,7 @@ function createProgram() {
5198
6021
  "dismiss the migration banner for the current version (persists in install.json)"
5199
6022
  ).argument("[spec]", "channel ('latest'|'beta') or exact version (default: latest)").action(
5200
6023
  async (spec, opts, cmd) => {
5201
- const { install } = await import("./install-YGTUSLRO.js");
6024
+ const { install } = await import("./install-KA35ONQP.js");
5202
6025
  const cli = cmd.optsWithGlobals();
5203
6026
  await install({
5204
6027
  ...cli,
@@ -5211,7 +6034,7 @@ function createProgram() {
5211
6034
  );
5212
6035
  installCmd.alias("migrate");
5213
6036
  program2.command("update").description("update Noir to the latest version via the active install method").option("--check", "check for a new version without changing anything").argument("[spec]", "channel ('latest'|'beta') or exact version (default: latest)").action(async (spec, opts, cmd) => {
5214
- const { update } = await import("./update-LET5BWOU.js");
6037
+ const { update } = await import("./update-XGKJ4KBR.js");
5215
6038
  const cli = cmd.optsWithGlobals();
5216
6039
  await update({ ...cli, spec, check: opts.check === true });
5217
6040
  });
@@ -5328,7 +6151,7 @@ var homeDeps = {
5328
6151
  }
5329
6152
  },
5330
6153
  // The grouped home menu resolves its sections against the LIVE palette
5331
- // registry (home-consolidation S1/S2) — the same source the TUI palette
6154
+ // registry — the same source the TUI palette
5332
6155
  // uses — so the menu cannot drift from the commander tree.
5333
6156
  commands: buildPaletteCommands(createProgram())
5334
6157
  };
@@ -5902,11 +6725,27 @@ export {
5902
6725
  describeMemory,
5903
6726
  gatherStatusPayload,
5904
6727
  isDestructive,
6728
+ runPromptFrom,
5905
6729
  resolveSections,
6730
+ INTERRUPT_GRACE_MS,
6731
+ parseStreamLine,
6732
+ normalizeStreamEvent,
6733
+ UsageReducer,
6734
+ runHost,
6735
+ postRunActions,
6736
+ performPostRunAction,
6737
+ interruptedNotice,
6738
+ RunInterrupt,
6739
+ loadRunConfig,
6740
+ resolveRunProfile,
6741
+ humanizeElapsed,
6742
+ mergeEnv,
6743
+ writeTranscript,
5906
6744
  buildConflictOpts,
5907
6745
  checkWritePathDedup,
5908
6746
  reportPlannedWrites,
6747
+ preservedStaleLine,
5909
6748
  assertTransportUrl,
5910
6749
  buildPaletteCommandsForTui
5911
6750
  };
5912
- //# sourceMappingURL=chunk-6LO4LXEH.js.map
6751
+ //# sourceMappingURL=chunk-5KWETEMF.js.map