@nanhara/hara 0.121.1 → 0.122.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +57 -10
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +169 -31
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +24 -6
  9. package/dist/checkpoints.js +103 -17
  10. package/dist/cli.js +16 -0
  11. package/dist/config.js +173 -34
  12. package/dist/context/agents-md.js +44 -9
  13. package/dist/context/mentions.js +10 -4
  14. package/dist/context/subdir-hints.js +40 -7
  15. package/dist/cron/deliver.js +37 -3
  16. package/dist/cron/runner.js +372 -37
  17. package/dist/cron/store.js +11 -3
  18. package/dist/exec/jobs.js +88 -20
  19. package/dist/feedback.js +3 -2
  20. package/dist/fs-read.js +421 -12
  21. package/dist/fs-walk.js +8 -2
  22. package/dist/fs-write.js +433 -21
  23. package/dist/gateway/dingtalk.js +4 -1
  24. package/dist/gateway/discord.js +53 -20
  25. package/dist/gateway/feishu.js +157 -58
  26. package/dist/gateway/flows-pending.js +727 -0
  27. package/dist/gateway/flows.js +391 -16
  28. package/dist/gateway/matrix.js +81 -18
  29. package/dist/gateway/mattermost.js +44 -34
  30. package/dist/gateway/media.js +659 -0
  31. package/dist/gateway/outbound-files.js +379 -0
  32. package/dist/gateway/serve.js +712 -169
  33. package/dist/gateway/sessions.js +475 -78
  34. package/dist/gateway/signal.js +31 -28
  35. package/dist/gateway/slack.js +28 -21
  36. package/dist/gateway/telegram.js +33 -21
  37. package/dist/gateway/tmux-routes.js +11 -3
  38. package/dist/gateway/wecom.js +38 -31
  39. package/dist/gateway/weixin.js +147 -59
  40. package/dist/hooks.js +41 -23
  41. package/dist/index.js +763 -273
  42. package/dist/mcp/client.js +164 -12
  43. package/dist/memory/store.js +68 -22
  44. package/dist/org/planner.js +36 -10
  45. package/dist/org/projects.js +347 -0
  46. package/dist/org/review-chain.js +360 -24
  47. package/dist/org/roles.js +42 -13
  48. package/dist/profile/profile.js +152 -27
  49. package/dist/recall.js +4 -2
  50. package/dist/runtime.js +37 -0
  51. package/dist/sandbox.js +142 -33
  52. package/dist/search/semindex.js +182 -53
  53. package/dist/search/zvec-store.js +121 -42
  54. package/dist/security/permissions.js +326 -19
  55. package/dist/security/private-state.js +299 -0
  56. package/dist/security/project-trust.js +6 -0
  57. package/dist/security/secrets.js +84 -9
  58. package/dist/security/sensitive-files.js +723 -0
  59. package/dist/security/subprocess-env.js +210 -0
  60. package/dist/serve/server.js +774 -318
  61. package/dist/serve/sessions.js +113 -33
  62. package/dist/session/store.js +298 -47
  63. package/dist/skills/skills.js +16 -7
  64. package/dist/tools/all.js +1 -0
  65. package/dist/tools/builtin.js +77 -49
  66. package/dist/tools/codebase.js +3 -1
  67. package/dist/tools/computer.js +98 -92
  68. package/dist/tools/cron.js +6 -0
  69. package/dist/tools/edit.js +22 -9
  70. package/dist/tools/external_agent.js +110 -16
  71. package/dist/tools/memory.js +38 -8
  72. package/dist/tools/patch.js +253 -34
  73. package/dist/tools/search.js +543 -73
  74. package/dist/tools/send.js +11 -5
  75. package/dist/tools/task.js +453 -0
  76. package/dist/tools/todo.js +67 -16
  77. package/dist/tools/web.js +168 -54
  78. package/dist/undo.js +83 -7
  79. package/package.json +11 -10
  80. package/runtime-bootstrap.cjs +72 -0
package/dist/index.js CHANGED
@@ -13,9 +13,11 @@ import { execFileSync } from "node:child_process";
13
13
  import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
14
14
  import { homedir, tmpdir } from "node:os";
15
15
  import { fileURLToPath } from "node:url";
16
- import { dirname, join, relative } from "node:path";
16
+ import { randomUUID } from "node:crypto";
17
+ import { dirname, join, relative, resolve } from "node:path";
17
18
  import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, providerDefaultBaseURL, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, REASONING_EFFORTS, } from "./config.js";
18
19
  import { runAgent } from "./agent/loop.js";
20
+ import { parseSchemaArg, structuredOutputTool, STRUCTURED_INSTRUCTION, STRUCTURED_NUDGE } from "./agent/structured.js";
19
21
  import { notifyDone } from "./notify.js";
20
22
  import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
21
23
  import { completionScript } from "./completions.js";
@@ -25,14 +27,14 @@ import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile,
25
27
  import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
26
28
  import { routingProvider } from "./agent/route.js";
27
29
  import { shouldAutoCompact, shouldAutoCompactTokens, AUTO_COMPACT_TOKEN_CAP, COMPACT_SYSTEM, buildFileRestore, workingSetFromSummary } from "./agent/compact.js";
28
- import { recentTouched } from "./agent/touched.js";
29
- import { INTERJECT_PREFIX } from "./agent/reminders.js";
30
+ import { recentTouched, clearTouched } from "./agent/touched.js";
31
+ import { INTERJECT_PREFIX, disposeReminderScope } from "./agent/reminders.js";
30
32
  import { checkForUpdate } from "./update-check.js";
31
33
  import { formatContextReport } from "./agent/context-report.js";
32
34
  import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
33
35
  import { checkpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
34
36
  import { mapLimit, maxParallel } from "./concurrency.js";
35
- import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
37
+ import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence, commitMessageInput, protectedStagedPaths, protectedTrackedWorkingTreePaths, protectedWorkingTreePaths, } from "./org/review-chain.js";
36
38
  import { parseSchedule, describeSchedule, nextRun, validTz } from "./cron/schedule.js";
37
39
  import { parseDeliver } from "./cron/deliver.js";
38
40
  import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath } from "./cron/store.js";
@@ -48,6 +50,8 @@ import { resolvePlatform } from "./providers/registry.js";
48
50
  import { levelsFor } from "./tui/model-picker.js";
49
51
  import { listModels } from "./providers/models.js";
50
52
  import { listJobs, tailJob, killJob } from "./exec/jobs.js";
53
+ import { readModelContextFileSync } from "./fs-read.js";
54
+ import { MIN_NODE_VERSION, unsupportedNodeMessage } from "./runtime.js";
51
55
  /** Render the background-job list for /jobs (user-facing view of what the agent has running in the
52
56
  * background — dev servers, watchers, long tasks). Mirrors codex/Claude-Code process visibility. */
53
57
  function renderBgJobs() {
@@ -68,9 +72,10 @@ import { getEmbedder } from "./search/embed.js";
68
72
  import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
69
73
  import { searchHybrid } from "./search/hybrid.js";
70
74
  import { expandMentions, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
71
- import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
75
+ import { newSessionId, shortId, resolveSessionId, validSessionId, sessionFileExists, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
72
76
  import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
73
- import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
77
+ import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
78
+ import { buildAgentsIndex, canonicalProjectPath, resolveAgent, loadProjects, addProject, removeProject } from "./org/projects.js";
74
79
  import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
75
80
  import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks, haraBinDir } from "./plugins/plugins.js";
76
81
  import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
@@ -92,6 +97,8 @@ import "./tools/memory.js"; // register memory_search/get/write/forget/skill_cre
92
97
  import "./tools/skill.js"; // register the skill loader tool
93
98
  import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
94
99
  import "./tools/todo.js"; // register todo_write (inline task checklist)
100
+ import { clearTodos, disposeTodoScope, restoreTodos, onTodosChange } from "./tools/todo.js"; // scoped session todo persistence
101
+ import "./tools/task.js"; // register task (project-level durable task pool)
95
102
  import "./tools/send.js"; // register send_file (self-gates on HARA_GATEWAY — pushes a file to the chat)
96
103
  import "./tools/external_agent.js"; // register external_agent (delegate to claude-code / codex headless)
97
104
  import "./tools/ask_user.js"; // register ask_user (pause mid-turn to ask the user a structured question)
@@ -390,24 +397,31 @@ async function runInit(provider, cwd, sandbox = "off") {
390
397
  /** Stage everything and commit with an AI-written message. Returns a one-line summary or "error: …".
391
398
  * Used by `hara org --commit`; the caller guards on a clean start tree so this only captures the run's work. */
392
399
  async function autoCommit(provider, cwd) {
400
+ const before = protectedWorkingTreePaths(cwd);
401
+ if (before.length)
402
+ return `error: refusing to stage protected path(s): ${before.map((p) => JSON.stringify(p)).join(", ")}`;
393
403
  try {
394
404
  await runShell("git add -A", cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
395
405
  }
396
406
  catch {
397
407
  /* fall through — empty diff is reported below */
398
408
  }
399
- let diff = "";
400
- try {
401
- diff = (await runShell("git diff --staged", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
409
+ const protectedAfterStage = protectedStagedPaths(cwd);
410
+ if (protectedAfterStage.length) {
411
+ return `error: refusing to inspect or commit protected staged path(s): ${protectedAfterStage.map((p) => JSON.stringify(p)).join(", ")}`;
402
412
  }
403
- catch (e) {
404
- return `error: git diff failed (${e instanceof Error ? e.message : String(e)})`;
413
+ const staged = captureChanges(cwd, 120_000, { staged: true, includeUntracked: false });
414
+ if (staged.error)
415
+ return `error: git diff failed closed (${staged.error})`;
416
+ if (staged.skippedFiles.length) {
417
+ return `error: refusing to inspect or commit protected staged path(s): ${staged.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}`;
405
418
  }
406
- if (!diff.trim())
419
+ const changeInput = commitMessageInput(staged);
420
+ if (!changeInput.trim())
407
421
  return "nothing to commit";
408
422
  const r = await provider.turn({
409
423
  system: COMMIT_SYSTEM,
410
- history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }],
424
+ history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n${changeInput.slice(0, 120_000)}` }],
411
425
  tools: [],
412
426
  onText: () => { },
413
427
  });
@@ -417,6 +431,10 @@ async function autoCommit(provider, cwd) {
417
431
  const tmp = join(tmpdir(), `hara-org-commit-${process.pid}.txt`);
418
432
  writeFileSync(tmp, msg + "\n", "utf8");
419
433
  try {
434
+ const protectedBeforeCommit = protectedStagedPaths(cwd);
435
+ if (protectedBeforeCommit.length) {
436
+ return `error: staged paths changed while writing the message; protected path(s) will not be committed: ${protectedBeforeCommit.map((p) => JSON.stringify(p)).join(", ")}`;
437
+ }
420
438
  const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
421
439
  return (res.stdout || "").trim().split("\n")[0] || "committed";
422
440
  }
@@ -443,18 +461,29 @@ async function commitStep(provider, cwd) {
443
461
  out(c.green(`✓ committed · ${r.slice(0, 100)}\n`));
444
462
  }
445
463
  /** Dispatch a task to the owning role and run that role's agent (its persona + tool subset + model). */
464
+ function runFailureDetail(outcome) {
465
+ if (outcome.status === "completed")
466
+ return null;
467
+ if (outcome.error?.trim())
468
+ return outcome.error.trim();
469
+ if (outcome.status === "empty")
470
+ return "the model returned an empty response";
471
+ if (outcome.status === "halted")
472
+ return "the run was halted by safety controls";
473
+ return "the agent run failed";
474
+ }
446
475
  async function runOrg(task, o) {
447
476
  const roles = loadRoles(o.cwd);
448
477
  if (!roles.length) {
449
478
  out(c.yellow("No roles defined — run ") + c.bold("hara roles init") + c.yellow(" to scaffold some.\n"));
450
- return;
479
+ return { status: "error", error: "no roles are defined" };
451
480
  }
452
481
  let role;
453
482
  if (o.forceRole) {
454
483
  role = roles.find((r) => r.id === o.forceRole);
455
484
  if (!role) {
456
485
  out(c.red(`No role '${o.forceRole}'. Available: ${roles.map((r) => r.id).join(", ")}\n`));
457
- return;
486
+ return { status: "error", error: `no role '${o.forceRole}' is available` };
458
487
  }
459
488
  }
460
489
  else {
@@ -478,23 +507,22 @@ async function runOrg(task, o) {
478
507
  const roleProvider = __roleModel
479
508
  ? ((await buildProvider({ ...o.cfg, model: __roleModel })) ?? o.baseProvider)
480
509
  : o.baseProvider;
481
- const toolFilter = role.allowTools
482
- ? (n) => role.allowTools.includes(n)
483
- : role.denyTools
484
- ? (n) => !role.denyTools.includes(n)
485
- : undefined;
510
+ const toolFilter = roleToolFilter(role);
486
511
  const history = [{ role: "user", content: expandMentions(task, o.cwd) }];
487
- const runImplementer = () => runAgent(history, {
488
- provider: roleProvider,
489
- ctx: { cwd: o.cwd, sandbox: o.sandbox },
490
- approval: o.approval,
491
- confirm: o.confirm,
492
- projectContext: o.projectContext,
493
- memory: memoryDigest(o.cwd),
494
- stats: o.stats,
495
- systemOverride: role.system,
496
- toolFilter,
497
- });
512
+ const runImplementer = async () => {
513
+ return runAgent(history, {
514
+ provider: roleProvider,
515
+ ctx: { cwd: o.cwd, sandbox: o.sandbox },
516
+ approval: o.approval,
517
+ confirm: o.confirm,
518
+ projectContext: o.projectContext,
519
+ memory: memoryDigest(o.cwd),
520
+ stats: o.stats,
521
+ systemOverride: role.system,
522
+ toolFilter,
523
+ ...(role.readOnly ? { hooks: false } : {}),
524
+ });
525
+ };
498
526
  const wasClean = o.commit ? isTreeClean(o.cwd) : false; // capture BEFORE the implementer edits anything
499
527
  const doCommit = async (ok) => {
500
528
  if (!o.commit)
@@ -505,27 +533,34 @@ async function runOrg(task, o) {
505
533
  return void out(c.yellow("(not auto-committing — the tree wasn't clean before this run; commit manually)\n"));
506
534
  await commitStep(o.baseProvider, o.cwd);
507
535
  };
508
- await runImplementer();
536
+ let implementerOutcome = await runImplementer();
537
+ if (implementerOutcome.status !== "completed")
538
+ return implementerOutcome;
509
539
  if (!o.review) {
510
540
  await doCommit(true);
511
- return;
541
+ return implementerOutcome;
512
542
  }
513
543
  // Review chain: a reviewer role inspects the diff and APPROVES or sends it back, looping until clean.
514
544
  const reviewer = roles.find((r) => r.id === "reviewer");
515
545
  const __revModel = effectiveRoleModel(reviewer?.model, o.cfg.model);
516
546
  const revProvider = __revModel ? ((await buildProvider({ ...o.cfg, model: __revModel })) ?? o.baseProvider) : o.baseProvider;
517
547
  const revSystem = reviewer?.system ?? REVIEWER_SYSTEM;
518
- const revTools = reviewer?.allowTools ? (n) => reviewer.allowTools.includes(n) : (n) => READONLY_TOOLS.has(n);
548
+ const revTools = roleToolFilter(reviewer ? { ...reviewer, readOnly: true } : undefined) ?? ((n) => READONLY_TOOLS.has(n));
519
549
  const maxRounds = Math.max(1, o.rounds ?? 3);
520
550
  for (let round = 1; round <= maxRounds; round++) {
521
551
  const changes = captureChanges(o.cwd);
522
- if (!changes.diff && !changes.newFiles.length) {
552
+ if (changes.error) {
553
+ out(c.red(`(review change capture failed closed: ${changes.error})\n`));
554
+ await doCommit(false);
555
+ return { status: "halted", error: `review change capture failed: ${changes.error}` };
556
+ }
557
+ if (!changes.diff && !changes.newFiles.length && !changes.skippedFiles.length && !changes.omittedDeletions.length) {
523
558
  out(c.dim("(no changes to review)\n"));
524
- return;
559
+ return implementerOutcome;
525
560
  }
526
561
  out(c.dim(`🔍 reviewer · round ${round}/${maxRounds}\n`));
527
562
  const rHist = [{ role: "user", content: reviewPrompt(task, changes) }];
528
- await runAgent(rHist, {
563
+ const reviewerOutcome = await runAgent(rHist, {
529
564
  provider: revProvider,
530
565
  ctx: { cwd: o.cwd, sandbox: o.sandbox },
531
566
  approval: "full-auto", // reviewer is read-only via revTools, so nothing to confirm
@@ -535,22 +570,28 @@ async function runOrg(task, o) {
535
570
  stats: o.stats,
536
571
  systemOverride: revSystem,
537
572
  toolFilter: revTools,
573
+ hooks: false,
538
574
  });
575
+ if (reviewerOutcome.status !== "completed")
576
+ return reviewerOutcome;
539
577
  const verdict = parseVerdict(lastAssistantText(rHist));
540
578
  if (verdict.approved) {
541
579
  out(c.green(`✓ reviewer approved after ${round} round(s)\n`));
542
580
  await doCommit(true);
543
- return;
581
+ return implementerOutcome;
544
582
  }
545
583
  if (round === maxRounds) {
546
584
  out(c.yellow(`⚠ stopped after ${maxRounds} round(s) — reviewer still wants changes.\n`));
547
585
  await doCommit(false);
548
- return;
586
+ return { status: "halted", error: `reviewer did not approve after ${maxRounds} round(s)` };
549
587
  }
550
588
  out(c.yellow(`✗ changes requested — back to ${role.id} (round ${round})\n`));
551
589
  history.push({ role: "user", content: fixPrompt(verdict.issues) });
552
- await runImplementer();
590
+ implementerOutcome = await runImplementer();
591
+ if (implementerOutcome.status !== "completed")
592
+ return implementerOutcome;
553
593
  }
594
+ return implementerOutcome;
554
595
  }
555
596
  function lastAssistantText(history) {
556
597
  for (let i = history.length - 1; i >= 0; i--) {
@@ -563,18 +604,14 @@ function lastAssistantText(history) {
563
604
  /** Run one atom (routed to its role if any), then gate it (its `check` command, else an LLM verify). */
564
605
  async function executeAtom(atom, plan, done, roles, o) {
565
606
  atom.status = "running";
566
- savePlan(o.cwd, plan);
607
+ await savePlan(o.cwd, plan);
567
608
  const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
568
609
  const __atomModel = effectiveRoleModel(role?.model, o.cfg.model);
569
610
  const roleProvider = __atomModel ? ((await buildProvider({ ...o.cfg, model: __atomModel })) ?? o.baseProvider) : o.baseProvider;
570
- const toolFilter = role?.allowTools
571
- ? (n) => role.allowTools.includes(n)
572
- : role?.denyTools
573
- ? (n) => !role.denyTools.includes(n)
574
- : undefined;
611
+ const toolFilter = roleToolFilter(role);
575
612
  const history = [{ role: "user", content: atomPrompt(atom, plan, done) }];
576
613
  try {
577
- await runAgent(history, {
614
+ const outcome = await runAgent(history, {
578
615
  provider: roleProvider,
579
616
  ctx: { cwd: o.cwd, sandbox: o.sandbox },
580
617
  approval: o.approval,
@@ -584,20 +621,29 @@ async function executeAtom(atom, plan, done, roles, o) {
584
621
  stats: o.stats,
585
622
  systemOverride: role?.system,
586
623
  toolFilter,
624
+ ...(role?.readOnly ? { hooks: false } : {}),
587
625
  quiet: o.parallel, // concurrent atoms would otherwise interleave their streamed output
588
626
  });
627
+ const failure = runFailureDetail(outcome);
628
+ if (failure) {
629
+ atom.status = "failed";
630
+ atom.note = failure;
631
+ await savePlan(o.cwd, plan);
632
+ out(c.red(` ✗ ${atom.id} agent ${outcome.status}: ${failure}\n`));
633
+ return false;
634
+ }
589
635
  }
590
636
  catch (e) {
591
637
  atom.status = "failed";
592
638
  atom.note = e.message;
593
- savePlan(o.cwd, plan);
639
+ await savePlan(o.cwd, plan);
594
640
  out(c.red(` ✗ ${atom.id} errored: ${e.message}\n`));
595
641
  return false;
596
642
  }
597
643
  const v = atom.check ? await runCheck(atom.check, o.cwd, o.sandbox) : await verify(o.baseProvider, atom, lastAssistantText(history));
598
644
  atom.status = v.ok ? "done" : "failed";
599
645
  atom.note = v.reason;
600
- savePlan(o.cwd, plan);
646
+ await savePlan(o.cwd, plan);
601
647
  out(v.ok ? c.green(` ✓ ${atom.id} verified\n`) : c.yellow(` ⚠ ${atom.id}: ${v.reason}\n`));
602
648
  return v.ok;
603
649
  }
@@ -606,10 +652,13 @@ async function executeAtom(atom, plan, done, roles, o) {
606
652
  async function executePlan(plan, roles, o) {
607
653
  const done = plan.atoms.filter((a) => a.status === "done");
608
654
  const doneIds = new Set(done.map((a) => a.id));
655
+ let failed = false;
609
656
  if (o.parallel) {
610
657
  const waved = topoWaves(plan.atoms);
611
- if ("error" in waved)
612
- return void out(c.red(`${waved.error}\n`));
658
+ if ("error" in waved) {
659
+ out(c.red(`${waved.error}\n`));
660
+ return { status: "error", error: waved.error };
661
+ }
613
662
  out(c.dim(`Parallel mode — ${waved.ok.length} wave(s).\n`));
614
663
  for (const wave of waved.ok) {
615
664
  const todo = wave.filter((a) => !doneIds.has(a.id));
@@ -624,6 +673,7 @@ async function executePlan(plan, roles, o) {
624
673
  }
625
674
  });
626
675
  if (results.some((r) => !r)) {
676
+ failed = true;
627
677
  out(c.dim("Stopping — a wave atom failed. Inspect .hara/org/plan.json, then fix & `hara plan resume`.\n"));
628
678
  break;
629
679
  }
@@ -631,8 +681,10 @@ async function executePlan(plan, roles, o) {
631
681
  }
632
682
  else {
633
683
  const ord = topoOrder(plan.atoms);
634
- if ("error" in ord)
635
- return void out(c.red(`${ord.error}\n`));
684
+ if ("error" in ord) {
685
+ out(c.red(`${ord.error}\n`));
686
+ return { status: "error", error: ord.error };
687
+ }
636
688
  for (const atom of ord.ok) {
637
689
  if (doneIds.has(atom.id))
638
690
  continue; // resume: skip completed atoms
@@ -642,12 +694,21 @@ async function executePlan(plan, roles, o) {
642
694
  doneIds.add(atom.id);
643
695
  }
644
696
  else {
697
+ failed = true;
645
698
  out(c.dim("Stopping — inspect .hara/org/plan.json, then fix & `hara plan resume`.\n"));
646
699
  break;
647
700
  }
648
701
  }
649
702
  }
650
703
  out(c.bold(`\nPlan: ${plan.atoms.filter((a) => a.status === "done").length}/${plan.atoms.length} atoms done.\n`));
704
+ if (failed || plan.atoms.some((atom) => atom.status === "failed")) {
705
+ const first = plan.atoms.find((atom) => atom.status === "failed");
706
+ return { status: "error", error: first ? `atom ${first.id} failed${first.note ? `: ${first.note}` : ""}` : "plan execution failed" };
707
+ }
708
+ if (plan.atoms.some((atom) => atom.status !== "done")) {
709
+ return { status: "halted", error: "plan stopped before every atom completed" };
710
+ }
711
+ return { status: "completed" };
651
712
  }
652
713
  /** Decompose a task into atoms, sequence them (DAG), and execute each with a verify gate.
653
714
  * With `parallel`, independent atoms (the same dependency wave) run concurrently. */
@@ -657,12 +718,12 @@ async function runPlan(task, o) {
657
718
  const plan = await decompose(o.baseProvider, task, roles);
658
719
  if (!plan.atoms.length) {
659
720
  out(c.red("Planner returned no atoms — try rephrasing the task.\n"));
660
- return;
721
+ return { status: "error", error: "planner returned no atoms" };
661
722
  }
662
723
  const ord = topoOrder(plan.atoms);
663
724
  if ("error" in ord) {
664
725
  out(c.red(`${ord.error}\n`));
665
- return;
726
+ return { status: "error", error: ord.error };
666
727
  }
667
728
  out(c.bold(`\nPlan (${ord.ok.length} atoms):\n`));
668
729
  for (const a of ord.ok) {
@@ -670,41 +731,65 @@ async function runPlan(task, o) {
670
731
  }
671
732
  if (o.approval !== "full-auto") {
672
733
  const ok = await o.confirm(`${c.yellow("▶")} Execute this ${ord.ok.length}-atom plan?`);
673
- if (!ok)
674
- return void out(c.dim("(cancelled)\n"));
734
+ if (!ok) {
735
+ out(c.dim("(cancelled)\n"));
736
+ return { status: "halted", error: "plan execution was cancelled" };
737
+ }
675
738
  }
676
- savePlan(o.cwd, plan);
677
- await executePlan(plan, roles, o);
739
+ await savePlan(o.cwd, plan);
740
+ return executePlan(plan, roles, o);
678
741
  }
679
742
  /** Resume the saved plan (.hara/org/plan.json): re-run atoms that aren't done; completed atoms are skipped. */
680
743
  async function runResume(o) {
681
744
  const roles = loadRoles(o.cwd);
682
745
  const plan = loadPlan(o.cwd);
683
- if (!plan)
684
- return void out(c.red('No saved plan at .hara/org/plan.json — run `hara plan "<task>"` first.\n'));
746
+ if (!plan) {
747
+ out(c.red('No saved plan at .hara/org/plan.json — run `hara plan "<task>"` first.\n'));
748
+ return { status: "error", error: "no saved plan" };
749
+ }
685
750
  const remaining = plan.atoms.filter((a) => a.status !== "done");
686
- if (!remaining.length)
687
- return void out(c.green(`Plan already complete — ${plan.atoms.length}/${plan.atoms.length} done.\n`));
751
+ if (!remaining.length) {
752
+ out(c.green(`Plan already complete — ${plan.atoms.length}/${plan.atoms.length} done.\n`));
753
+ return { status: "completed" };
754
+ }
688
755
  out(c.bold(`Resuming: ${plan.task}\n`) + c.dim(`${plan.atoms.length - remaining.length}/${plan.atoms.length} done · ${remaining.length} to go\n`));
689
756
  for (const a of remaining)
690
757
  out(` ${c.cyan(a.id)} ${a.title} ${c.dim("(" + a.status + ")")}\n`);
691
758
  if (o.approval !== "full-auto") {
692
759
  const ok = await o.confirm(`${c.yellow("▶")} Resume the ${remaining.length} remaining atom(s)?`);
693
- if (!ok)
694
- return void out(c.dim("(cancelled)\n"));
760
+ if (!ok) {
761
+ out(c.dim("(cancelled)\n"));
762
+ return { status: "halted", error: "plan resume was cancelled" };
763
+ }
695
764
  }
696
765
  for (const a of plan.atoms)
697
766
  if (a.status === "failed" || a.status === "running")
698
767
  a.status = "pending"; // retry interrupted
699
- savePlan(o.cwd, plan);
700
- await executePlan(plan, roles, o);
768
+ await savePlan(o.cwd, plan);
769
+ return executePlan(plan, roles, o);
701
770
  }
702
771
  const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "web_search", "codebase_search", "todo_write"]);
703
- const REVIEW_SYSTEM = "You are a senior code reviewer. Review the git diff the user provides for: correctness bugs, security " +
772
+ const REVIEW_SYSTEM = "You are a senior code reviewer. Review the safe Git status metadata the user provides for: correctness bugs, security " +
704
773
  "issues, missing error handling, unclear naming, and missing/weak tests. You may read files (read-only) " +
705
- "for context. Be concise and specific — cite file:line and the concrete fix. Group findings by severity: " +
774
+ "to inspect their current contents; historical patch lines are intentionally unavailable. Be concise and specific — cite file:line and the concrete fix. Group findings by severity: " +
706
775
  "**Blocker**, **Should-fix**, **Nit**. If nothing material is wrong, say the diff looks good. Never edit files.";
707
- const COMMIT_SYSTEM = "Write a git commit message for the staged diff. A concise imperative subject (≤72 chars; an optional " +
776
+ function standaloneReviewPrompt(changes) {
777
+ const parts = ["Review these changes:"];
778
+ if (changes.diff)
779
+ parts.push(`Change metadata only (status + path; no historical patch contents):\n\`\`\`text\n${changes.diff}\n\`\`\``);
780
+ if (changes.newFiles.length)
781
+ parts.push(`New files (inspect with read_file): ${changes.newFiles.map((p) => JSON.stringify(p)).join(", ")}`);
782
+ if (changes.skippedFiles.length) {
783
+ parts.push("Protected paths were omitted and MUST NOT be opened during this review: " +
784
+ changes.skippedFiles.map((p) => JSON.stringify(p)).join(", "));
785
+ }
786
+ if (changes.omittedDeletions.length) {
787
+ parts.push("Tracked deletions were detected; their historical contents were omitted because old filesystem " +
788
+ "identity cannot be verified safely: " + changes.omittedDeletions.map((p) => JSON.stringify(p)).join(", "));
789
+ }
790
+ return parts.join("\n\n");
791
+ }
792
+ const COMMIT_SYSTEM = "Write a git commit message for the staged change metadata. A concise imperative subject (≤72 chars; an optional " +
708
793
  "conventional-commits prefix like feat:/fix:/refactor:/docs:/test:/chore: is welcome). If the change is " +
709
794
  "non-trivial, add a blank line then a short body (a few bullets or sentences) on what changed and why. " +
710
795
  "Output ONLY the commit message — no code fences, no preamble, no surrounding quotes.";
@@ -783,7 +868,7 @@ async function compactConversation(provider, history, meta, stats) {
783
868
  // most recently touched files (current on-disk state, byte-capped) so work continues without re-reads.
784
869
  const restore = buildFileRestore(recentTouched(5), (p) => {
785
870
  try {
786
- return readFileSync(p, "utf8");
871
+ return readModelContextFileSync(p, 32 * 1024);
787
872
  }
788
873
  catch {
789
874
  return null;
@@ -818,29 +903,64 @@ async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
818
903
  /** Run a (read-only by default) sub-agent to completion, quietly, and return its final text. */
819
904
  async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
820
905
  const roles = loadRoles(cwd);
821
- const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
906
+ const roleRef = roleId?.trim();
907
+ if (roleId !== undefined && !roleRef)
908
+ return "Error: sub-agent role cannot be blank.";
909
+ let role;
910
+ if (roleRef?.includes(":")) {
911
+ const hit = resolveAgent(roleRef, cwd);
912
+ if (hit && "ambiguous" in hit) {
913
+ return `Error: sub-agent role '${roleRef}' is ambiguous; use one of: ${hit.ambiguous.map((entry) => `${entry.project}:${entry.name}`).join(", ")}.`;
914
+ }
915
+ if (hit?.project && resolve(hit.home) !== resolve(cwd)) {
916
+ return `Error: sub-agent role '${roleRef}' belongs to ${hit.home}; nested read-only agents stay in their parent home (${cwd}).`;
917
+ }
918
+ if (hit && !("ambiguous" in hit)) {
919
+ role = hit.project
920
+ ? roles.find((candidate) => candidate.id === hit.name)
921
+ : loadGlobalRoles().find((candidate) => candidate.id === hit.name);
922
+ }
923
+ }
924
+ else if (roleRef) {
925
+ role = roles.find((candidate) => candidate.id === roleRef);
926
+ }
822
927
  // Built-in explore persona: `agent(role:"explore")` works with ZERO user setup (a user-defined
823
928
  // explore role still wins — it was found above and carries its own system).
824
- const builtinSystem = !role && roleId === "explore" ? EXPLORE_SYSTEM : undefined;
825
- const __subModel = effectiveRoleModel(role?.model, cfg.model);
826
- const provider = __subModel ? ((await buildProvider({ ...cfg, model: __subModel })) ?? baseProvider) : baseProvider;
929
+ const builtinSystem = !role && roleRef === "explore" ? EXPLORE_SYSTEM : undefined;
930
+ if (roleRef && !role && !builtinSystem) {
931
+ return `Error: no sub-agent role '${roleRef}' is available in ${cwd}. Use a local role id, global:<name>, or role "explore".`;
932
+ }
933
+ const __subModel = effectiveRoleModel(role?.model, baseProvider.model);
934
+ const provider = __subModel && __subModel !== baseProvider.model
935
+ ? ((await buildProvider({ ...cfg, model: __subModel })) ?? baseProvider)
936
+ : baseProvider;
827
937
  // A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
828
938
  // further but can never GRANT write/exec to a fan-out sub-agent (that would bypass the approval gate).
829
939
  // Write-capable roles run in the main loop via `hara org`, behind the user's gate.
830
940
  const toolFilter = subagentToolFilter(role, (n) => READONLY_TOOLS.has(n));
831
941
  const subHistory = [{ role: "user", content: task }];
832
- await runAgent(subHistory, {
833
- provider,
834
- ctx: { cwd, sandbox }, // no `spawn` here → sub-agents can't recurse
835
- approval: "full-auto", // read-only tools, so no prompts (can't prompt in parallel)
836
- confirm: async () => true,
837
- projectContext,
838
- memory: memoryDigest(cwd),
839
- stats,
840
- systemOverride: role?.system ?? builtinSystem,
841
- toolFilter,
842
- quiet: true,
843
- });
942
+ const todoScope = `subagent:${randomUUID()}`;
943
+ try {
944
+ await runAgent(subHistory, {
945
+ provider,
946
+ ctx: { cwd, sandbox, todoScope }, // isolated checklist; no spawn → no recursion
947
+ approval: "full-auto", // read-only tools, so no prompts (can't prompt in parallel)
948
+ confirm: async () => true,
949
+ projectContext,
950
+ memory: memoryDigest(cwd),
951
+ stats,
952
+ systemOverride: role?.system ?? builtinSystem,
953
+ toolFilter,
954
+ hooks: false,
955
+ quiet: true,
956
+ });
957
+ }
958
+ finally {
959
+ disposeTodoScope(todoScope);
960
+ disposeReminderScope(todoScope);
961
+ resetRepeatGuard(todoScope);
962
+ clearTouched(todoScope);
963
+ }
844
964
  for (let i = subHistory.length - 1; i >= 0; i--) {
845
965
  const m = subHistory[i];
846
966
  if (m.role === "assistant" && typeof m.text === "string" && m.text.trim())
@@ -852,7 +972,7 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
852
972
  function runDoctor(cfg) {
853
973
  const ok = (b) => (b ? c.green("✓") : c.red("✗"));
854
974
  const dot = c.dim("·");
855
- const nodeMajor = Number(process.versions.node.split(".")[0]);
975
+ const nodeSupported = unsupportedNodeMessage() === null;
856
976
  const hasKey = !!(cfg.apiKey || process.env[providerEnvKey(cfg.provider)] || process.env.HARA_API_KEY);
857
977
  const oauthOk = cfg.provider === "qwen-oauth" && existsSync(join(homedir(), ".hara", "qwen-oauth.json"));
858
978
  const authed = hasKey || oauthOk;
@@ -862,7 +982,7 @@ function runDoctor(cfg) {
862
982
  const vdesc = vcap === "vision" ? c.dim("sees images (inline)") : vcap === "text" ? c.dim("text-only") : c.yellow("capability unknown — asks on first image");
863
983
  const lines = [
864
984
  c.bold("hara doctor"),
865
- `${ok(nodeMajor >= 20)} node ${process.versions.node} ${c.dim("(need ≥20)")}`,
985
+ `${ok(nodeSupported)} node ${process.versions.node} ${c.dim(`(need ≥${MIN_NODE_VERSION})`)}`,
866
986
  `${dot} provider ${c.bold(cfg.provider)} · model ${c.bold(cfg.model)}${cfg.baseURL ? c.dim(" · " + cfg.baseURL) : ""}`,
867
987
  `${ok(authed)} auth ${authed ? c.dim("configured") : c.yellow("missing — " + authHint(cfg))}`,
868
988
  `${ok(existsSync(configPath()))} config ${c.dim(configPath())}`,
@@ -898,6 +1018,8 @@ program
898
1018
  .description("A coding agent CLI that runs like an engineering org.")
899
1019
  .version(pkg.version)
900
1020
  .option("-p, --print <prompt>", "run a single prompt non-interactively, then exit")
1021
+ .option("--schema <json|file>", "(with -p) force a schema-shaped result: the model must call structured_output; stdout = that JSON")
1022
+ .option("--role <id>", "(with -p) run as a local, global:name, or project:name role (qualified projects run at their registered home)")
901
1023
  .option("-y, --yes", "auto-approve all tool actions (= --approval full-auto)")
902
1024
  .option("-m, --model <model>", "model id (overrides config)")
903
1025
  .option("--approval <mode>", "approval mode: suggest | auto-edit | full-auto")
@@ -980,31 +1102,98 @@ program
980
1102
  .option("--review", "after implementing, loop a reviewer role until it approves (implement → review → fix)")
981
1103
  .option("--rounds <n>", "max review rounds with --review (default 3)", (v) => parseInt(v, 10))
982
1104
  .option("--commit", "commit the result with an AI message (with --review: only after approval; needs a clean start tree)")
983
- .action(async (taskParts, opts2) => {
984
- const cfg = loadConfig();
1105
+ .action(async (taskParts, _localOpts, command) => {
1106
+ // The root headless command and `org` both expose --role. Commander stores that shared flag on the
1107
+ // parent command, while review/rounds/commit stay local; optsWithGlobals is the supported merged view.
1108
+ // Without it, flow-approved `hara org --role ...` children silently fell back to model dispatching.
1109
+ const opts2 = command.optsWithGlobals();
1110
+ let cfg = loadConfig();
1111
+ // Home dispatch (globally addressable, executes at home): a --role of "project:name" — or a bare name
1112
+ // that only exists in a REGISTERED project — resolves via the global agent index and runs with cwd =
1113
+ // that project (its AGENTS.md/data context), instead of failing or running context-blind here.
1114
+ let orgCwd = cfg.cwd;
1115
+ let forceRole = opts2.role;
1116
+ if (opts2.role && (opts2.role.includes(":") || !loadRoles(cfg.cwd).some((r) => r.id === opts2.role))) {
1117
+ const hit = resolveAgent(opts2.role);
1118
+ if (hit && "ambiguous" in hit) {
1119
+ out(c.yellow(`'${opts2.role}' exists in several projects — qualify it:\n`) + hit.ambiguous.map((e) => ` ${e.project}:${e.name}`).join("\n") + "\n");
1120
+ process.exit(1);
1121
+ }
1122
+ if (hit?.home) {
1123
+ orgCwd = hit.home;
1124
+ forceRole = hit.name;
1125
+ cfg = loadConfig({ cwd: hit.home });
1126
+ out(c.dim(`(dispatching to ${hit.project}:${hit.name} · home ${hit.home})\n`));
1127
+ }
1128
+ else if (hit) {
1129
+ // Explicit `global:name` is an address, not the literal role id. Global roles intentionally run
1130
+ // in the caller's current project so they retain that project's AGENTS.md and tool context.
1131
+ forceRole = hit.name;
1132
+ out(c.dim(`(dispatching to global:${hit.name} · current home ${orgCwd})\n`));
1133
+ }
1134
+ else if (!hit) {
1135
+ out(c.red(`No agent '${opts2.role}' was found here or in the registered project index.\n`));
1136
+ process.exit(1);
1137
+ }
1138
+ }
985
1139
  const provider = await buildProvider(cfg);
986
1140
  if (!provider) {
987
- out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
1141
+ out(c.red(`Not authenticated for provider '${cfg.provider}' at ${orgCwd}.\n`) + authHint(cfg) + "\n");
988
1142
  process.exit(1);
989
1143
  }
990
1144
  const stats = { input: 0, output: 0, lastInput: 0 };
991
- await runOrg(taskParts.join(" "), {
1145
+ const outcome = await runOrg(taskParts.join(" "), {
992
1146
  cfg,
993
1147
  baseProvider: provider,
994
- cwd: cfg.cwd,
1148
+ cwd: orgCwd,
995
1149
  sandbox: cfg.sandbox,
996
1150
  approval: "full-auto",
997
1151
  confirm: async () => true,
998
- projectContext: loadAgentsMd(cfg.cwd) || undefined,
1152
+ projectContext: loadAgentsMd(orgCwd) || undefined,
999
1153
  stats,
1000
- forceRole: opts2.role,
1154
+ forceRole,
1001
1155
  review: opts2.review,
1002
1156
  rounds: opts2.rounds,
1003
1157
  commit: opts2.commit,
1004
1158
  });
1159
+ const failure = runFailureDetail(outcome);
1160
+ if (failure) {
1161
+ process.stderr.write(`hara: org run failed (${outcome.status}) — ${failure}\n`);
1162
+ process.exitCode = 2;
1163
+ }
1005
1164
  if (stats.input || stats.output)
1006
1165
  out(statusLine(cfg.model, stats.input, stats.output) + "\n");
1007
1166
  });
1167
+ program
1168
+ .command("agents")
1169
+ .description("global agent index — every addressable agent (global roles + registered projects) and its home")
1170
+ .action(() => {
1171
+ const idx = buildAgentsIndex();
1172
+ if (!idx.length) {
1173
+ out(c.dim("(no agents — add roles to ~/.hara/roles, or register projects: hara projects add <name> <path>)\n"));
1174
+ return;
1175
+ }
1176
+ for (const e of idx) {
1177
+ out(c.bold(e.project ? `${e.project}:${e.name}` : `global:${e.name}`) + c.dim(e.home ? ` · ${e.home}` : " · current project") + "\n");
1178
+ if (e.description)
1179
+ out(c.dim(` ${e.description.slice(0, 120)}\n`));
1180
+ }
1181
+ out(c.dim(`\n${idx.length} agent(s). Run one: hara org --role <global:name|project:name> "<task>"\n`));
1182
+ });
1183
+ const projectsCmd = program.command("projects").description("register project homes for the global agent index (see `hara agents`)");
1184
+ projectsCmd.command("list").action(() => {
1185
+ const list = loadProjects();
1186
+ out(list.length ? list.map((p) => `${c.bold(p.name)} ${c.dim(p.path)}`).join("\n") + "\n" : c.dim("(none — hara projects add <name> <path>)\n"));
1187
+ });
1188
+ projectsCmd.command("add <name> <path>").action((name, path) => {
1189
+ const err = addProject(name, resolve(path));
1190
+ out(err ? c.red(`${err}\n`) : c.green(`✓ registered ${name} → ${resolve(path)}\n`));
1191
+ if (err)
1192
+ process.exit(1);
1193
+ });
1194
+ projectsCmd.command("remove <name>").action((name) => {
1195
+ out(removeProject(name) ? c.green(`✓ removed ${name}\n`) : c.yellow(`no project '${name}'\n`));
1196
+ });
1008
1197
  program
1009
1198
  .command("plan [task...]")
1010
1199
  .description("decompose a task into atoms, sequence them (DAG), and execute each with a verify gate")
@@ -1029,12 +1218,20 @@ program
1029
1218
  parallel: opts.parallel,
1030
1219
  };
1031
1220
  const task = (taskParts ?? []).join(" ").trim();
1221
+ let outcome;
1032
1222
  if (task === "resume")
1033
- await runResume(o);
1223
+ outcome = await runResume(o);
1034
1224
  else if (!task)
1035
1225
  out(c.dim('usage: hara plan "<task>" (or: hara plan resume)\n'));
1036
1226
  else
1037
- await runPlan(task, o);
1227
+ outcome = await runPlan(task, o);
1228
+ if (outcome) {
1229
+ const failure = runFailureDetail(outcome);
1230
+ if (failure) {
1231
+ process.stderr.write(`hara: plan run failed (${outcome.status}) — ${failure}\n`);
1232
+ process.exitCode = 2;
1233
+ }
1234
+ }
1038
1235
  if (stats.input || stats.output)
1039
1236
  out(statusLine(cfg.model, stats.input, stats.output) + "\n");
1040
1237
  });
@@ -1387,14 +1584,14 @@ profileCmd
1387
1584
  profileCmd
1388
1585
  .command("pin [id]")
1389
1586
  .description("write `.hara-profile` in this dir to lock the active profile here (omit id = pin current active)")
1390
- .action((id) => {
1587
+ .action(async (id) => {
1391
1588
  const target = (id && id.trim()) || activeId();
1392
1589
  if (!getProfile(target)) {
1393
1590
  out(c.red(`No profile '${target}'.\n`) + c.dim("List: `hara profile list`\n"));
1394
1591
  process.exit(1);
1395
1592
  }
1396
1593
  try {
1397
- const { file } = writePin(process.cwd(), target);
1594
+ const { file } = await writePin(process.cwd(), target);
1398
1595
  out(c.green(`✓ pinned ${target} to ${relPath(file)}\n`));
1399
1596
  // .hara-profile carries personal identity (which org you're as), unlike .nvmrc which
1400
1597
  // is project-level. Nudge user toward GLOBAL gitignore so they don't accidentally
@@ -1577,21 +1774,37 @@ program
1577
1774
  // `hara serve` is persistent, but config.json is user-editable at any time. Re-read it for every
1578
1775
  // new/resumed session and model operation so a repaired/rotated key takes effect without restarting
1579
1776
  // the desktop server (and, critically, never ask for a key that is already on disk).
1580
- buildSessionProvider: async () => {
1581
- const live = loadConfig();
1777
+ buildSessionProvider: async (targetCwd) => {
1778
+ const live = loadConfig({ cwd: targetCwd ?? cwd });
1582
1779
  return withRouting(await buildProvider(live), live);
1583
1780
  },
1584
- buildProviderFor: async (model, effort) => {
1585
- const live = loadConfig();
1781
+ buildProviderFor: async (model, effort, targetCwd) => {
1782
+ const live = loadConfig({ cwd: targetCwd ?? cwd });
1586
1783
  return withRouting(await buildProvider({ ...live, model, reasoningEffort: effort ?? live.reasoningEffort }), live);
1587
1784
  },
1588
- listModels: () => {
1589
- const live = loadConfig();
1785
+ listModels: (targetCwd) => {
1786
+ const live = loadConfig({ cwd: targetCwd ?? cwd });
1590
1787
  return listModels(live.baseURL ?? providerDefaultBaseURL(live.provider), live.apiKey ?? "");
1591
1788
  },
1592
1789
  effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning).filter((e) => !!e),
1593
- spawnSubagent: (provider, scwd, projectContext, stats, task, role) => runSubagent(cfg, provider, scwd, sandbox, projectContext, stats, task, role),
1790
+ runtimeInfo: (targetCwd) => {
1791
+ const live = loadConfig({ cwd: targetCwd ?? cwd });
1792
+ return {
1793
+ providerId: live.provider,
1794
+ model: live.model,
1795
+ effortLevels: levelsFor(resolvePlatform(live.provider, live.baseURL ?? providerDefaultBaseURL(live.provider)).reasoning).filter((e) => !!e),
1796
+ };
1797
+ },
1798
+ spawnSubagent: (provider, scwd, projectContext, stats, task, role) => {
1799
+ const live = loadConfig({ cwd: scwd });
1800
+ return runSubagent(live, provider, scwd, sandbox, projectContext, stats, task, role);
1801
+ },
1594
1802
  guardian: guardianOpt,
1803
+ buildGuardian: async (targetCwd) => {
1804
+ const live = loadConfig({ cwd: targetCwd ?? cwd });
1805
+ const base = await withRouting(await buildProvider(live), live);
1806
+ return buildGuardian(live, base);
1807
+ },
1595
1808
  sandbox,
1596
1809
  approval,
1597
1810
  });
@@ -1707,7 +1920,7 @@ program
1707
1920
  });
1708
1921
  program
1709
1922
  .command("review")
1710
- .description("review your uncommitted changes (git diff) for bugs, security, and missing tests")
1923
+ .description("review your uncommitted changes for bugs, security, and missing tests")
1711
1924
  .option("--staged", "review only staged changes")
1712
1925
  .option("--base <ref>", "review against a base ref (e.g. main) instead of just the working tree")
1713
1926
  .action(async (opts) => {
@@ -1717,25 +1930,29 @@ program
1717
1930
  out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
1718
1931
  process.exit(1);
1719
1932
  }
1720
- const cmd = opts.base ? `git diff ${opts.base}` : opts.staged ? "git diff --staged" : "git diff HEAD";
1721
- let diff = "";
1722
- try {
1723
- diff = (await runShell(cmd, cfg.cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
1933
+ const changes = captureChanges(cfg.cwd, 120_000, {
1934
+ staged: opts.staged,
1935
+ base: opts.base,
1936
+ includeUntracked: !opts.staged && !opts.base,
1937
+ });
1938
+ if (changes.error)
1939
+ return void out(c.red(`Git change capture failed closed: ${changes.error}\n`) + c.dim("(is this a git repo / valid base ref?)\n"));
1940
+ if (!changes.diff && !changes.newFiles.length && !changes.skippedFiles.length && !changes.omittedDeletions.length) {
1941
+ return void out(c.dim("No changes to review.\n"));
1724
1942
  }
1725
- catch (e) {
1726
- return void out(c.red(`\`${cmd}\` failed: ${e instanceof Error ? e.message : String(e)}\n`) + c.dim("(is this a git repo?)\n"));
1943
+ if (changes.skippedFiles.length) {
1944
+ out(c.yellow(`Protected paths omitted: ${changes.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}\n`));
1727
1945
  }
1728
- if (!diff.trim())
1729
- return void out(c.dim(`No changes to review (${cmd}).\n`));
1730
- out(c.dim(`Reviewing \`${cmd}\` (${diff.split("\n").length} diff lines)…\n\n`));
1946
+ out(c.dim(`Reviewing ${changes.diff ? changes.diff.split("\n").length : 0} safe change entries…\n\n`));
1731
1947
  const stats = { input: 0, output: 0, lastInput: 0 };
1732
- await runAgent([{ role: "user", content: `Review this diff:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }], {
1948
+ await runAgent([{ role: "user", content: standaloneReviewPrompt(changes) }], {
1733
1949
  provider,
1734
1950
  ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
1735
1951
  approval: "full-auto",
1736
1952
  confirm: async () => true,
1737
1953
  systemOverride: REVIEW_SYSTEM,
1738
1954
  toolFilter: (n) => READONLY_TOOLS.has(n), // read-only: the reviewer can inspect, never edit
1955
+ hooks: false,
1739
1956
  projectContext: loadAgentsMd(cfg.cwd) || undefined,
1740
1957
  memory: memoryDigest(cfg.cwd),
1741
1958
  stats,
@@ -1756,6 +1973,10 @@ program
1756
1973
  process.exit(1);
1757
1974
  }
1758
1975
  if (opts.all) {
1976
+ const protectedTracked = protectedTrackedWorkingTreePaths(cfg.cwd);
1977
+ if (protectedTracked.length) {
1978
+ return void out(c.red(`Refusing to stage protected path(s): ${protectedTracked.map((p) => JSON.stringify(p)).join(", ")}\n`));
1979
+ }
1759
1980
  try {
1760
1981
  await runShell("git add -u", cfg.cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
1761
1982
  }
@@ -1763,19 +1984,23 @@ program
1763
1984
  /* report below if nothing is staged */
1764
1985
  }
1765
1986
  }
1766
- let diff = "";
1767
- try {
1768
- diff = (await runShell("git diff --staged", cfg.cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
1987
+ const protectedStaged = protectedStagedPaths(cfg.cwd);
1988
+ if (protectedStaged.length) {
1989
+ return void out(c.red(`Refusing to inspect or commit protected staged path(s): ${protectedStaged.map((p) => JSON.stringify(p)).join(", ")}\n`));
1769
1990
  }
1770
- catch (e) {
1771
- return void out(c.red(`git diff failed: ${e instanceof Error ? e.message : String(e)}\n`) + c.dim("(is this a git repo?)\n"));
1991
+ const staged = captureChanges(cfg.cwd, 120_000, { staged: true, includeUntracked: false });
1992
+ if (staged.error)
1993
+ return void out(c.red(`git diff failed closed: ${staged.error}\n`) + c.dim("(is this a git repo?)\n"));
1994
+ if (staged.skippedFiles.length) {
1995
+ return void out(c.red(`Refusing to inspect or commit protected staged path(s): ${staged.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}\n`));
1772
1996
  }
1773
- if (!diff.trim())
1997
+ const changeInput = commitMessageInput(staged);
1998
+ if (!changeInput.trim())
1774
1999
  return void out(c.dim("Nothing staged. Stage changes with `git add`, or use `hara commit -a`.\n"));
1775
2000
  out(c.dim("Writing a commit message…\n"));
1776
2001
  const r = await provider.turn({
1777
2002
  system: COMMIT_SYSTEM,
1778
- history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }],
2003
+ history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n${changeInput.slice(0, 120_000)}` }],
1779
2004
  tools: [],
1780
2005
  onText: () => { },
1781
2006
  });
@@ -1795,6 +2020,10 @@ program
1795
2020
  const tmp = join(tmpdir(), `hara-commit-${process.pid}.txt`);
1796
2021
  writeFileSync(tmp, msg + "\n", "utf8");
1797
2022
  try {
2023
+ const protectedBeforeCommit = protectedStagedPaths(cfg.cwd);
2024
+ if (protectedBeforeCommit.length) {
2025
+ return void out(c.red(`Staged paths changed; refusing to commit protected path(s): ${protectedBeforeCommit.map((p) => JSON.stringify(p)).join(", ")}\n`));
2026
+ }
1798
2027
  const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cfg.cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
1799
2028
  out(c.green("✓ committed ") + c.dim(((res.stdout || "").trim().split("\n")[0] || "").slice(0, 100)) + "\n");
1800
2029
  }
@@ -1943,8 +2172,8 @@ memoryCmd.command("show").description("print the memory digest injected at sessi
1943
2172
  const d = memoryDigest(process.cwd());
1944
2173
  out(d ? d + "\n" : c.dim("(memory is empty — `hara memory init`, or let the agent write via memory_write)\n"));
1945
2174
  });
1946
- memoryCmd.command("init").description("scaffold the memory dirs + seed files (global + project)").action(() => {
1947
- const w = scaffoldMemory(process.cwd());
2175
+ memoryCmd.command("init").description("scaffold the memory dirs + seed files (global + project)").action(async () => {
2176
+ const w = await scaffoldMemory(process.cwd());
1948
2177
  out(w.length ? c.green(`Scaffolded: ${w.join(", ")}\n`) : c.dim("Memory already scaffolded.\n"));
1949
2178
  });
1950
2179
  memoryCmd
@@ -2140,8 +2369,8 @@ const skillsCmd = program.command("skills").description("manage skills (.hara/sk
2140
2369
  skillsCmd
2141
2370
  .command("init")
2142
2371
  .description("scaffold an example skill")
2143
- .action(() => {
2144
- const written = scaffoldSkills(process.cwd());
2372
+ .action(async () => {
2373
+ const written = await scaffoldSkills(process.cwd());
2145
2374
  out(written.length
2146
2375
  ? c.green(`Created an example skill: ${written.join(", ")}\n`)
2147
2376
  : c.dim("Skills already exist in .hara/skills/.\n"));
@@ -2286,7 +2515,48 @@ program.action(async (opts) => {
2286
2515
  // hook above — see setFlagOverride() + resolveActive() in profile.ts. activeId() / loadActiveProfile()
2287
2516
  // pick it up automatically. `HARA_PROFILE` env still works as a transient override (one slot lower
2288
2517
  // in the priority chain than --profile).
2289
- const cfg = loadConfig({ overlay: opts.overlay });
2518
+ // Resolve addressable headless roles BEFORE loading config/provider/MCP. A qualified project role is
2519
+ // an execution-home selection, so every downstream route (credentials, model, AGENTS.md, tools) must be
2520
+ // constructed from that home rather than from the shell directory that happened to launch hara.
2521
+ let requestedHeadlessAgent;
2522
+ if (opts.print && opts.role) {
2523
+ const ref = String(opts.role).trim();
2524
+ const isLocalRole = !ref.includes(":") && loadRoles(process.cwd()).some((role) => role.id === ref);
2525
+ if (!isLocalRole) {
2526
+ const hit = resolveAgent(ref, process.cwd());
2527
+ if (hit && "ambiguous" in hit) {
2528
+ process.stderr.write(`hara: role '${ref}' is ambiguous; choose one of: ${hit.ambiguous.map((entry) => `${entry.project}:${entry.name}`).join(", ")}\n`);
2529
+ process.exitCode = 2;
2530
+ return;
2531
+ }
2532
+ if (!hit) {
2533
+ process.stderr.write(`hara: no agent '${ref}' was found locally, globally, or in the registered project index.\n`);
2534
+ process.exitCode = 2;
2535
+ return;
2536
+ }
2537
+ requestedHeadlessAgent = hit;
2538
+ }
2539
+ }
2540
+ const cfg = loadConfig({ overlay: opts.overlay, ...(requestedHeadlessAgent?.home ? { cwd: requestedHeadlessAgent.home } : {}) });
2541
+ const cwd = cfg.cwd;
2542
+ // Resolve the concrete role before constructing any user/plugin MCP transport. MCP servers are arbitrary
2543
+ // stdio subprocesses, so a read-only persona must not start them merely by launching a turn. Reusing this
2544
+ // object later also closes the resolve→connect→re-resolve race where a role could disappear or change policy.
2545
+ let requestedHeadlessRole;
2546
+ if (opts.print && opts.role) {
2547
+ const requestedRole = String(opts.role).trim();
2548
+ requestedHeadlessRole = requestedHeadlessAgent?.project
2549
+ ? loadRoles(requestedHeadlessAgent.home).find((candidate) => candidate.id === requestedHeadlessAgent.name)
2550
+ : requestedHeadlessAgent
2551
+ ? loadGlobalRoles().find((candidate) => candidate.id === requestedHeadlessAgent.name)
2552
+ : loadRoles(cwd).find((candidate) => candidate.id === requestedRole);
2553
+ if (!requestedHeadlessRole) {
2554
+ process.stderr.write(`hara: role '${opts.role}' disappeared from its declared home (${cwd}); refusing to start providers or MCP servers under the wrong persona.\n`);
2555
+ process.exitCode = 2;
2556
+ return;
2557
+ }
2558
+ }
2559
+ const machineOutput = !!opts.print && !!opts.schema;
2290
2560
  if (opts.model)
2291
2561
  cfg.model = opts.model;
2292
2562
  const provider0 = await withRouting(await buildProvider(cfg), cfg);
@@ -2331,7 +2601,11 @@ program.action(async (opts) => {
2331
2601
  process.exit(0);
2332
2602
  }
2333
2603
  }
2334
- out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
2604
+ const message = `Not authenticated for provider '${cfg.provider}'.\n${authHint(cfg)}\n`;
2605
+ if (machineOutput)
2606
+ process.stderr.write(message);
2607
+ else
2608
+ out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
2335
2609
  process.exit(1);
2336
2610
  }
2337
2611
  let provider = provider0;
@@ -2350,146 +2624,323 @@ program.action(async (opts) => {
2350
2624
  void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
2351
2625
  void syncOrgRoles(); // refresh governed org-role bundle (B3) in the background; best-effort, never blocks
2352
2626
  }
2353
- const cwd = cfg.cwd;
2354
2627
  let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
2355
2628
  let currentTurn = null; // set during a running turn so Esc can abort it
2356
2629
  const autoApprove = new Set(); // tools the user chose "don't ask again" for, this session
2357
2630
  let recalledContext = ""; // snippets queued by /recall, prepended to the next message
2358
2631
  const sandbox = opts.sandbox || cfg.sandbox;
2359
2632
  if (sandbox !== "off" && !sandboxSupported()) {
2360
- out(c.yellow(`(sandbox '${sandbox}' is macOS-only; shell runs unsandboxed here)\n`));
2633
+ const message = `(sandbox '${sandbox}' is macOS-only; shell runs unsandboxed here)\n`;
2634
+ if (machineOutput)
2635
+ process.stderr.write(message);
2636
+ else
2637
+ out(c.yellow(message));
2361
2638
  }
2362
2639
  const stats = { input: 0, output: 0, lastInput: 0 };
2640
+ // Connecting to MCP executes configured external commands before an MCP tool is selected, so the ordinary
2641
+ // per-tool confirmation is too late. Obtain one explicit startup grant for the named server set. askConfirm
2642
+ // uses a short-lived Ink prompt and restores stdin, making it safe before either the main TUI or readline REPL
2643
+ // owns the terminal. Non-interactive runs remain closed unless the user opted in before process launch.
2363
2644
  const mcpAll = { ...pluginMcpServers(), ...cfg.mcpServers }; // user config wins over plugin-contributed servers
2364
- if (Object.keys(mcpAll).length) {
2365
- await connectMcpServers(mcpAll, (m) => out(c.dim(m + "\n")));
2645
+ const mcpNames = Object.keys(mcpAll);
2646
+ if (!requestedHeadlessRole?.readOnly && mcpNames.length) {
2647
+ const trustedOptIn = process.env.HARA_ALLOW_TRUSTED_EXTENSIONS === "1";
2648
+ const interactiveTerminal = !opts.print && !!stdin.isTTY && !!stdout.isTTY;
2649
+ const approved = trustedOptIn ||
2650
+ (interactiveTerminal &&
2651
+ (await askConfirm(`Start configured MCP server${mcpNames.length === 1 ? "" : "s"} ${mcpNames
2652
+ .slice(0, 8)
2653
+ .map((name) => `'${name.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80)}'`)
2654
+ .join(", ")}${mcpNames.length > 8 ? ` and ${mcpNames.length - 8} more` : ""}? ` +
2655
+ "They execute external code outside Hara's protected-file boundary.", false)));
2656
+ if (approved) {
2657
+ await connectMcpServers(mcpAll, (m) => machineOutput ? process.stderr.write(m + "\n") : out(c.dim(m + "\n")), { approved: true });
2658
+ }
2659
+ else {
2660
+ const message = interactiveTerminal
2661
+ ? "hara: MCP server startup declined; no configured MCP command was executed.\n"
2662
+ : "hara: MCP servers skipped because this run has no interactive startup approval. " +
2663
+ "Set HARA_ALLOW_TRUSTED_EXTENSIONS=1 before launch only for reviewed servers.\n";
2664
+ if (machineOutput || !interactiveTerminal)
2665
+ process.stderr.write(message);
2666
+ else
2667
+ out(c.dim(message));
2668
+ }
2366
2669
  }
2367
2670
  // one-shot
2368
2671
  if (opts.print) {
2369
- const projectContext = loadAgentsMd(cwd) || undefined;
2370
- // Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
2371
- // "configure a vision model" even when one is set, leaving a headless agent blind. Mirrors the interactive
2372
- // describeScreenshot a configured visionModel, else the main model if it's vision-capable.
2373
- const describeImage = async (path, hint) => {
2374
- const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
2375
- const vp = cfg.visionModel
2376
- ? ((await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey })) ?? null)
2377
- : cap === "vision"
2378
- ? provider
2379
- : null;
2380
- if (!vp)
2381
- return "";
2382
- try {
2383
- return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
2384
- }
2385
- catch {
2386
- return "";
2387
- }
2388
- };
2389
- // Headless session continuity: --resume <id> / --continue loads the session, appends this prompt, and
2390
- // saves it back — so `hara -p … --resume <id>` continues a thread (used by cron, scripts, the chat gateway).
2391
- // Plain `hara -p` stays stateless. A --resume id with no match is created WITH that id (stable per caller).
2392
- let meta = null;
2393
- const history = [];
2394
- if (opts.resume || opts.continue) {
2395
- const rid = opts.resume ? (resolveSessionId(opts.resume) ?? opts.resume) : latestForCwd(cwd)?.meta.id;
2396
- const prior = rid ? loadSession(rid) : null;
2397
- if (prior?.history)
2398
- history.push(...prior.history);
2399
- // Stamp who created this session (cron runner sets HARA_CRON, gateway sets HARA_GATEWAY) and give
2400
- // automated sessions a "name · time" title UP FRONT — the raw prompt must never become the title.
2401
- const src = sessionSourceFromEnv();
2402
- meta = prior?.meta ?? {
2403
- id: rid ?? newSessionId(),
2404
- cwd,
2405
- provider: cfg.provider,
2406
- model: cfg.model,
2407
- title: src.source === "interactive" ? "" : automatedTitle(src.source, src.sourceName),
2408
- createdAt: new Date().toISOString(),
2409
- updatedAt: "",
2410
- ...(src.source !== "interactive" ? { source: src.source, sourceName: src.sourceName } : { source: "interactive" }),
2672
+ let headlessLockId = null;
2673
+ try {
2674
+ const projectContext = loadAgentsMd(cwd) || undefined;
2675
+ // Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
2676
+ // "configure a vision model" even when one is set, leaving a headless agent blind. Mirrors the interactive
2677
+ // describeScreenshot a configured visionModel, else the main model if it's vision-capable.
2678
+ const describeImage = async (path, hint) => {
2679
+ const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
2680
+ const vp = cfg.visionModel
2681
+ ? ((await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey })) ?? null)
2682
+ : cap === "vision"
2683
+ ? provider
2684
+ : null;
2685
+ if (!vp)
2686
+ return "";
2687
+ try {
2688
+ return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
2689
+ }
2690
+ catch {
2691
+ return "";
2692
+ }
2411
2693
  };
2412
- // Apply per-session pinned model on headless resume (mirrors the interactive path).
2413
- // --model flag wins (already on cfg.model) and is written back; otherwise restore meta.model.
2414
- if (prior) {
2415
- if (opts.model) {
2416
- meta.model = cfg.model;
2694
+ // Headless session continuity: --resume <id> / --continue loads the session, appends this prompt, and
2695
+ // saves it back — so `hara -p … --resume <id>` continues a thread (used by cron, scripts, the chat gateway).
2696
+ // Plain `hara -p` stays stateless. A --resume id with no match is created WITH that id (stable per caller).
2697
+ let meta = null;
2698
+ const history = [];
2699
+ if (opts.resume || opts.continue) {
2700
+ const resumeArg = opts.resume ? String(opts.resume) : undefined;
2701
+ if (resumeArg && !validSessionId(resumeArg)) {
2702
+ process.stderr.write("hara: --resume contains an invalid session id. Use an id shown by `hara sessions`.\n");
2703
+ process.exitCode = 2;
2704
+ return;
2417
2705
  }
2418
- else if (meta.model && meta.model !== cfg.model) {
2419
- const __allowed = __activeP.kind === "gateway" && __activeP.availableModels && __activeP.availableModels.length > 0;
2420
- if (__allowed && !__activeP.availableModels.includes(meta.model)) {
2421
- const __fb = __activeP.defaultModel || cfg.model;
2422
- // headless: log to stderr so it doesn't pollute the captured stdout reply
2423
- try {
2424
- process.stderr.write(`hara: resumed session pinned '${meta.model}' not in availableModels — falling back to '${__fb}'.\n`);
2706
+ const resolvedResume = resumeArg ? resolveSessionId(resumeArg) : null;
2707
+ if (resumeArg && !resolvedResume) {
2708
+ const prefixMatches = listSessions().filter((session) => session.id.startsWith(resumeArg));
2709
+ if (prefixMatches.length > 1) {
2710
+ process.stderr.write(`hara: session prefix '${resumeArg}' is ambiguous; use more characters.\n`);
2711
+ process.exitCode = 2;
2712
+ return;
2713
+ }
2714
+ }
2715
+ const rid = (resumeArg ? (resolvedResume ?? resumeArg) : latestForCwd(cwd)?.meta.id) ?? newSessionId();
2716
+ const lock = acquireSessionLock(rid);
2717
+ if (!lock.ok) {
2718
+ process.stderr.write(`hara: session ${shortId(rid)} is already open in another process (pid ${lock.pid ?? "unknown"}); refusing a concurrent headless resume.\n`);
2719
+ process.exitCode = 1;
2720
+ await closeMcp();
2721
+ return;
2722
+ }
2723
+ headlessLockId = rid;
2724
+ // Re-read only after acquiring the single-writer lock. Loading first would leave a race window where
2725
+ // another gateway/cron process appends history that this stale snapshot later overwrites.
2726
+ const prior = loadSession(rid);
2727
+ if (sessionFileExists(rid) && !prior) {
2728
+ process.stderr.write(`hara: session ${shortId(rid)} exists but is unreadable or corrupt; refusing to overwrite it. Inspect ~/.hara/sessions/${rid}.json.\n`);
2729
+ process.exitCode = 2;
2730
+ return;
2731
+ }
2732
+ if (prior && canonicalProjectPath(prior.meta.cwd) !== canonicalProjectPath(cwd)) {
2733
+ process.stderr.write(`hara: session ${shortId(rid)} belongs to ${prior.meta.cwd}, but this run is rooted at ${cwd}; refusing to resume across project homes. Run hara from the session's project directory instead.\n`);
2734
+ process.exitCode = 2;
2735
+ return;
2736
+ }
2737
+ if (prior && requestedHeadlessAgent?.project) {
2738
+ const priorHome = canonicalProjectPath(prior.meta.cwd);
2739
+ if (priorHome !== requestedHeadlessAgent.home) {
2740
+ process.stderr.write(`hara: session ${shortId(rid)} belongs to ${prior.meta.cwd}; refusing to resume it as ${requestedHeadlessAgent.project}:${requestedHeadlessAgent.name} at ${requestedHeadlessAgent.home}. Start a new role session or resume one from that home.\n`);
2741
+ process.exitCode = 2;
2742
+ return;
2743
+ }
2744
+ }
2745
+ if (prior?.history)
2746
+ history.push(...prior.history);
2747
+ // Stamp who created this session (cron runner sets HARA_CRON, gateway sets HARA_GATEWAY) and give
2748
+ // automated sessions a "name · time" title UP FRONT — the raw prompt must never become the title.
2749
+ const src = sessionSourceFromEnv();
2750
+ meta = prior?.meta ?? {
2751
+ id: rid,
2752
+ cwd,
2753
+ provider: cfg.provider,
2754
+ model: cfg.model,
2755
+ title: src.source === "interactive" ? "" : automatedTitle(src.source, src.sourceName),
2756
+ createdAt: new Date().toISOString(),
2757
+ updatedAt: "",
2758
+ ...(src.source !== "interactive" ? { source: src.source, sourceName: src.sourceName } : { source: "interactive" }),
2759
+ };
2760
+ // Task-state continuity: restore the persisted checklist, then mirror every change back onto meta
2761
+ // live — all existing saveSession() sites persist it for free (no per-site threading).
2762
+ restoreTodos(meta.todos);
2763
+ onTodosChange((list) => {
2764
+ if (meta)
2765
+ meta.todos = [...list];
2766
+ });
2767
+ // Apply per-session pinned model on headless resume (mirrors the interactive path).
2768
+ // --model flag wins (already on cfg.model) and is written back; otherwise restore meta.model.
2769
+ if (prior) {
2770
+ if (opts.model) {
2771
+ meta.model = cfg.model;
2772
+ }
2773
+ else if (meta.model && meta.model !== cfg.model) {
2774
+ const __allowed = __activeP.kind === "gateway" && __activeP.availableModels && __activeP.availableModels.length > 0;
2775
+ if (__allowed && !__activeP.availableModels.includes(meta.model)) {
2776
+ const __fb = __activeP.defaultModel || cfg.model;
2777
+ // headless: log to stderr so it doesn't pollute the captured stdout reply
2778
+ try {
2779
+ process.stderr.write(`hara: resumed session pinned '${meta.model}' not in availableModels — falling back to '${__fb}'.\n`);
2780
+ }
2781
+ catch { /* ignore */ }
2782
+ cfg.model = __fb;
2783
+ meta.model = __fb;
2784
+ const __rb = await buildProvider(cfg);
2785
+ if (__rb)
2786
+ provider = __rb;
2787
+ }
2788
+ else {
2789
+ cfg.model = meta.model;
2790
+ const __rb = await buildProvider(cfg);
2791
+ if (__rb)
2792
+ provider = __rb;
2425
2793
  }
2426
- catch { /* ignore */ }
2427
- cfg.model = __fb;
2428
- meta.model = __fb;
2429
- const __rb = await buildProvider(cfg);
2430
- if (__rb)
2431
- provider = __rb;
2432
2794
  }
2433
- else {
2434
- cfg.model = meta.model;
2435
- const __rb = await buildProvider(cfg);
2436
- if (__rb)
2437
- provider = __rb;
2795
+ }
2796
+ }
2797
+ // --schema: schema-enforced structured output. The schema (inline JSON or a file path) becomes a run-scoped
2798
+ // structured_output tool the model MUST call; stdout is then exactly that JSON (streaming suppressed), so
2799
+ // scripts / gateway flows / cron parse a guaranteed shape instead of regex-fishing prose.
2800
+ let schemaObj = null;
2801
+ if (opts.schema) {
2802
+ const rawArg = String(opts.schema);
2803
+ let raw = rawArg;
2804
+ if (existsSync(rawArg)) {
2805
+ try {
2806
+ raw = readModelContextFileSync(rawArg, 1024 * 1024);
2807
+ }
2808
+ catch (error) {
2809
+ process.stderr.write(`hara: refusing unsafe schema file: ${error instanceof Error ? error.message : String(error)}\n`);
2810
+ process.exitCode = 2;
2811
+ await closeMcp();
2812
+ return;
2438
2813
  }
2439
2814
  }
2815
+ const parsed = parseSchemaArg(raw);
2816
+ if ("error" in parsed) {
2817
+ process.stderr.write(`hara: ${parsed.error}\n`);
2818
+ process.exitCode = 2;
2819
+ await closeMcp();
2820
+ return;
2821
+ }
2822
+ schemaObj = parsed;
2440
2823
  }
2441
- }
2442
- // Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
2443
- // Let the agent actually SEE them attached inline for a vision-capable main model, else described via the
2444
- // visionModel sidecar and folded into the message (text-only models can't take image blocks).
2445
- const userText = expandMentions(String(opts.print), cwd);
2446
- const inboundImgs = (process.env.HARA_GATEWAY_IMAGES ?? "")
2447
- .split("\n")
2448
- .map((s) => s.trim())
2449
- .filter((p) => p && existsSync(p))
2450
- .map((p) => ({ path: p, mediaType: mediaTypeFor(p) ?? "image/jpeg" }));
2451
- if (inboundImgs.length && classifyVision(cfg.provider, cfg.model, cfg.modelVision) === "vision") {
2452
- history.push({ role: "user", content: userText, images: inboundImgs }); // native vision → inline
2453
- }
2454
- else if (inboundImgs.length && cfg.visionModel) {
2455
- let desc = "";
2456
- try {
2457
- const vp = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
2458
- if (vp)
2459
- desc = await describeImages(vp, inboundImgs);
2824
+ // Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
2825
+ // Let the agent actually SEE them attached inline for a vision-capable main model, else described via the
2826
+ // visionModel sidecar and folded into the message (text-only models can't take image blocks).
2827
+ const userText = expandMentions(String(opts.print), cwd) + (schemaObj ? STRUCTURED_INSTRUCTION : "");
2828
+ const inboundImgs = (process.env.HARA_GATEWAY_IMAGES ?? "")
2829
+ .split("\n")
2830
+ .map((s) => s.trim())
2831
+ .filter((p) => p && existsSync(p))
2832
+ .map((p) => ({ path: p, mediaType: mediaTypeFor(p) ?? "image/jpeg" }));
2833
+ if (inboundImgs.length && classifyVision(cfg.provider, cfg.model, cfg.modelVision) === "vision") {
2834
+ history.push({ role: "user", content: userText, images: inboundImgs }); // native vision → inline
2460
2835
  }
2461
- catch {
2462
- /* describe is best-effort — fall back to the marker-only text */
2836
+ else if (inboundImgs.length && cfg.visionModel) {
2837
+ let desc = "";
2838
+ try {
2839
+ const vp = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
2840
+ if (vp)
2841
+ desc = await describeImages(vp, inboundImgs);
2842
+ }
2843
+ catch {
2844
+ /* describe is best-effort — fall back to the marker-only text */
2845
+ }
2846
+ const n = inboundImgs.length;
2847
+ history.push({
2848
+ role: "user",
2849
+ content: desc ? `${userText}\n\n[${n} image${n > 1 ? "s" : ""} the user sent — described by ${cfg.visionModel}]\n${desc}` : userText,
2850
+ });
2463
2851
  }
2464
- const n = inboundImgs.length;
2465
- history.push({
2466
- role: "user",
2467
- content: desc ? `${userText}\n\n[${n} image${n > 1 ? "s" : ""} the user sent described by ${cfg.visionModel}]\n${desc}` : userText,
2468
- });
2852
+ else {
2853
+ history.push({ role: "user", content: userText });
2854
+ }
2855
+ // --role: run this headless turn AS an org role/agent persona (the gateway's /agent switch lands here).
2856
+ // Local roles resolve at cwd; qualified project agents were resolved before config/provider startup and
2857
+ // cwd is already their registered home. Explicit global roles remain portable in the current project.
2858
+ let roleOverride;
2859
+ let headlessProvider = provider;
2860
+ let headlessToolFilter;
2861
+ let headlessHooks = true;
2862
+ if (requestedHeadlessRole) {
2863
+ roleOverride = requestedHeadlessRole.system;
2864
+ headlessToolFilter = roleToolFilter(requestedHeadlessRole);
2865
+ if (requestedHeadlessRole.readOnly)
2866
+ headlessHooks = false;
2867
+ const roleModel = effectiveRoleModel(requestedHeadlessRole.model, cfg.model);
2868
+ if (roleModel && roleModel !== provider.model) {
2869
+ const selected = await buildProvider({ ...cfg, model: roleModel });
2870
+ if (!selected) {
2871
+ process.stderr.write(`hara: role '${opts.role}' requires model '${roleModel}', but that provider is not authenticated.\n`);
2872
+ process.exitCode = 2;
2873
+ await closeMcp();
2874
+ return;
2875
+ }
2876
+ headlessProvider = selected;
2877
+ }
2878
+ }
2879
+ let structured;
2880
+ let structuredSet = false;
2881
+ const printRunOpts = {
2882
+ provider: headlessProvider,
2883
+ ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, headlessProvider, cwd, sandbox, projectContext, stats, t, role), describeImage },
2884
+ approval: "full-auto",
2885
+ confirm: async () => true,
2886
+ projectContext,
2887
+ memory: memoryDigest(cwd),
2888
+ ...(roleOverride ? { systemOverride: roleOverride } : {}),
2889
+ ...(headlessToolFilter ? { toolFilter: headlessToolFilter } : {}),
2890
+ hooks: headlessHooks,
2891
+ stats,
2892
+ guardian: guardianOpt, // safety layer stays on in headless -p (fail-open; breaker aborts, never hangs)
2893
+ ...(schemaObj
2894
+ ? {
2895
+ extraTools: [structuredOutputTool(schemaObj, (v) => ((structured = v), (structuredSet = true)))],
2896
+ quiet: true, // stdout must be exactly the JSON — no streamed prose
2897
+ }
2898
+ : {}),
2899
+ };
2900
+ let runOutcome = await runAgent(history, printRunOpts);
2901
+ if (schemaObj) {
2902
+ // The tool call IS the answer — if the model finished without it, nudge and retry (bounded).
2903
+ for (let attempt = 0; attempt < 2 && !structuredSet && runOutcome.status === "completed"; attempt++) {
2904
+ history.push({ role: "user", content: STRUCTURED_NUDGE });
2905
+ runOutcome = await runAgent(history, printRunOpts);
2906
+ }
2907
+ const failure = runFailureDetail(runOutcome);
2908
+ if (failure) {
2909
+ // A valid structured_output call is provisional until the whole agent run completes. A later provider
2910
+ // error or safety halt must never be hidden behind stale-looking success JSON on stdout.
2911
+ process.stderr.write(`hara: structured run failed (${runOutcome.status}) — ${failure}\n`);
2912
+ process.exitCode = 2;
2913
+ }
2914
+ else if (structuredSet)
2915
+ out(JSON.stringify(structured) + "\n");
2916
+ else {
2917
+ process.stderr.write("hara: model never called structured_output — no result.\n");
2918
+ process.exitCode = 2;
2919
+ }
2920
+ }
2921
+ else {
2922
+ const failure = runFailureDetail(runOutcome);
2923
+ if (failure) {
2924
+ process.stderr.write(`hara: headless run failed (${runOutcome.status}) — ${failure}\n`);
2925
+ process.exitCode = 2;
2926
+ }
2927
+ }
2928
+ if (meta) {
2929
+ // Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
2930
+ // Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
2931
+ await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { });
2932
+ saveSession(meta, history); // persist when resuming/continuing; plain -p stays stateless
2933
+ }
2934
+ if (!schemaObj && runOutcome.status === "completed" && (stats.input || stats.output))
2935
+ out(statusLine(headlessProvider.model, stats.input, stats.output) + "\n");
2936
+ await closeMcp();
2937
+ return;
2469
2938
  }
2470
- else {
2471
- history.push({ role: "user", content: userText });
2939
+ finally {
2940
+ if (headlessLockId)
2941
+ releaseSessionLock(headlessLockId);
2942
+ await closeMcp();
2472
2943
  }
2473
- await runAgent(history, {
2474
- provider,
2475
- ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role), describeImage },
2476
- approval: "full-auto",
2477
- confirm: async () => true,
2478
- projectContext,
2479
- memory: memoryDigest(cwd),
2480
- stats,
2481
- guardian: guardianOpt, // safety layer stays on in headless -p (fail-open; breaker aborts, never hangs)
2482
- });
2483
- if (meta) {
2484
- // Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
2485
- // Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
2486
- await maybeAutoCompact(provider, history, meta, stats, cfg, () => { });
2487
- saveSession(meta, history); // persist when resuming/continuing; plain -p stays stateless
2488
- }
2489
- if (stats.input || stats.output)
2490
- out(statusLine(cfg.model, stats.input, stats.output) + "\n");
2491
- await closeMcp();
2492
- return;
2493
2944
  }
2494
2945
  // interactive REPL — ink TUI by default on a real terminal; HARA_TUI=0 forces the classic readline path
2495
2946
  const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
@@ -2568,20 +3019,43 @@ program.action(async (opts) => {
2568
3019
  let projectContext = loadAgentsMd(cwd) || undefined;
2569
3020
  const spawn = (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role);
2570
3021
  // session: --resume <id> / --continue (latest in this cwd) / new
2571
- let resumed = null;
3022
+ let resumeId = null;
2572
3023
  if (opts.resume) {
2573
3024
  const rid = resolveSessionId(opts.resume); // accept a full UUID or a unique prefix (short id)
2574
- resumed = rid ? loadSession(rid) : null;
2575
- if (!resumed)
3025
+ resumeId = rid;
3026
+ if (!resumeId)
2576
3027
  out(c.yellow(`(no session '${opts.resume}'; starting fresh)\n`));
2577
3028
  }
2578
3029
  else if (opts.continue) {
2579
- resumed = latestForCwd(cwd);
2580
- if (!resumed)
3030
+ resumeId = latestForCwd(cwd)?.meta.id ?? null;
3031
+ if (!resumeId)
2581
3032
  out(c.dim("(no prior session in this directory; starting fresh)\n"));
2582
3033
  }
3034
+ // Single-writer guard: two hara processes on the SAME session race writes to its append-only history and
3035
+ // corrupt it. Acquire BEFORE reading the history, then re-read under the lock so no stale snapshot can
3036
+ // overwrite a turn appended between load and lock.
3037
+ const sessionId = resumeId ?? newSessionId();
3038
+ const lock = acquireSessionLock(sessionId);
3039
+ if (!lock.ok) {
3040
+ out(c.red(`Session ${shortId(sessionId)} is already open in another hara process (pid ${lock.pid}).`) +
3041
+ c.dim(` Resuming the same session twice races writes and can corrupt its history. Close that one, or run \`hara\` for a new session. (Override: rm ~/.hara/sessions/${sessionId}.lock)\n`));
3042
+ process.exit(1);
3043
+ }
3044
+ process.on("exit", () => releaseSessionLock(sessionId));
3045
+ const resumed = resumeId ? loadSession(resumeId) : null;
3046
+ if (resumeId && sessionFileExists(resumeId) && !resumed) {
3047
+ releaseSessionLock(sessionId);
3048
+ out(c.red(`Session ${shortId(resumeId)} exists but is unreadable or corrupt; refusing to overwrite it. Inspect ~/.hara/sessions/${resumeId}.json.\n`));
3049
+ process.exit(2);
3050
+ }
3051
+ if (resumed && canonicalProjectPath(resumed.meta.cwd) !== canonicalProjectPath(cwd)) {
3052
+ releaseSessionLock(sessionId);
3053
+ out(c.red(`Session ${shortId(resumed.meta.id)} belongs to ${resumed.meta.cwd}, but this run is rooted at ${cwd}; refusing to resume across project homes.\n`) +
3054
+ c.dim(`Run hara from the session's project directory instead.\n`));
3055
+ process.exit(2);
3056
+ }
2583
3057
  const meta = resumed?.meta ?? {
2584
- id: newSessionId(),
3058
+ id: sessionId,
2585
3059
  cwd,
2586
3060
  provider: cfg.provider,
2587
3061
  model: cfg.model,
@@ -2590,16 +3064,11 @@ program.action(async (opts) => {
2590
3064
  updatedAt: "",
2591
3065
  source: "interactive",
2592
3066
  };
2593
- // Single-writer guard: two hara processes on the SAME session race writes to its append-only history and
2594
- // corrupt it. Lock it here (a brand-new session's id is unique, so this only ever blocks a DOUBLE-resume
2595
- // of a session already open elsewhere). Released on exit.
2596
- const lock = acquireSessionLock(meta.id);
2597
- if (!lock.ok) {
2598
- out(c.red(`Session ${shortId(meta.id)} is already open in another hara process (pid ${lock.pid}).`) +
2599
- c.dim(` Resuming the same session twice races writes and can corrupt its history. Close that one, or run \`hara\` for a new session. (Override: rm ~/.hara/sessions/${meta.id}.lock)\n`));
2600
- process.exit(1);
2601
- }
2602
- process.on("exit", () => releaseSessionLock(meta.id));
3067
+ // Task-state continuity (interactive twin of the -p path): restore the checklist, mirror changes onto meta.
3068
+ restoreTodos(meta.todos);
3069
+ onTodosChange((list) => {
3070
+ meta.todos = [...list];
3071
+ });
2603
3072
  // Per-session model precedence on resume:
2604
3073
  // 1. --model flag (already applied to cfg.model up-top) → wins and is written back to meta.model.
2605
3074
  // 2. resumed meta.model → restored into cfg.model (the user's last /model choice).
@@ -2929,7 +3398,22 @@ program.action(async (opts) => {
2929
3398
  out(c.green(`(renamed → ${meta.title})\n`));
2930
3399
  },
2931
3400
  },
2932
- { name: "reset", aliases: ["clear"], desc: "clear conversation context", run: () => void ((history.length = 0), (recalledContext = ""), out(c.dim("(context cleared)\n"))) },
3401
+ {
3402
+ name: "reset",
3403
+ aliases: ["clear"],
3404
+ desc: "clear conversation context",
3405
+ run: () => {
3406
+ history.length = 0;
3407
+ recalledContext = "";
3408
+ clearTodos();
3409
+ meta.todos = [];
3410
+ resetReachability();
3411
+ resetRepeatGuard();
3412
+ clearTouched();
3413
+ saveSession(meta, history);
3414
+ out(c.dim("(context cleared)\n"));
3415
+ },
3416
+ },
2933
3417
  { name: "exit", aliases: ["quit"], desc: "leave", run: () => "exit" },
2934
3418
  ];
2935
3419
  const byName = new Map();
@@ -3129,6 +3613,10 @@ program.action(async (opts) => {
3129
3613
  recalledContext = "";
3130
3614
  resetReachability(); // fresh start — drop any "host unreachable" marks (network may be fixed)
3131
3615
  resetRepeatGuard(); // …and the repeated-failure streaks (the user may have fixed the cause)
3616
+ clearTouched();
3617
+ clearTodos();
3618
+ meta.todos = [];
3619
+ saveSession(meta, history);
3132
3620
  return void h.sink.notice("(context cleared)");
3133
3621
  }
3134
3622
  if (nm === "undo") {
@@ -3306,24 +3794,25 @@ program.action(async (opts) => {
3306
3794
  return void h.sink.notice(r.startsWith("error:") ? `✗ ${r}` : r === "nothing to commit" ? "(nothing to commit — make or stage changes first)" : `✓ committed · ${r.slice(0, 100)}`);
3307
3795
  }
3308
3796
  if (nm === "review") {
3309
- let diff = "";
3310
- try {
3311
- diff = (await runShell("git diff HEAD", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
3797
+ const changes = captureChanges(cwd, 120_000, { includeUntracked: true });
3798
+ if (changes.error)
3799
+ return void h.sink.notice(`(review capture failed closed: ${changes.error})`);
3800
+ if (!changes.diff && !changes.newFiles.length && !changes.skippedFiles.length && !changes.omittedDeletions.length) {
3801
+ return void h.sink.notice("(nothing to review — no changes vs HEAD)");
3312
3802
  }
3313
- catch {
3314
- /* not a git repo empty */
3803
+ if (changes.skippedFiles.length) {
3804
+ h.sink.notice(`Protected paths omitted: ${changes.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}`);
3315
3805
  }
3316
- if (!diff.trim())
3317
- return void h.sink.notice("(nothing to review — no changes vs HEAD)");
3318
3806
  const rui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
3319
3807
  const xin = stats.input;
3320
3808
  const xout = stats.output;
3321
- await runAgent([{ role: "user", content: `Review this diff:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }], {
3809
+ await runAgent([{ role: "user", content: standaloneReviewPrompt(changes) }], {
3322
3810
  provider,
3323
3811
  ctx: { cwd, sandbox, ui: rui },
3324
3812
  approval: "full-auto", // read-only via the tool filter, so nothing prompts
3325
3813
  confirm: h.confirm,
3326
3814
  toolFilter: (n) => READONLY_TOOLS.has(n),
3815
+ hooks: false,
3327
3816
  systemOverride: REVIEW_SYSTEM,
3328
3817
  memory: buildMemory(),
3329
3818
  stats,
@@ -3422,6 +3911,7 @@ program.action(async (opts) => {
3422
3911
  approval: "suggest",
3423
3912
  confirm: h.confirm,
3424
3913
  toolFilter: (n) => READONLY_TOOLS.has(n),
3914
+ hooks: false,
3425
3915
  extraTools: [exitPlanTool],
3426
3916
  systemOverride: PLAN_SYSTEM,
3427
3917
  memory: buildMemory(),