hippo-memory 1.57.0 → 1.59.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.
Files changed (109) hide show
  1. package/README.md +24 -1
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.js +1 -1
  4. package/dist/agent-memories/gemini.js +1 -1
  5. package/dist/agent-memories/legacy.js +4 -1
  6. package/dist/api-errors.d.ts +27 -0
  7. package/dist/api-errors.js +37 -0
  8. package/dist/api.d.ts +5 -5
  9. package/dist/api.js +40 -47
  10. package/dist/audit.d.ts +5 -1
  11. package/dist/audit.js +13 -0
  12. package/dist/autolearn.d.ts +1 -1
  13. package/dist/autolearn.js +7 -5
  14. package/dist/capture-contract.d.ts +47 -0
  15. package/dist/capture-contract.js +49 -0
  16. package/dist/capture-error.js +2 -1
  17. package/dist/capture.d.ts +0 -13
  18. package/dist/capture.js +5 -66
  19. package/dist/cli/output.d.ts +3 -0
  20. package/dist/cli/output.js +7 -0
  21. package/dist/cli/projects.d.ts +4 -0
  22. package/dist/cli/projects.js +90 -0
  23. package/dist/cli/shared.js +23 -13
  24. package/dist/cli/sleep.js +5 -3
  25. package/dist/cli.d.ts +1 -0
  26. package/dist/cli.js +486 -397
  27. package/dist/client.js +9 -0
  28. package/dist/codex-patch.js +1 -1
  29. package/dist/compaction-record.d.ts +1 -1
  30. package/dist/compaction-record.js +3 -2
  31. package/dist/config.d.ts +5 -0
  32. package/dist/config.js +17 -0
  33. package/dist/connectors/github/dlq.js +5 -2
  34. package/dist/connectors/github/octokit-client.js +4 -2
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/consolidate.d.ts +10 -0
  38. package/dist/consolidate.js +48 -35
  39. package/dist/customer-notes.js +14 -13
  40. package/dist/dag.js +7 -4
  41. package/dist/dashboard.js +1 -1
  42. package/dist/db.d.ts +12 -0
  43. package/dist/db.js +62 -1
  44. package/dist/decisions.js +9 -8
  45. package/dist/dedupe.js +1 -1
  46. package/dist/doctor.js +28 -0
  47. package/dist/dormant.d.ts +2 -2
  48. package/dist/embedding-provider.js +3 -3
  49. package/dist/embeddings.d.ts +4 -4
  50. package/dist/embeddings.js +72 -16
  51. package/dist/extract.js +19 -18
  52. package/dist/http-retry.d.ts +21 -0
  53. package/dist/http-retry.js +50 -0
  54. package/dist/http-util.d.ts +8 -0
  55. package/dist/http-util.js +10 -0
  56. package/dist/importers.d.ts +2 -0
  57. package/dist/importers.js +16 -5
  58. package/dist/incidents.js +11 -10
  59. package/dist/judgment.js +10 -17
  60. package/dist/log.d.ts +25 -0
  61. package/dist/log.js +48 -0
  62. package/dist/mcp/server.js +52 -24
  63. package/dist/mcp/tool-args.d.ts +21 -0
  64. package/dist/mcp/tool-args.js +80 -0
  65. package/dist/memory.js +3 -2
  66. package/dist/overlap-index.d.ts +7 -0
  67. package/dist/overlap-index.js +38 -0
  68. package/dist/pilot-arm.d.ts +9 -0
  69. package/dist/pilot-arm.js +47 -0
  70. package/dist/policies.js +12 -11
  71. package/dist/predictions.js +9 -8
  72. package/dist/processes.js +14 -13
  73. package/dist/project-briefs.js +16 -15
  74. package/dist/project-identity.d.ts +1 -1
  75. package/dist/project-identity.js +25 -1
  76. package/dist/project-merge.d.ts +52 -0
  77. package/dist/project-merge.js +168 -0
  78. package/dist/raw-archive.js +7 -6
  79. package/dist/recall-scope.d.ts +5 -4
  80. package/dist/recall-scope.js +7 -5
  81. package/dist/refine-llm.js +3 -2
  82. package/dist/reject-flow.js +6 -9
  83. package/dist/rejection.d.ts +2 -1
  84. package/dist/rejection.js +2 -1
  85. package/dist/rerankers/clef.d.ts +29 -0
  86. package/dist/rerankers/clef.js +182 -0
  87. package/dist/rerankers/index.js +3 -0
  88. package/dist/rerankers/jev.d.ts +11 -0
  89. package/dist/rerankers/jev.js +10 -5
  90. package/dist/rerankers/types.d.ts +16 -0
  91. package/dist/search.js +14 -2
  92. package/dist/secret-detect.d.ts +13 -1
  93. package/dist/secret-detect.js +33 -1
  94. package/dist/server.d.ts +9 -2
  95. package/dist/server.js +188 -411
  96. package/dist/session-digest.js +2 -1
  97. package/dist/shared.js +7 -6
  98. package/dist/skills.js +15 -14
  99. package/dist/store.js +10 -10
  100. package/dist/token-ledger.d.ts +4 -2
  101. package/dist/token-ledger.js +2 -2
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  105. package/extensions/openclaw-plugin/package.json +1 -1
  106. package/openclaw.plugin.json +1 -1
  107. package/package.json +5 -2
  108. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  109. package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/cli.js CHANGED
@@ -20,6 +20,7 @@
20
20
  * hippo rejections
21
21
  * hippo unreject <digest-prefix>
22
22
  * hippo dormant [<query>] [--limit <n>] [--json] | restore <id> | forget <id>
23
+ * hippo projects [--json] | merge <from> <into> [--apply] | repair [--apply]
23
24
  * hippo tokens [--days <n>] [--json] [--global]
24
25
  * hippo doctor [--json]
25
26
  * hippo inspect <id>
@@ -59,7 +60,8 @@ import { resolveEmbeddingProvider } from './embedding-provider.js';
59
60
  import { loadPhysicsState, resetAllPhysicsState } from './physics-state.js';
60
61
  import { computeSystemEnergy, vecNorm } from './physics.js';
61
62
  import { loadConfig } from './config.js';
62
- import { openHippoDb, closeHippoDb } from './db.js';
63
+ import { ensurePilotArm, hashArm, readPilotArm } from './pilot-arm.js';
64
+ import { openHippoDb, closeHippoDb, withSharedStoreHandles, HOOK_DB_WAIT_MS, isSqliteBusy, noteStoreBusy } from './db.js';
63
65
  import { runDoctor, formatDoctor } from './doctor.js';
64
66
  import { buildSupportBundle, TAIL_MAX_LINES } from './support-bundle.js';
65
67
  import { PACKAGE_VERSION } from './version.js';
@@ -88,6 +90,7 @@ import { listApiKeys } from './auth.js';
88
90
  import { buildProvenanceCoverage } from './provenance-coverage.js';
89
91
  import { buildCorrectionLatency } from './correction-latency.js';
90
92
  import * as api from './api.js';
93
+ import { vetSecrets } from './secret-detect.js';
91
94
  import * as predictionsModule from './predictions.js';
92
95
  import { computePlanningFallacyOutput } from './predictions.js';
93
96
  import * as decisionsModule from './decisions.js';
@@ -133,6 +136,7 @@ import { MAX_HOPS, DEFAULT_MAX_NEIGHBORS } from './graph-recall.js';
133
136
  import { getReranker } from './rerankers/index.js';
134
137
  import { rankRecall } from './recall-pipeline.js';
135
138
  import { JEV_DEFAULT_TOP_K } from './rerankers/jev.js';
139
+ import { isClefModel } from './rerankers/clef.js';
136
140
  import { computeSalience } from './salience.js';
137
141
  import { renderAmbientSummary } from './ambient.js';
138
142
  import { assembleCost, assembleHeading, contextCost, contextHeading, contextLine, crossProjectHeading, crossProjectLine, drillCost, handoffText, printedTokens, sessionTrailText, settleTokens, snapshotText, } from './context-render.js';
@@ -143,6 +147,8 @@ import { backfillChannel } from './connectors/slack/backfill.js';
143
147
  import { slackHistoryFetcher } from './connectors/slack/web-client.js';
144
148
  import { addWorkspace as addSlackWorkspace, listWorkspaces as listSlackWorkspaces, removeWorkspace as removeSlackWorkspace, } from './connectors/slack/workspaces.js';
145
149
  import { cmdGithub, printGithubBackfillUsage } from './connectors/github/cli-impl.js';
150
+ import { log } from './log.js';
151
+ import { printError } from './cli/output.js';
146
152
  import { parseLimitFlag, parseCountFlag, parseBudgetFlag, emitCliAudit, requireInit, runChurnStaleForRepo, runViaServerIfAvailable, fmt, recallEntryText, recallHeading, printAgentImport, hippoBlock, installCodexMemoryHooks, setupDailySchedule, parseAsOfFlag, engineFlags, collectHandoffEvidence, logSessionEndImport, appendSessionEndCloseLog, printActiveTaskSnapshot, printSessionEvents, printHandoff, cardStringFlag, hostSessionId, resetHookInjection, captureConsole, hookStoreRoot, withLedgerDb, learnFromRepo, HOOK_MARKERS, HOOKS, resolveAuthRoot, } from './cli/shared.js';
147
153
  // ---------------------------------------------------------------------------
148
154
  // Helpers
@@ -151,7 +157,7 @@ import { parseLimitFlag, parseCountFlag, parseBudgetFlag, emitCliAudit, requireI
151
157
  // and as off under === true (`--pin=true` would not pin), so parseArgs and main() refuse one.
152
158
  // tests/cli-parse-flag-equals.test.ts fails when a switch read is missing from this set.
153
159
  export const BOOLEAN_FLAGS = new Set([
154
- 'agents', 'all', 'all-tenants', 'archive', 'auto', 'bad', 'bootstrap', 'classic', 'churn', 'continuity',
160
+ 'agents', 'all', 'all-tenants', 'apply', 'archive', 'auto', 'bad', 'bootstrap', 'classic', 'churn', 'continuity',
155
161
  'cross-project', 'dry-run', 'equal-sources', 'error', 'evc-adaptive', 'extract',
156
162
  'filter-conflicts', 'fix', 'force', 'forget', 'git', 'global', 'good', 'graph-stream',
157
163
  'help', 'include-logs', 'include-superseded', 'inferred', 'json', 'last-session', 'multihop', 'no-hooks',
@@ -179,7 +185,7 @@ export const KNOWN_FLAGS = new Set([
179
185
  ]);
180
186
  // Commands that delete or hide memories: an unknown flag here stops the run instead of being ignored.
181
187
  const DESTRUCTIVE_COMMANDS = new Set([
182
- 'audit', 'dedup', 'forget', 'invalidate', 'reject', 'resolve', 'sleep', 'supersede',
188
+ 'audit', 'dedup', 'forget', 'invalidate', 'projects', 'reject', 'resolve', 'sleep', 'supersede',
183
189
  ]);
184
190
  // Commands that honour --dry-run. Any other command would ignore it and run for real.
185
191
  const DRY_RUN_COMMANDS = new Set([
@@ -635,18 +641,18 @@ async function cmdRemember(hippoRoot, text, flags) {
635
641
  // 'archived' is an internal sentinel set only inside archiveRawMemory's transaction.
636
642
  const userVisibleKinds = ['distilled', 'superseded'];
637
643
  if (kindFlag !== undefined && !userVisibleKinds.includes(kindFlag)) {
638
- console.error(`Invalid --kind: "${kindFlagRaw}". Must be one of: ${userVisibleKinds.join(', ')}`);
639
- console.error(`(kind='raw' is reserved for ingestion connectors; kind='archived' is internal.)`);
644
+ printError(`Invalid --kind: "${kindFlagRaw}". Must be one of: ${userVisibleKinds.join(', ')}`);
645
+ printError(`(kind='raw' is reserved for ingestion connectors; kind='archived' is internal.)`);
640
646
  process.exit(1);
641
647
  }
642
648
  const ownerRaw = typeof flags['owner'] === 'string' ? flags['owner'] : null;
643
649
  const ownerCheck = validateOwner(ownerRaw, { strict: isStrictOwnerEnv() });
644
650
  if (!ownerCheck.ok) {
645
- console.error(ownerCheck.message);
651
+ printError(ownerCheck.message);
646
652
  process.exit(1);
647
653
  }
648
654
  if (ownerCheck.message)
649
- console.error(ownerCheck.message);
655
+ printError(ownerCheck.message);
650
656
  const ownerFlag = ownerCheck.value ?? null;
651
657
  const artifactRefFlag = typeof flags['artifact-ref'] === 'string' ? flags['artifact-ref'] : null;
652
658
  const scopeForEnvelope = typeof flags['scope'] === 'string' ? flags['scope'].trim() || null : null;
@@ -695,6 +701,8 @@ async function cmdRemember(hippoRoot, text, flags) {
695
701
  console.log(` Tags: ${entry.tags.join(', ')}`);
696
702
  if (entry.pinned)
697
703
  console.log(' Pinned (no decay)');
704
+ for (const w of vetSecrets(entry.content, entry.tags, false).warnings)
705
+ printError(`Warning: ${w}`);
698
706
  void embedMemory(targetRoot, entry);
699
707
  const config = loadConfig(targetRoot);
700
708
  const shouldExtract = flags['extract'] || config.extraction.enabled === true;
@@ -705,31 +713,31 @@ async function cmdRemember(hippoRoot, text, flags) {
705
713
  const facts = await extractFacts(entry.content, {
706
714
  apiKey,
707
715
  model: config.extraction.model,
708
- onError: (msg) => console.error(` (extraction failed: ${msg})`),
716
+ onError: (msg) => printError(` (extraction failed: ${msg})`),
709
717
  });
710
718
  if (facts.length > 0) {
711
719
  storeExtractedFacts(targetRoot, entry, facts);
712
- console.error(` extracted ${facts.length} fact(s)`);
720
+ printError(` extracted ${facts.length} fact(s)`);
713
721
  }
714
722
  }
715
723
  catch (err) {
716
724
  // Extraction is best-effort: report it, never block remember.
717
- console.error(` (extraction failed: ${err instanceof Error ? err.message : String(err)})`);
725
+ printError(` (extraction failed: ${err instanceof Error ? err.message : String(err)})`);
718
726
  }
719
727
  }
720
728
  else if (shouldExtract && !apiKey) {
721
- console.error(' (extraction skipped: ANTHROPIC_API_KEY not set)');
729
+ printError(' (extraction skipped: ANTHROPIC_API_KEY not set)');
722
730
  }
723
731
  }
724
732
  function cmdSupersede(hippoRoot, oldId, newContent, flags) {
725
733
  requireInit(hippoRoot);
726
734
  const old = readEntry(hippoRoot, oldId, resolveTenantId({}));
727
735
  if (!old) {
728
- console.error(`Error: memory ${oldId} not found.`);
736
+ printError(`Error: memory ${oldId} not found.`);
729
737
  process.exit(1);
730
738
  }
731
739
  if (old.superseded_by) {
732
- console.error(`Error: memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
740
+ printError(`Error: memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
733
741
  process.exit(1);
734
742
  }
735
743
  const layer = typeof flags['layer'] === 'string' ? flags['layer'] : undefined;
@@ -761,7 +769,7 @@ function cmdSupersede(hippoRoot, oldId, newContent, flags) {
761
769
  }
762
770
  catch (err) {
763
771
  if (err instanceof RejectedValueError) {
764
- console.error(`Error: ${err.message}`);
772
+ printError(`Error: ${err.message}`);
765
773
  process.exit(1);
766
774
  }
767
775
  throw err;
@@ -773,7 +781,7 @@ function cmdSupersede(hippoRoot, oldId, newContent, flags) {
773
781
  }
774
782
  function failWith(message) {
775
783
  return () => {
776
- console.error(message);
784
+ printError(message);
777
785
  process.exit(1);
778
786
  };
779
787
  }
@@ -835,7 +843,7 @@ function parseRerankerFlag(flags) {
835
843
  return {};
836
844
  const topK = flags['reranker-top-k'] !== undefined
837
845
  ? parseInt(String(flags['reranker-top-k']), 10)
838
- : name === 'jev' ? JEV_DEFAULT_TOP_K : 50;
846
+ : name === 'jev' || isClefModel(name) ? JEV_DEFAULT_TOP_K : 50;
839
847
  return { value: { fn, topK } };
840
848
  }
841
849
  function parseSalienceFlag(flags) {
@@ -913,7 +921,7 @@ async function cmdRecall(hippoRoot, query, flags) {
913
921
  const entryText = (r) => recallEntryText(r, query, showWhy, primaryIsGlobal || (globalOn && !localIndex.entries[r.entry.id]));
914
922
  const printCost = (r) => printedTokens(entryText(r));
915
923
  const entryBudget = Math.max(0, budget - printedTokens(recallHeading(budget, budget, query)));
916
- const rank = await rankRecall({ hippoRoot, globalRoot: globalRoot !== hippoRoot && globalOn ? globalRoot : undefined, tenantId, note: (line) => console.error(line) }, {
924
+ const rank = await rankRecall({ hippoRoot, globalRoot: globalRoot !== hippoRoot && globalOn ? globalRoot : undefined, tenantId, note: (line) => printError(line) }, {
917
925
  query, budget: entryBudget, cost: printCost, limit, why: showWhy, includeSuperseded, asOf,
918
926
  explicitScope: recallExplicitScope, activeScope: recallActiveScope,
919
927
  search: { ...engineFlags(flags, config), multihop: flags['multihop'] === true || config.multihop.enabled, graphStream, minResults, explain: false },
@@ -1277,7 +1285,7 @@ function noteScopeHidden(hippoRoot, globalRoot, query, tenantId, requested) {
1277
1285
  // Window-capped, so the count is a floor on large stores; fine for a "why is my row missing" hint.
1278
1286
  const hidden = probe.filter((e) => !api.passesCliRecallScopeFilter(e.scope ?? null, requested)).length;
1279
1287
  if (hidden > 0) {
1280
- console.error(`[note] ${hidden} candidate${hidden === 1 ? '' : 's'} hidden by recall scope policy (pass an explicit --scope to inspect).`);
1288
+ printError(`[note] ${hidden} candidate${hidden === 1 ? '' : 's'} hidden by recall scope policy (pass an explicit --scope to inspect).`);
1281
1289
  }
1282
1290
  }
1283
1291
  async function cmdExplain(hippoRoot, query, flags) {
@@ -1437,7 +1445,7 @@ async function cmdEval(hippoRoot, corpusPath, flags) {
1437
1445
  baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
1438
1446
  }
1439
1447
  catch {
1440
- console.error(`Warning: eval baseline ${baselinePath} is unreadable; running without it.`);
1448
+ printError(`Warning: eval baseline ${baselinePath} is unreadable; running without it.`);
1441
1449
  }
1442
1450
  }
1443
1451
  const result = await runFeatureEval(version);
@@ -1462,11 +1470,11 @@ async function cmdEval(hippoRoot, corpusPath, flags) {
1462
1470
  return;
1463
1471
  }
1464
1472
  if (!corpusPath) {
1465
- console.error('Usage: hippo eval <corpus.json> OR hippo eval --suite [--save-baseline] OR hippo eval --bootstrap');
1473
+ printError('Usage: hippo eval <corpus.json> OR hippo eval --suite [--save-baseline] OR hippo eval --bootstrap');
1466
1474
  process.exit(1);
1467
1475
  }
1468
1476
  if (!fs.existsSync(corpusPath)) {
1469
- console.error(`Corpus file not found: ${corpusPath}`);
1477
+ printError(`Corpus file not found: ${corpusPath}`);
1470
1478
  process.exit(1);
1471
1479
  }
1472
1480
  let cases;
@@ -1477,7 +1485,7 @@ async function cmdEval(hippoRoot, corpusPath, flags) {
1477
1485
  throw new Error('Corpus JSON must be an array or { cases: [...] }');
1478
1486
  }
1479
1487
  catch (err) {
1480
- console.error(`Failed to read corpus: ${err instanceof Error ? err.message : err}`);
1488
+ printError(`Failed to read corpus: ${err instanceof Error ? err.message : err}`);
1481
1489
  process.exit(1);
1482
1490
  }
1483
1491
  const globalRoot = getGlobalRoot();
@@ -1535,12 +1543,12 @@ async function cmdEval(hippoRoot, corpusPath, flags) {
1535
1543
  }
1536
1544
  }
1537
1545
  if (minMrr !== null && summary.meanMrr < minMrr) {
1538
- console.error(`MRR ${fmt(summary.meanMrr, 4)} below threshold ${minMrr}`);
1546
+ printError(`MRR ${fmt(summary.meanMrr, 4)} below threshold ${minMrr}`);
1539
1547
  process.exit(1);
1540
1548
  }
1541
1549
  if (comparePath) {
1542
1550
  if (!fs.existsSync(comparePath)) {
1543
- console.error(`Baseline file not found: ${comparePath}`);
1551
+ printError(`Baseline file not found: ${comparePath}`);
1544
1552
  process.exit(1);
1545
1553
  }
1546
1554
  let baseline;
@@ -1548,14 +1556,14 @@ async function cmdEval(hippoRoot, corpusPath, flags) {
1548
1556
  baseline = JSON.parse(fs.readFileSync(comparePath, 'utf8'));
1549
1557
  }
1550
1558
  catch (err) {
1551
- console.error(`Failed to parse baseline: ${err instanceof Error ? err.message : err}`);
1559
+ printError(`Failed to parse baseline: ${err instanceof Error ? err.message : err}`);
1552
1560
  process.exit(1);
1553
1561
  }
1554
1562
  const cmp = compareSummaries(baseline, summary);
1555
1563
  if (asJson) {
1556
1564
  // The main JSON output already emitted; append comparison to stderr so
1557
1565
  // both can be captured independently.
1558
- console.error(JSON.stringify({ compare: cmp }, null, 2));
1566
+ printError(JSON.stringify({ compare: cmp }, null, 2));
1559
1567
  }
1560
1568
  else {
1561
1569
  console.log();
@@ -1592,11 +1600,11 @@ function cmdTraceRecord(hippoRoot, flags) {
1592
1600
  const outcome = String(flags['outcome'] ?? '').trim();
1593
1601
  const validOutcomes = ['success', 'failure', 'partial'];
1594
1602
  if (!task || !stepsJson || !outcome) {
1595
- console.error('Usage: hippo trace record --task <t> --steps <json> --outcome <success|failure|partial> [--session <id>] [--tag <t>]');
1603
+ printError('Usage: hippo trace record --task <t> --steps <json> --outcome <success|failure|partial> [--session <id>] [--tag <t>]');
1596
1604
  process.exit(1);
1597
1605
  }
1598
1606
  if (!validOutcomes.includes(outcome)) {
1599
- console.error(`Invalid outcome: "${outcome}". Must be one of: ${validOutcomes.join(', ')}.`);
1607
+ printError(`Invalid outcome: "${outcome}". Must be one of: ${validOutcomes.join(', ')}.`);
1600
1608
  process.exit(1);
1601
1609
  }
1602
1610
  let steps;
@@ -1604,7 +1612,7 @@ function cmdTraceRecord(hippoRoot, flags) {
1604
1612
  steps = parseSteps(stepsJson);
1605
1613
  }
1606
1614
  catch (err) {
1607
- console.error(String(err instanceof Error ? err.message : err));
1615
+ printError(String(err instanceof Error ? err.message : err));
1608
1616
  process.exit(1);
1609
1617
  }
1610
1618
  const sessionId = String(flags['session'] ?? '').trim() || null;
@@ -1644,7 +1652,7 @@ function cmdTrace(hippoRoot, id, flags) {
1644
1652
  sourceLabel = 'global';
1645
1653
  }
1646
1654
  if (!entry) {
1647
- console.error(`Memory not found: ${id}`);
1655
+ printError(`Memory not found: ${id}`);
1648
1656
  process.exit(1);
1649
1657
  }
1650
1658
  const now = evalNow();
@@ -1739,7 +1747,7 @@ async function cmdRefine(hippoRoot, flags) {
1739
1747
  requireInit(hippoRoot);
1740
1748
  const apiKey = process.env.ANTHROPIC_API_KEY;
1741
1749
  if (!apiKey) {
1742
- console.error('hippo refine needs ANTHROPIC_API_KEY in the environment.');
1750
+ printError('hippo refine needs ANTHROPIC_API_KEY in the environment.');
1743
1751
  process.exit(1);
1744
1752
  }
1745
1753
  const dryRun = Boolean(flags['dry-run']);
@@ -1774,7 +1782,7 @@ function cmdDedup(hippoRoot, flags) {
1774
1782
  requireInit(hippoRoot);
1775
1783
  const dryRun = Boolean(flags['dry-run']);
1776
1784
  if (flags['threshold'] !== undefined) {
1777
- console.error('hippo dedup: --threshold is ignored; a duplicate is the same text apart from spacing.');
1785
+ printError('hippo dedup: --threshold is ignored; a duplicate is the same text apart from spacing.');
1778
1786
  }
1779
1787
  const entries = loadAllEntries(hippoRoot);
1780
1788
  console.log(`Scanning ${entries.length} memories for duplicates (same text apart from spacing)${dryRun ? ' (dry run)' : ''}...\n`);
@@ -1826,14 +1834,15 @@ function cmdLastSleep(flags) {
1826
1834
  content = fs.readFileSync(logPath, 'utf8');
1827
1835
  }
1828
1836
  catch {
1837
+ // Removed or locked since the exists check: there is nothing to show this session.
1829
1838
  return;
1830
1839
  }
1831
1840
  if (content.trim().length > 0) {
1832
- console.error('=== Previous session hippo consolidation ===');
1841
+ printError('=== Previous session hippo consolidation ===');
1833
1842
  process.stderr.write(content);
1834
1843
  if (!content.endsWith('\n'))
1835
- console.error();
1836
- console.error('===========================================');
1844
+ printError();
1845
+ printError('===========================================');
1837
1846
  }
1838
1847
  if (!flags['keep']) {
1839
1848
  try {
@@ -1884,6 +1893,7 @@ function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
1884
1893
  payload = JSON.parse(stdinText.trim());
1885
1894
  }
1886
1895
  catch {
1896
+ // Malformed JSON is handled as a null payload by the fail-closed check below.
1887
1897
  payload = null;
1888
1898
  }
1889
1899
  if (!payload || typeof payload !== 'object') {
@@ -1908,6 +1918,10 @@ function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
1908
1918
  }
1909
1919
  }
1910
1920
  }
1921
+ // A compaction follows a prompt or SessionStart that booked the arm, so this only reads it.
1922
+ if (!suppressOutput && payloadSessionId !== null && inPilotHoldout(hippoRoot, resolveTenantId({}), payloadSessionId, false)) {
1923
+ suppressOutput = true;
1924
+ }
1911
1925
  if (!suppressOutput) {
1912
1926
  const tenantId = resolveTenantId({});
1913
1927
  const snapshot = loadFreshActiveTaskSnapshot(hippoRoot, tenantId, { maxAgeMs: COMPACT_RESUME_MAX_AGE_MS });
@@ -1930,7 +1944,7 @@ function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
1930
1944
  }
1931
1945
  }
1932
1946
  catch (err) {
1933
- console.error(`hippo compact-resume: trail skipped: ${err instanceof Error ? err.message : String(err)}`);
1947
+ log.warn(`hippo compact-resume: trail skipped: ${err instanceof Error ? err.message : String(err)}`);
1934
1948
  }
1935
1949
  // Printed in one write so the ledger books exactly the text the model is handed.
1936
1950
  const text = captureConsole(() => {
@@ -1953,7 +1967,7 @@ function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
1953
1967
  }
1954
1968
  catch (err) {
1955
1969
  // Empty stdout on any store error, never a crashed SessionStart; the reason goes to stderr, which the model never sees.
1956
- console.error(`hippo compact-resume: skipped: ${err instanceof Error ? err.message : String(err)}`);
1970
+ log.warn(`hippo compact-resume: skipped: ${err instanceof Error ? err.message : String(err)}`);
1957
1971
  }
1958
1972
  process.exit(0);
1959
1973
  }
@@ -2192,7 +2206,7 @@ function cmdCodexRun(hippoRoot, args) {
2192
2206
  }
2193
2207
  const child = spawnRealCodex(metadata.realCodexPath, args, process.cwd());
2194
2208
  child.on('error', (err) => {
2195
- console.error(`Failed to launch Codex: ${err.message}`);
2209
+ printError(`Failed to launch Codex: ${err.message}`);
2196
2210
  process.exit(1);
2197
2211
  });
2198
2212
  child.on('exit', async (code, signal) => {
@@ -2239,6 +2253,7 @@ function cmdCodexRun(hippoRoot, args) {
2239
2253
  process.kill(process.pid, signal);
2240
2254
  }
2241
2255
  catch {
2256
+ // Cannot re-raise the child's signal on this platform; a non-zero exit still reports the failure.
2242
2257
  process.exit(1);
2243
2258
  }
2244
2259
  return;
@@ -2409,6 +2424,7 @@ function cmdStatus(hippoRoot) {
2409
2424
  return resolveEmbeddingProvider(hippoRoot);
2410
2425
  }
2411
2426
  catch {
2427
+ // Status reports a bad provider config as "misconfigured" below instead of failing.
2412
2428
  return null;
2413
2429
  }
2414
2430
  })();
@@ -2485,7 +2501,7 @@ function cmdOutcome(hippoRoot, flags) {
2485
2501
  const good = Boolean(flags['good']);
2486
2502
  const bad = Boolean(flags['bad']);
2487
2503
  if (!good && !bad) {
2488
- console.error('Specify --good or --bad');
2504
+ printError('Specify --good or --bad');
2489
2505
  process.exit(1);
2490
2506
  }
2491
2507
  // Behavior fix (v1.11.3): cmdOutcome used to bypass api.outcome and do its
@@ -2528,7 +2544,7 @@ function cmdForget(hippoRoot, id, flags) {
2528
2544
  if (flags['archive'] === true) {
2529
2545
  const reason = typeof flags['reason'] === 'string' ? flags['reason'] : null;
2530
2546
  if (!reason) {
2531
- console.error(ARCHIVE_REASON_REQUIRED);
2547
+ printError(ARCHIVE_REASON_REQUIRED);
2532
2548
  process.exit(1);
2533
2549
  }
2534
2550
  try {
@@ -2536,7 +2552,7 @@ function cmdForget(hippoRoot, id, flags) {
2536
2552
  console.log(`Archived ${id}`);
2537
2553
  }
2538
2554
  catch (err) {
2539
- console.error(`Could not archive ${id}: ${err instanceof Error ? err.message : String(err)}`);
2555
+ printError(`Could not archive ${id}: ${err instanceof Error ? err.message : String(err)}`);
2540
2556
  process.exit(1);
2541
2557
  }
2542
2558
  return;
@@ -2550,16 +2566,16 @@ function cmdForget(hippoRoot, id, flags) {
2550
2566
  if (/append-only/i.test(msg)) {
2551
2567
  // The delete was refused by the append-only trigger — this is a raw
2552
2568
  // memory, not a missing one. Point the user at the archive path.
2553
- console.error(rawForgetRefusal(id));
2569
+ printError(rawForgetRefusal(id));
2554
2570
  }
2555
2571
  else if (api.isDormant(ctx, id)) {
2556
2572
  // Sleep moved it to the dormant store: it is not in active memory, so
2557
2573
  // point at the command that owns it.
2558
- console.error(`${id} is dormant, not in active memory. Delete it for good: hippo dormant forget ${id} ` +
2574
+ printError(`${id} is dormant, not in active memory. Delete it for good: hippo dormant forget ${id} ` +
2559
2575
  `(or bring it back: hippo dormant restore ${id})`);
2560
2576
  }
2561
2577
  else {
2562
- console.error(`Memory not found: ${id}`);
2578
+ printError(`Memory not found: ${id}`);
2563
2579
  }
2564
2580
  process.exit(1);
2565
2581
  }
@@ -2572,15 +2588,15 @@ function previewForget(hippoRoot, id, archive) {
2572
2588
  requireInit(hippoRoot);
2573
2589
  const entry = readEntry(hippoRoot, id, resolveTenantId({}));
2574
2590
  if (!entry) {
2575
- console.error(`Memory not found: ${id}`);
2591
+ printError(`Memory not found: ${id}`);
2576
2592
  process.exit(1);
2577
2593
  }
2578
2594
  if (!archive && entry.kind === 'raw') {
2579
- console.error(rawForgetRefusal(id));
2595
+ printError(rawForgetRefusal(id));
2580
2596
  process.exit(1);
2581
2597
  }
2582
2598
  if (archive && entry.kind !== 'raw') {
2583
- console.error(`Could not archive ${id}: memory ${id} is not raw (kind=${entry.kind})`);
2599
+ printError(`Could not archive ${id}: memory ${id} is not raw (kind=${entry.kind})`);
2584
2600
  process.exit(1);
2585
2601
  }
2586
2602
  const snippet = entry.content.length > 80 ? `${entry.content.slice(0, 80)}...` : entry.content;
@@ -2590,7 +2606,7 @@ function cmdInspect(hippoRoot, id) {
2590
2606
  requireInit(hippoRoot);
2591
2607
  const entry = readEntry(hippoRoot, id, resolveTenantId({}));
2592
2608
  if (!entry) {
2593
- console.error(`Memory not found: ${id}`);
2609
+ printError(`Memory not found: ${id}`);
2594
2610
  process.exit(1);
2595
2611
  }
2596
2612
  const now = evalNow();
@@ -2653,7 +2669,7 @@ function cmdResolve(hippoRoot, args, flags) {
2653
2669
  // Accept "42" or "conflict_42"
2654
2670
  const conflictId = parseInt(rawId.replace(/^conflict_/, ''), 10);
2655
2671
  if (isNaN(conflictId)) {
2656
- console.error('Usage: hippo resolve <conflict_id> --keep <memory_id> [--forget]');
2672
+ printError('Usage: hippo resolve <conflict_id> --keep <memory_id> [--forget]');
2657
2673
  process.exit(1);
2658
2674
  }
2659
2675
  const tenantId = resolveTenantId({});
@@ -2663,7 +2679,7 @@ function cmdResolve(hippoRoot, args, flags) {
2663
2679
  const conflicts = listMemoryConflicts(hippoRoot, 'open', tenantId);
2664
2680
  const conflict = conflicts.find((c) => c.id === conflictId);
2665
2681
  if (!conflict) {
2666
- console.error(`Conflict ${conflictId} not found or already resolved.`);
2682
+ printError(`Conflict ${conflictId} not found or already resolved.`);
2667
2683
  process.exit(1);
2668
2684
  }
2669
2685
  console.log(`Conflict ${conflictId}:`);
@@ -2697,7 +2713,7 @@ function cmdResolve(hippoRoot, args, flags) {
2697
2713
  reason: reasonFlag,
2698
2714
  });
2699
2715
  if (!result) {
2700
- console.error(`Could not resolve conflict ${conflictId}. Check the ID and --keep value.`);
2716
+ printError(`Could not resolve conflict ${conflictId}. Check the ID and --keep value.`);
2701
2717
  process.exit(1);
2702
2718
  }
2703
2719
  const action = rejectLoser
@@ -2720,20 +2736,20 @@ function cmdReject(hippoRoot, args, flags) {
2720
2736
  // content, so reason is its only human-readable identity.
2721
2737
  const reason = typeof flags['reason'] === 'string' ? flags['reason'].trim() : '';
2722
2738
  if (!reason) {
2723
- console.error('hippo reject requires --reason "<why>" (the tombstone stores no content; reason is its only identity).');
2739
+ printError('hippo reject requires --reason "<why>" (the tombstone stores no content; reason is its only identity).');
2724
2740
  process.exit(1);
2725
2741
  }
2726
2742
  const valueFlag = typeof flags['value'] === 'string' ? flags['value'] : undefined;
2727
2743
  const memoryId = args[0];
2728
2744
  if (!memoryId && valueFlag === undefined) {
2729
- console.error('Usage: hippo reject <memory-id> --reason "<why>"');
2730
- console.error(' or: hippo reject --value "<text>" --reason "<why>"');
2745
+ printError('Usage: hippo reject <memory-id> --reason "<why>"');
2746
+ printError(' or: hippo reject --value "<text>" --reason "<why>"');
2731
2747
  process.exit(1);
2732
2748
  }
2733
2749
  if (memoryId && valueFlag !== undefined) {
2734
2750
  // Ambiguous ask: silently preferring one form would ignore the other
2735
2751
  // without feedback (code-review round-1 low).
2736
- console.error('hippo reject takes EITHER a memory id OR --value, not both.');
2752
+ printError('hippo reject takes EITHER a memory id OR --value, not both.');
2737
2753
  process.exit(1);
2738
2754
  }
2739
2755
  try {
@@ -2763,7 +2779,7 @@ function cmdReject(hippoRoot, args, flags) {
2763
2779
  }
2764
2780
  }
2765
2781
  catch (err) {
2766
- console.error(`Could not reject: ${err instanceof Error ? err.message : String(err)}`);
2782
+ printError(`Could not reject: ${err instanceof Error ? err.message : String(err)}`);
2767
2783
  process.exit(1);
2768
2784
  }
2769
2785
  }
@@ -2797,18 +2813,18 @@ function cmdUnreject(hippoRoot, args, flags) {
2797
2813
  const tenantId = resolveTenantId({});
2798
2814
  const digestOrPrefix = (args[0] ?? '').trim();
2799
2815
  if (!digestOrPrefix) {
2800
- console.error('Usage: hippo unreject <digest-or-prefix>');
2816
+ printError('Usage: hippo unreject <digest-or-prefix>');
2801
2817
  process.exit(1);
2802
2818
  }
2803
2819
  const outcome = unrejectValue(root, tenantId, digestOrPrefix, 'cli');
2804
2820
  if (outcome.status === 'not_found') {
2805
- console.error(`No rejected value matches "${digestOrPrefix}". Run \`hippo rejections\` to list tombstones.`);
2821
+ printError(`No rejected value matches "${digestOrPrefix}". Run \`hippo rejections\` to list tombstones.`);
2806
2822
  process.exit(1);
2807
2823
  }
2808
2824
  if (outcome.status === 'ambiguous') {
2809
- console.error(`"${digestOrPrefix}" matches ${outcome.candidates.length} tombstones. Use a longer prefix:`);
2825
+ printError(`"${digestOrPrefix}" matches ${outcome.candidates.length} tombstones. Use a longer prefix:`);
2810
2826
  for (const c of outcome.candidates) {
2811
- console.error(` ${c.digest.slice(0, 16)}... ${c.reason ?? 'none given'}`);
2827
+ printError(` ${c.digest.slice(0, 16)}... ${c.reason ?? 'none given'}`);
2812
2828
  }
2813
2829
  process.exit(1);
2814
2830
  }
@@ -2831,7 +2847,7 @@ function cmdDormant(hippoRoot, args, flags) {
2831
2847
  if (sub === 'restore' || sub === 'forget') {
2832
2848
  const id = (args[1] ?? '').trim();
2833
2849
  if (!id) {
2834
- console.error(`Usage: hippo dormant ${sub} <id>`);
2850
+ printError(`Usage: hippo dormant ${sub} <id>`);
2835
2851
  process.exit(1);
2836
2852
  }
2837
2853
  try {
@@ -2846,10 +2862,10 @@ function cmdDormant(hippoRoot, args, flags) {
2846
2862
  }
2847
2863
  catch (err) {
2848
2864
  if (err instanceof RejectedValueError) {
2849
- console.error(`Cannot restore ${id}: its value was rejected (${err.reason ?? 'no reason given'}). Run \`hippo unreject\` first to allow it.`);
2865
+ printError(`Cannot restore ${id}: its value was rejected (${err.reason ?? 'no reason given'}). Run \`hippo unreject\` first to allow it.`);
2850
2866
  }
2851
2867
  else {
2852
- console.error(`Could not ${sub} ${id}: ${err instanceof Error ? err.message : String(err)}`);
2868
+ printError(`Could not ${sub} ${id}: ${err instanceof Error ? err.message : String(err)}`);
2853
2869
  }
2854
2870
  process.exit(1);
2855
2871
  }
@@ -2894,7 +2910,7 @@ function cmdQuarantine(hippoRoot, args, flags) {
2894
2910
  if (sub === 'approve' || sub === 'reject') {
2895
2911
  const id = (args[1] ?? '').trim();
2896
2912
  if (!id) {
2897
- console.error(`Usage: hippo quarantine ${sub} <id>`);
2913
+ printError(`Usage: hippo quarantine ${sub} <id>`);
2898
2914
  process.exit(1);
2899
2915
  }
2900
2916
  try {
@@ -2908,7 +2924,7 @@ function cmdQuarantine(hippoRoot, args, flags) {
2908
2924
  }
2909
2925
  }
2910
2926
  catch (err) {
2911
- console.error(`Could not ${sub} ${id}: ${err instanceof Error ? err.message : String(err)}`);
2927
+ printError(`Could not ${sub} ${id}: ${err instanceof Error ? err.message : String(err)}`);
2912
2928
  process.exit(1);
2913
2929
  }
2914
2930
  return;
@@ -3025,7 +3041,7 @@ function cmdSnapshot(hippoRoot, args, flags) {
3025
3041
  const nextStep = String(flags['next-step'] ?? '').trim();
3026
3042
  const sessionId = String(flags['session'] ?? flags['id'] ?? '').trim();
3027
3043
  if (!task || !summary || !nextStep) {
3028
- console.error('Usage: hippo snapshot save --task <task> --summary <summary> --next-step <step> [--source <source>] [--session <session-id>]');
3044
+ printError('Usage: hippo snapshot save --task <task> --summary <summary> --next-step <step> [--source <source>] [--session <session-id>]');
3029
3045
  process.exit(1);
3030
3046
  }
3031
3047
  const snapshot = saveActiveTaskSnapshot(hippoRoot, resolveTenantId({}), {
@@ -3070,7 +3086,7 @@ function cmdSnapshot(hippoRoot, args, flags) {
3070
3086
  printActiveTaskSnapshot(snapshot);
3071
3087
  return;
3072
3088
  }
3073
- console.error('Usage: hippo snapshot <save|show|clear>');
3089
+ printError('Usage: hippo snapshot <save|show|clear>');
3074
3090
  process.exit(1);
3075
3091
  }
3076
3092
  function cmdSession(hippoRoot, args, flags) {
@@ -3083,7 +3099,7 @@ function cmdSession(hippoRoot, args, flags) {
3083
3099
  const eventType = String(flags['type'] ?? 'note').trim();
3084
3100
  const content = String(flags['content'] ?? '').trim();
3085
3101
  if (!sessionId || !content) {
3086
- console.error('Usage: hippo session log --id <session-id> --content <text> [--type <type>] [--task <task>] [--source <source>]');
3102
+ printError('Usage: hippo session log --id <session-id> --content <text> [--type <type>] [--task <task>] [--source <source>]');
3087
3103
  process.exit(1);
3088
3104
  }
3089
3105
  const event = appendSessionEvent(hippoRoot, resolveTenantId({}), {
@@ -3135,11 +3151,11 @@ function cmdSession(hippoRoot, args, flags) {
3135
3151
  const outcomeRaw = String(flags['outcome'] ?? '').trim();
3136
3152
  const summary = String(flags['summary'] ?? '').trim();
3137
3153
  if (!sessionId) {
3138
- console.error('Usage: hippo session complete --session <session-id> --outcome <success|failure|partial> [--summary "..."]');
3154
+ printError('Usage: hippo session complete --session <session-id> --outcome <success|failure|partial> [--summary "..."]');
3139
3155
  process.exit(1);
3140
3156
  }
3141
3157
  if (!isHandoffOutcome(outcomeRaw)) {
3142
- console.error(`Invalid outcome: "${outcomeRaw}". Must be one of: success, failure, partial.`);
3158
+ printError(`Invalid outcome: "${outcomeRaw}". Must be one of: success, failure, partial.`);
3143
3159
  process.exit(1);
3144
3160
  }
3145
3161
  const outcome = outcomeRaw;
@@ -3206,7 +3222,7 @@ function cmdSession(hippoRoot, args, flags) {
3206
3222
  console.log(lines.join('\n'));
3207
3223
  return;
3208
3224
  }
3209
- console.error('Usage: hippo session <log|show|latest|resume|complete>');
3225
+ printError('Usage: hippo session <log|show|latest|resume|complete>');
3210
3226
  process.exit(1);
3211
3227
  }
3212
3228
  function cmdHandoff(hippoRoot, args, flags) {
@@ -3215,12 +3231,12 @@ function cmdHandoff(hippoRoot, args, flags) {
3215
3231
  if (subcommand === 'create') {
3216
3232
  const summary = String(flags['summary'] ?? '').trim();
3217
3233
  if (!summary) {
3218
- console.error('Usage: hippo handoff create --summary "..." [--next "..."] [--session <id>] [--task <id>] [--artifact <path>...] [--constraint <text>...] [--outcome <success|failure|partial>] [--target-runtime <name>] [--card-id <id>] [--tests <pass|fail|unknown>]');
3234
+ printError('Usage: hippo handoff create --summary "..." [--next "..."] [--session <id>] [--task <id>] [--artifact <path>...] [--constraint <text>...] [--outcome <success|failure|partial>] [--target-runtime <name>] [--card-id <id>] [--tests <pass|fail|unknown>]');
3219
3235
  process.exit(1);
3220
3236
  }
3221
3237
  const outcomeRaw = flags['outcome'];
3222
3238
  if (outcomeRaw !== undefined && !isHandoffOutcome(outcomeRaw)) {
3223
- console.error(`Invalid outcome: "${String(outcomeRaw)}". Must be one of: success, failure, partial.`);
3239
+ printError(`Invalid outcome: "${String(outcomeRaw)}". Must be one of: success, failure, partial.`);
3224
3240
  process.exit(1);
3225
3241
  }
3226
3242
  const sessionId = String(flags['session'] ?? flags['id'] ?? '').trim() || `fallback-${Date.now()}-${process.pid}`;
@@ -3238,7 +3254,7 @@ function cmdHandoff(hippoRoot, args, flags) {
3238
3254
  for (const name of ['target-runtime', 'card-id']) {
3239
3255
  // parseArgs turns a value-less flag into `true`; refuse rather than store "true".
3240
3256
  if (flags[name] === true) {
3241
- console.error(`--${name} needs a value`);
3257
+ printError(`--${name} needs a value`);
3242
3258
  process.exit(1);
3243
3259
  }
3244
3260
  }
@@ -3303,12 +3319,12 @@ function cmdHandoff(hippoRoot, args, flags) {
3303
3319
  if (subcommand === 'show') {
3304
3320
  const idArg = args[1];
3305
3321
  if (!idArg) {
3306
- console.error('Usage: hippo handoff show <id> [--json]');
3322
+ printError('Usage: hippo handoff show <id> [--json]');
3307
3323
  process.exit(1);
3308
3324
  }
3309
3325
  const handoffId = parseInt(idArg, 10);
3310
3326
  if (!Number.isFinite(handoffId) || handoffId <= 0) {
3311
- console.error(`Invalid handoff ID: ${idArg}`);
3327
+ printError(`Invalid handoff ID: ${idArg}`);
3312
3328
  process.exit(1);
3313
3329
  }
3314
3330
  const handoff = loadHandoffById(hippoRoot, resolveTenantId({}), handoffId);
@@ -3328,7 +3344,7 @@ function cmdHandoff(hippoRoot, args, flags) {
3328
3344
  printHandoff(handoff);
3329
3345
  return;
3330
3346
  }
3331
- console.error('Usage: hippo handoff <create|latest|show>');
3347
+ printError('Usage: hippo handoff <create|latest|show>');
3332
3348
  process.exit(1);
3333
3349
  }
3334
3350
  // Mirrors ARCHIVE_REASON_REQUIRED so the block message can't drift from its usage line.
@@ -3381,7 +3397,7 @@ function cardRunFlag(flags) {
3381
3397
  return undefined;
3382
3398
  const n = Number(raw);
3383
3399
  if (!/^\d+$/.test(raw) || !Number.isSafeInteger(n) || n <= 0) {
3384
- console.error(`Invalid --run: "${raw}" (expected a positive integer).`);
3400
+ printError(`Invalid --run: "${raw}" (expected a positive integer).`);
3385
3401
  process.exit(1);
3386
3402
  }
3387
3403
  return n;
@@ -3414,7 +3430,7 @@ function cmdCard(hippoRoot, args, flags) {
3414
3430
  for (const key of Object.keys(flags)) {
3415
3431
  if (!allowedFlags.includes(key)) {
3416
3432
  const valid = allowedFlags.length > 0 ? allowedFlags.map((f) => `--${f}`).join(', ') : '(none)';
3417
- console.error(`Unknown flag --${key} for hippo card ${subcommand}. Valid flags: ${valid}`);
3433
+ printError(`Unknown flag --${key} for hippo card ${subcommand}. Valid flags: ${valid}`);
3418
3434
  process.exit(1);
3419
3435
  }
3420
3436
  }
@@ -3422,7 +3438,7 @@ function cmdCard(hippoRoot, args, flags) {
3422
3438
  if (subcommand === 'create') {
3423
3439
  const title = cardStringFlag(flags, 'title') ?? '';
3424
3440
  if (!title) {
3425
- console.error('Usage: hippo card create --title "..." [--repo <name>] [--contract <text>] [--budget <n>] [--depends-on <id>...]');
3441
+ printError('Usage: hippo card create --title "..." [--repo <name>] [--contract <text>] [--budget <n>] [--depends-on <id>...]');
3426
3442
  process.exit(1);
3427
3443
  }
3428
3444
  const repo = cardStringFlag(flags, 'repo') || undefined;
@@ -3431,14 +3447,14 @@ function cmdCard(hippoRoot, args, flags) {
3431
3447
  let budget;
3432
3448
  if (budgetRaw !== undefined) {
3433
3449
  if (!/^\d+$/.test(budgetRaw)) {
3434
- console.error(`Invalid budget: "${budgetRaw}" (expected a positive integer)`);
3450
+ printError(`Invalid budget: "${budgetRaw}" (expected a positive integer)`);
3435
3451
  process.exit(1);
3436
3452
  }
3437
3453
  budget = Number(budgetRaw);
3438
3454
  }
3439
3455
  const dependsOnFlag = flags['depends-on'];
3440
3456
  if (dependsOnFlag === true) {
3441
- console.error('--depends-on requires a value');
3457
+ printError('--depends-on requires a value');
3442
3458
  process.exit(1);
3443
3459
  }
3444
3460
  const dependsOn = Array.isArray(dependsOnFlag) ? dependsOnFlag : [];
@@ -3447,7 +3463,7 @@ function cmdCard(hippoRoot, args, flags) {
3447
3463
  card = createCard(hippoRoot, tenantId, { title, repo, contract, budget, dependsOn });
3448
3464
  }
3449
3465
  catch (error) {
3450
- console.error(error instanceof Error ? error.message : String(error));
3466
+ printError(error instanceof Error ? error.message : String(error));
3451
3467
  process.exit(1);
3452
3468
  }
3453
3469
  console.log(`Created card ${card.id} (status: ${card.status})`);
@@ -3456,12 +3472,12 @@ function cmdCard(hippoRoot, args, flags) {
3456
3472
  if (subcommand === 'show') {
3457
3473
  const id = args[1];
3458
3474
  if (!id) {
3459
- console.error('Usage: hippo card show <id> [--json]');
3475
+ printError('Usage: hippo card show <id> [--json]');
3460
3476
  process.exit(1);
3461
3477
  }
3462
3478
  const detail = loadCardDetail(hippoRoot, tenantId, id);
3463
3479
  if (!detail) {
3464
- console.error(`No card found with id ${id}.`);
3480
+ printError(`No card found with id ${id}.`);
3465
3481
  process.exit(1);
3466
3482
  }
3467
3483
  if (flags['json']) {
@@ -3474,7 +3490,7 @@ function cmdCard(hippoRoot, args, flags) {
3474
3490
  if (subcommand === 'list') {
3475
3491
  const status = cardStringFlag(flags, 'status');
3476
3492
  if (status !== undefined && !isCardStatus(status)) {
3477
- console.error(`Invalid status: "${status}".`);
3493
+ printError(`Invalid status: "${status}".`);
3478
3494
  process.exit(1);
3479
3495
  }
3480
3496
  const cards = listCards(hippoRoot, tenantId, { status });
@@ -3495,7 +3511,7 @@ function cmdCard(hippoRoot, args, flags) {
3495
3511
  const id = args[1];
3496
3512
  const runtime = cardStringFlag(flags, 'runtime') ?? '';
3497
3513
  if (!id || !runtime) {
3498
- console.error('Usage: hippo card claim <id> --runtime <name> [--session <id>]');
3514
+ printError('Usage: hippo card claim <id> --runtime <name> [--session <id>]');
3499
3515
  process.exit(1);
3500
3516
  }
3501
3517
  const sessionId = cardStringFlag(flags, 'session') || undefined;
@@ -3504,11 +3520,11 @@ function cmdCard(hippoRoot, args, flags) {
3504
3520
  card = claimCard(hippoRoot, tenantId, id, runtime, sessionId);
3505
3521
  }
3506
3522
  catch (error) {
3507
- console.error(error instanceof Error ? error.message : String(error));
3523
+ printError(error instanceof Error ? error.message : String(error));
3508
3524
  process.exit(1);
3509
3525
  }
3510
3526
  if (!card) {
3511
- console.error(`Could not claim card ${id} (not ready/blocked, or already claimed).`);
3527
+ printError(`Could not claim card ${id} (not ready/blocked, or already claimed).`);
3512
3528
  process.exit(1);
3513
3529
  }
3514
3530
  console.log(`Claimed card ${card.id} for ${runtime} (run ${card.runId}, lease until ${card.leaseUntil})`);
@@ -3518,7 +3534,7 @@ function cmdCard(hippoRoot, args, flags) {
3518
3534
  const id = args[1];
3519
3535
  const runId = cardRunFlag(flags);
3520
3536
  if (!id || runId === undefined) {
3521
- console.error('Usage: hippo card heartbeat <id> --run <n>');
3537
+ printError('Usage: hippo card heartbeat <id> --run <n>');
3522
3538
  process.exit(1);
3523
3539
  }
3524
3540
  let card;
@@ -3526,11 +3542,11 @@ function cmdCard(hippoRoot, args, flags) {
3526
3542
  card = heartbeatCard(hippoRoot, tenantId, id, runId);
3527
3543
  }
3528
3544
  catch (error) {
3529
- console.error(error instanceof Error ? error.message : String(error));
3545
+ printError(error instanceof Error ? error.message : String(error));
3530
3546
  process.exit(1);
3531
3547
  }
3532
3548
  if (!card) {
3533
- console.error(`Could not heartbeat card ${id} (${cardRefusal(hippoRoot, tenantId, id)}).`);
3549
+ printError(`Could not heartbeat card ${id} (${cardRefusal(hippoRoot, tenantId, id)}).`);
3534
3550
  process.exit(1);
3535
3551
  }
3536
3552
  console.log(`Heartbeat card ${card.id}: lease until ${card.leaseUntil}`);
@@ -3540,7 +3556,7 @@ function cmdCard(hippoRoot, args, flags) {
3540
3556
  const id = args[1];
3541
3557
  const reason = cardStringFlag(flags, 'reason') ?? '';
3542
3558
  if (!id || !reason) {
3543
- console.error(CARD_BLOCK_REASON_REQUIRED);
3559
+ printError(CARD_BLOCK_REASON_REQUIRED);
3544
3560
  process.exit(1);
3545
3561
  }
3546
3562
  const runId = cardRunFlag(flags);
@@ -3549,12 +3565,12 @@ function cmdCard(hippoRoot, args, flags) {
3549
3565
  card = blockCard(hippoRoot, tenantId, id, reason, runId);
3550
3566
  }
3551
3567
  catch (error) {
3552
- console.error(error instanceof Error ? error.message : String(error));
3568
+ printError(error instanceof Error ? error.message : String(error));
3553
3569
  process.exit(1);
3554
3570
  }
3555
3571
  if (!card) {
3556
3572
  const why = runId === undefined ? 'not running' : cardRefusal(hippoRoot, tenantId, id);
3557
- console.error(`Could not block card ${id} (${why}).`);
3573
+ printError(`Could not block card ${id} (${why}).`);
3558
3574
  process.exit(1);
3559
3575
  }
3560
3576
  console.log(`Blocked card ${card.id}`);
@@ -3563,7 +3579,7 @@ function cmdCard(hippoRoot, args, flags) {
3563
3579
  if (subcommand === 'review') {
3564
3580
  const id = args[1];
3565
3581
  if (!id) {
3566
- console.error('Usage: hippo card review <id> [--run <n>]');
3582
+ printError('Usage: hippo card review <id> [--run <n>]');
3567
3583
  process.exit(1);
3568
3584
  }
3569
3585
  const runId = cardRunFlag(flags);
@@ -3572,12 +3588,12 @@ function cmdCard(hippoRoot, args, flags) {
3572
3588
  card = reviewCard(hippoRoot, tenantId, id, runId);
3573
3589
  }
3574
3590
  catch (error) {
3575
- console.error(error instanceof Error ? error.message : String(error));
3591
+ printError(error instanceof Error ? error.message : String(error));
3576
3592
  process.exit(1);
3577
3593
  }
3578
3594
  if (!card) {
3579
3595
  const why = runId === undefined ? 'not running' : cardRefusal(hippoRoot, tenantId, id);
3580
- console.error(`Could not move card ${id} to review (${why}).`);
3596
+ printError(`Could not move card ${id} to review (${why}).`);
3581
3597
  process.exit(1);
3582
3598
  }
3583
3599
  console.log(`Card ${card.id} moved to review`);
@@ -3587,7 +3603,7 @@ function cmdCard(hippoRoot, args, flags) {
3587
3603
  const id = args[1];
3588
3604
  const outcomeRaw = flags['outcome'];
3589
3605
  if (!id || !isHandoffOutcome(outcomeRaw)) {
3590
- console.error('Usage: hippo card complete <id> --outcome <success|failure|partial> [--run <n>]');
3606
+ printError('Usage: hippo card complete <id> --outcome <success|failure|partial> [--run <n>]');
3591
3607
  process.exit(1);
3592
3608
  }
3593
3609
  const runId = cardRunFlag(flags);
@@ -3596,12 +3612,12 @@ function cmdCard(hippoRoot, args, flags) {
3596
3612
  result = completeCard(hippoRoot, tenantId, id, outcomeRaw, runId);
3597
3613
  }
3598
3614
  catch (error) {
3599
- console.error(error instanceof Error ? error.message : String(error));
3615
+ printError(error instanceof Error ? error.message : String(error));
3600
3616
  process.exit(1);
3601
3617
  }
3602
3618
  if (!result) {
3603
3619
  const why = runId === undefined ? 'not in review' : cardRefusal(hippoRoot, tenantId, id);
3604
- console.error(`Could not complete card ${id} (${why}).`);
3620
+ printError(`Could not complete card ${id} (${why}).`);
3605
3621
  process.exit(1);
3606
3622
  }
3607
3623
  console.log(`Completed card ${result.card.id} (status: ${result.card.status})`);
@@ -3612,7 +3628,7 @@ function cmdCard(hippoRoot, args, flags) {
3612
3628
  }
3613
3629
  if (subcommand === 'reclaim') {
3614
3630
  if (args.length > 1) {
3615
- console.error('Usage: hippo card reclaim (sweeps every expired lease; use hippo card block <id> for one card)');
3631
+ printError('Usage: hippo card reclaim (sweeps every expired lease; use hippo card block <id> for one card)');
3616
3632
  process.exit(1);
3617
3633
  }
3618
3634
  const ids = reclaimExpiredCards(hippoRoot, tenantId);
@@ -3628,18 +3644,18 @@ function cmdCard(hippoRoot, args, flags) {
3628
3644
  if (subcommand === 'comment') {
3629
3645
  const id = args[1];
3630
3646
  if (!id) {
3631
- console.error('Usage: hippo card comment <id> --body "..." [--author <name>]');
3647
+ printError('Usage: hippo card comment <id> --body "..." [--author <name>]');
3632
3648
  process.exit(1);
3633
3649
  }
3634
3650
  // Only show and comment look the card up directly; claim/heartbeat/block/review/complete throw from the store instead.
3635
3651
  const card = loadCard(hippoRoot, tenantId, id);
3636
3652
  if (!card) {
3637
- console.error(`No card found with id ${id}.`);
3653
+ printError(`No card found with id ${id}.`);
3638
3654
  process.exit(1);
3639
3655
  }
3640
3656
  const body = cardStringFlag(flags, 'body') ?? '';
3641
3657
  if (!body) {
3642
- console.error('Usage: hippo card comment <id> --body "..." [--author <name>]');
3658
+ printError('Usage: hippo card comment <id> --body "..." [--author <name>]');
3643
3659
  process.exit(1);
3644
3660
  }
3645
3661
  const author = cardStringFlag(flags, 'author') || 'cli';
@@ -3647,7 +3663,7 @@ function cmdCard(hippoRoot, args, flags) {
3647
3663
  console.log(`Added comment ${comment.id} to card ${id}`);
3648
3664
  return;
3649
3665
  }
3650
- console.error('Usage: hippo card <create|show|list|claim|heartbeat|block|review|complete|reclaim|comment>');
3666
+ printError('Usage: hippo card <create|show|list|claim|heartbeat|block|review|complete|reclaim|comment>');
3651
3667
  process.exit(1);
3652
3668
  }
3653
3669
  // ---------------------------------------------------------------------------
@@ -3661,23 +3677,23 @@ function cmdPredict(hippoRoot, args, flags) {
3661
3677
  if (subcommand === 'close') {
3662
3678
  const idRaw = args[1];
3663
3679
  if (!idRaw) {
3664
- console.error('Usage: hippo predict close <id> --state <closed|closed-unknown> [--actual <v>] [--note "..."]');
3680
+ printError('Usage: hippo predict close <id> --state <closed|closed-unknown> [--actual <v>] [--note "..."]');
3665
3681
  process.exit(1);
3666
3682
  }
3667
3683
  const id = parseInt(String(idRaw), 10);
3668
3684
  if (!Number.isFinite(id) || id <= 0) {
3669
- console.error(`Invalid prediction id: "${idRaw}"`);
3685
+ printError(`Invalid prediction id: "${idRaw}"`);
3670
3686
  process.exit(1);
3671
3687
  }
3672
3688
  const stateRaw = typeof flags['state'] === 'string' ? flags['state'].trim() : '';
3673
3689
  if (!predictionsModule.VALID_CLOSURE_STATES.has(stateRaw) || stateRaw === 'open') {
3674
- console.error(`Invalid --state: "${stateRaw}". Must be one of: closed | closed-unknown.`);
3690
+ printError(`Invalid --state: "${stateRaw}". Must be one of: closed | closed-unknown.`);
3675
3691
  process.exit(1);
3676
3692
  }
3677
3693
  const actualRaw = flags['actual'];
3678
3694
  const actualValue = actualRaw !== undefined ? Number(actualRaw) : undefined;
3679
3695
  if (actualRaw !== undefined && !Number.isFinite(actualValue)) {
3680
- console.error(`Invalid --actual: "${actualRaw}". Must be a number.`);
3696
+ printError(`Invalid --actual: "${actualRaw}". Must be a number.`);
3681
3697
  process.exit(1);
3682
3698
  }
3683
3699
  const noteRaw = flags['note'];
@@ -3698,7 +3714,7 @@ function cmdPredict(hippoRoot, args, flags) {
3698
3714
  const limitRaw = flags['limit'];
3699
3715
  const limit = limitRaw !== undefined ? parseInt(String(limitRaw), 10) : 100;
3700
3716
  if (!Number.isFinite(limit) || limit <= 0) {
3701
- console.error(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
3717
+ printError(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
3702
3718
  process.exit(1);
3703
3719
  }
3704
3720
  let results;
@@ -3722,7 +3738,7 @@ function cmdPredict(hippoRoot, args, flags) {
3722
3738
  }
3723
3739
  else {
3724
3740
  if (!predictionsModule.VALID_CLOSURE_STATES.has(status)) {
3725
- console.error(`Invalid --status: "${status}". Must be one of: open | closed | closed-unknown | all.`);
3741
+ printError(`Invalid --status: "${status}". Must be one of: open | closed | closed-unknown | all.`);
3726
3742
  process.exit(1);
3727
3743
  }
3728
3744
  if (classTag) {
@@ -3733,7 +3749,7 @@ function cmdPredict(hippoRoot, args, flags) {
3733
3749
  }
3734
3750
  else {
3735
3751
  // status filter without class — scan all classes is more complex; v1 requires --class for non-default status
3736
- console.error('--status filter (non-open) requires --class to be set.');
3752
+ printError('--status filter (non-open) requires --class to be set.');
3737
3753
  process.exit(1);
3738
3754
  }
3739
3755
  }
@@ -3756,17 +3772,17 @@ function cmdPredict(hippoRoot, args, flags) {
3756
3772
  if (subcommand === 'show') {
3757
3773
  const idRaw = args[1];
3758
3774
  if (!idRaw) {
3759
- console.error('Usage: hippo predict show <id>');
3775
+ printError('Usage: hippo predict show <id>');
3760
3776
  process.exit(1);
3761
3777
  }
3762
3778
  const id = parseInt(String(idRaw), 10);
3763
3779
  if (!Number.isFinite(id) || id <= 0) {
3764
- console.error(`Invalid prediction id: "${idRaw}"`);
3780
+ printError(`Invalid prediction id: "${idRaw}"`);
3765
3781
  process.exit(1);
3766
3782
  }
3767
3783
  const pred = predictionsModule.loadPredictionById(hippoRoot, tenantId, id);
3768
3784
  if (!pred) {
3769
- console.error(`Prediction ${id} not found.`);
3785
+ printError(`Prediction ${id} not found.`);
3770
3786
  process.exit(1);
3771
3787
  }
3772
3788
  console.log(`Prediction #${pred.id}`);
@@ -3792,7 +3808,7 @@ function cmdPredict(hippoRoot, args, flags) {
3792
3808
  // J3 reference-class / planning-fallacy detector
3793
3809
  const classTagRaw = flags['class'];
3794
3810
  if (typeof classTagRaw !== 'string' || !classTagRaw.trim()) {
3795
- console.error('Usage: hippo predict baserate --class <c>');
3811
+ printError('Usage: hippo predict baserate --class <c>');
3796
3812
  process.exit(1);
3797
3813
  }
3798
3814
  const baserate = predictionsModule.computePredictionBaserate(hippoRoot, tenantId, classTagRaw.trim());
@@ -3820,22 +3836,22 @@ function cmdPredict(hippoRoot, args, flags) {
3820
3836
  // Default subcommand: create. args[0] is the claim text.
3821
3837
  const claimText = subcommand;
3822
3838
  if (!claimText) {
3823
- console.error('Usage: hippo predict "<claim>" --class <c> [--estimate <v>] [--unit <u>] [--target <YYYY-MM-DD>]');
3824
- console.error(' hippo predict close <id> --state <closed|closed-unknown> [--actual <v>] [--note "..."]');
3825
- console.error(' hippo predict list [--class X] [--status open|closed|closed-unknown|all] [--limit N]');
3826
- console.error(' hippo predict show <id>');
3839
+ printError('Usage: hippo predict "<claim>" --class <c> [--estimate <v>] [--unit <u>] [--target <YYYY-MM-DD>]');
3840
+ printError(' hippo predict close <id> --state <closed|closed-unknown> [--actual <v>] [--note "..."]');
3841
+ printError(' hippo predict list [--class X] [--status open|closed|closed-unknown|all] [--limit N]');
3842
+ printError(' hippo predict show <id>');
3827
3843
  process.exit(1);
3828
3844
  }
3829
3845
  const classTagRaw = flags['class'];
3830
3846
  if (typeof classTagRaw !== 'string' || !classTagRaw.trim()) {
3831
- console.error('--class is required for prediction creation.');
3847
+ printError('--class is required for prediction creation.');
3832
3848
  process.exit(1);
3833
3849
  }
3834
3850
  const classTag = classTagRaw.trim();
3835
3851
  const estimateRaw = flags['estimate'];
3836
3852
  const estimateValue = estimateRaw !== undefined ? Number(estimateRaw) : undefined;
3837
3853
  if (estimateRaw !== undefined && !Number.isFinite(estimateValue)) {
3838
- console.error(`Invalid --estimate: "${estimateRaw}". Must be a number.`);
3854
+ printError(`Invalid --estimate: "${estimateRaw}". Must be a number.`);
3839
3855
  process.exit(1);
3840
3856
  }
3841
3857
  const unitRaw = flags['unit'];
@@ -3863,7 +3879,7 @@ function cmdDecide(hippoRoot, args, flags) {
3863
3879
  const limitRaw = flags['limit'];
3864
3880
  const limit = limitRaw !== undefined ? parseInt(String(limitRaw), 10) : 100;
3865
3881
  if (!Number.isFinite(limit) || limit <= 0) {
3866
- console.error(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
3882
+ printError(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
3867
3883
  process.exit(1);
3868
3884
  }
3869
3885
  let results;
@@ -3872,7 +3888,7 @@ function cmdDecide(hippoRoot, args, flags) {
3872
3888
  }
3873
3889
  else {
3874
3890
  if (!decisionsModule.VALID_DECISION_STATES.has(status)) {
3875
- console.error(`Invalid --status: "${status}". Must be one of: active | superseded | closed | all.`);
3891
+ printError(`Invalid --status: "${status}". Must be one of: active | superseded | closed | all.`);
3876
3892
  process.exit(1);
3877
3893
  }
3878
3894
  results = decisionsModule.loadDecisions(hippoRoot, tenantId, {
@@ -3897,17 +3913,17 @@ function cmdDecide(hippoRoot, args, flags) {
3897
3913
  if (subcommand === 'get') {
3898
3914
  const idRaw = args[1];
3899
3915
  if (!idRaw) {
3900
- console.error('Usage: hippo decide get <id>');
3916
+ printError('Usage: hippo decide get <id>');
3901
3917
  process.exit(1);
3902
3918
  }
3903
3919
  const id = parseInt(String(idRaw), 10);
3904
3920
  if (!Number.isFinite(id) || id <= 0) {
3905
- console.error(`Invalid decision id: "${idRaw}"`);
3921
+ printError(`Invalid decision id: "${idRaw}"`);
3906
3922
  process.exit(1);
3907
3923
  }
3908
3924
  const decision = decisionsModule.loadDecisionById(hippoRoot, tenantId, id);
3909
3925
  if (!decision) {
3910
- console.error(`Decision ${id} not found.`);
3926
+ printError(`Decision ${id} not found.`);
3911
3927
  process.exit(1);
3912
3928
  }
3913
3929
  console.log(`Decision #${decision.id}`);
@@ -3929,12 +3945,12 @@ function cmdDecide(hippoRoot, args, flags) {
3929
3945
  if (subcommand === 'close') {
3930
3946
  const idRaw = args[1];
3931
3947
  if (!idRaw) {
3932
- console.error('Usage: hippo decide close <id>');
3948
+ printError('Usage: hippo decide close <id>');
3933
3949
  process.exit(1);
3934
3950
  }
3935
3951
  const id = parseInt(String(idRaw), 10);
3936
3952
  if (!Number.isFinite(id) || id <= 0) {
3937
- console.error(`Invalid decision id: "${idRaw}"`);
3953
+ printError(`Invalid decision id: "${idRaw}"`);
3938
3954
  process.exit(1);
3939
3955
  }
3940
3956
  const closed = decisionsModule.closeDecision(hippoRoot, tenantId, id);
@@ -3944,10 +3960,10 @@ function cmdDecide(hippoRoot, args, flags) {
3944
3960
  // Default subcommand: create. args[0] is the decision text.
3945
3961
  const decisionText = subcommand;
3946
3962
  if (!decisionText) {
3947
- console.error('Usage: hippo decide "<decision>" [--context "<why>"] [--supersedes <memory-id>]');
3948
- console.error(' hippo decide list [--status active|superseded|closed|all] [--limit N]');
3949
- console.error(' hippo decide get <id>');
3950
- console.error(' hippo decide close <id>');
3963
+ printError('Usage: hippo decide "<decision>" [--context "<why>"] [--supersedes <memory-id>]');
3964
+ printError(' hippo decide list [--status active|superseded|closed|all] [--limit N]');
3965
+ printError(' hippo decide get <id>');
3966
+ printError(' hippo decide close <id>');
3951
3967
  process.exit(1);
3952
3968
  }
3953
3969
  const contextRaw = flags['context'];
@@ -3956,7 +3972,7 @@ function cmdDecide(hippoRoot, args, flags) {
3956
3972
  // request: the user asked to supersede but gave no memory id. Reject it rather
3957
3973
  // than silently creating a non-superseding decision (codex review 2026-05-28).
3958
3974
  if (flags['supersedes'] === true) {
3959
- console.error('--supersedes requires a memory id, e.g. hippo decide "<text>" --supersedes mem_abc123.');
3975
+ printError('--supersedes requires a memory id, e.g. hippo decide "<text>" --supersedes mem_abc123.');
3960
3976
  process.exit(1);
3961
3977
  }
3962
3978
  const supersedesMemId = typeof flags['supersedes'] === 'string' ? flags['supersedes'] : null;
@@ -3970,7 +3986,7 @@ function cmdDecide(hippoRoot, args, flags) {
3970
3986
  if (supersedesMemId) {
3971
3987
  oldEntry = readEntry(hippoRoot, supersedesMemId, tenantId) ?? null;
3972
3988
  if (!oldEntry) {
3973
- console.error(`Memory ${supersedesMemId} not found.`);
3989
+ printError(`Memory ${supersedesMemId} not found.`);
3974
3990
  process.exit(1);
3975
3991
  }
3976
3992
  supersedesDecisionId =
@@ -4000,7 +4016,7 @@ function cmdDecide(hippoRoot, args, flags) {
4000
4016
  writeEntry(hippoRoot, oldEntry);
4001
4017
  }
4002
4018
  catch (e) {
4003
- console.error(` warning: decision recorded and superseded, but failed to weaken the prior memory ${supersedesMemId}: ${e.message}`);
4019
+ printError(` warning: decision recorded and superseded, but failed to weaken the prior memory ${supersedesMemId}: ${e.message}`);
4004
4020
  }
4005
4021
  }
4006
4022
  console.log(`Decision recorded: #${created.id}`);
@@ -4021,7 +4037,7 @@ function parsePositiveIncidentId(idRaw) {
4021
4037
  const s = String(idRaw ?? '').trim();
4022
4038
  const id = parseInt(s, 10);
4023
4039
  if (!/^\d+$/.test(s) || id <= 0) {
4024
- console.error(`Invalid incident id: "${idRaw}" (expected a positive integer).`);
4040
+ printError(`Invalid incident id: "${idRaw}" (expected a positive integer).`);
4025
4041
  process.exit(1);
4026
4042
  }
4027
4043
  return id;
@@ -4036,7 +4052,7 @@ function cmdIncident(hippoRoot, args, flags) {
4036
4052
  const limitRaw = flags['limit'];
4037
4053
  const limit = limitRaw !== undefined ? parseInt(String(limitRaw), 10) : 100;
4038
4054
  if (!Number.isFinite(limit) || limit <= 0) {
4039
- console.error(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
4055
+ printError(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
4040
4056
  process.exit(1);
4041
4057
  }
4042
4058
  let results;
@@ -4045,7 +4061,7 @@ function cmdIncident(hippoRoot, args, flags) {
4045
4061
  }
4046
4062
  else {
4047
4063
  if (!incidentsModule.VALID_INCIDENT_STATES.has(status)) {
4048
- console.error(`Invalid --status: "${status}". Must be one of: open | resolved | closed | all.`);
4064
+ printError(`Invalid --status: "${status}". Must be one of: open | resolved | closed | all.`);
4049
4065
  process.exit(1);
4050
4066
  }
4051
4067
  results = incidentsModule.loadIncidents(hippoRoot, tenantId, {
@@ -4070,13 +4086,13 @@ function cmdIncident(hippoRoot, args, flags) {
4070
4086
  if (subcommand === 'get') {
4071
4087
  const idRaw = args[1];
4072
4088
  if (!idRaw) {
4073
- console.error('Usage: hippo incident get <id>');
4089
+ printError('Usage: hippo incident get <id>');
4074
4090
  process.exit(1);
4075
4091
  }
4076
4092
  const id = parsePositiveIncidentId(idRaw);
4077
4093
  const incident = incidentsModule.loadIncidentById(hippoRoot, tenantId, id);
4078
4094
  if (!incident) {
4079
- console.error(`Incident ${id} not found.`);
4095
+ printError(`Incident ${id} not found.`);
4080
4096
  process.exit(1);
4081
4097
  }
4082
4098
  console.log(`Incident #${incident.id}`);
@@ -4101,13 +4117,13 @@ function cmdIncident(hippoRoot, args, flags) {
4101
4117
  if (subcommand === 'resolve') {
4102
4118
  const idRaw = args[1];
4103
4119
  if (!idRaw) {
4104
- console.error('Usage: hippo incident resolve <id> --resolution "<text>"');
4120
+ printError('Usage: hippo incident resolve <id> --resolution "<text>"');
4105
4121
  process.exit(1);
4106
4122
  }
4107
4123
  const id = parsePositiveIncidentId(idRaw);
4108
4124
  const resolutionRaw = flags['resolution'];
4109
4125
  if (typeof resolutionRaw !== 'string' || !resolutionRaw.trim()) {
4110
- console.error('--resolution requires a non-empty value, e.g. hippo incident resolve <id> --resolution "root cause fixed".');
4126
+ printError('--resolution requires a non-empty value, e.g. hippo incident resolve <id> --resolution "root cause fixed".');
4111
4127
  process.exit(1);
4112
4128
  }
4113
4129
  const resolved = incidentsModule.resolveIncident(hippoRoot, tenantId, id, resolutionRaw);
@@ -4117,7 +4133,7 @@ function cmdIncident(hippoRoot, args, flags) {
4117
4133
  if (subcommand === 'close') {
4118
4134
  const idRaw = args[1];
4119
4135
  if (!idRaw) {
4120
- console.error('Usage: hippo incident close <id>');
4136
+ printError('Usage: hippo incident close <id>');
4121
4137
  process.exit(1);
4122
4138
  }
4123
4139
  const id = parsePositiveIncidentId(idRaw);
@@ -4130,11 +4146,11 @@ function cmdIncident(hippoRoot, args, flags) {
4130
4146
  // `open` keyword the text is args[1], otherwise args[0] IS the text.
4131
4147
  const incidentText = subcommand === 'open' ? (args[1] ?? '') : subcommand;
4132
4148
  if (!incidentText) {
4133
- console.error('Usage: hippo incident "<incident>" [--context "<details>"] [--link <memory-id>]...');
4134
- console.error(' hippo incident list [--status open|resolved|closed|all] [--limit N]');
4135
- console.error(' hippo incident get <id>');
4136
- console.error(' hippo incident resolve <id> --resolution "<text>"');
4137
- console.error(' hippo incident close <id>');
4149
+ printError('Usage: hippo incident "<incident>" [--context "<details>"] [--link <memory-id>]...');
4150
+ printError(' hippo incident list [--status open|resolved|closed|all] [--limit N]');
4151
+ printError(' hippo incident get <id>');
4152
+ printError(' hippo incident resolve <id> --resolution "<text>"');
4153
+ printError(' hippo incident close <id>');
4138
4154
  process.exit(1);
4139
4155
  }
4140
4156
  const contextRaw = flags['context'];
@@ -4150,7 +4166,7 @@ function cmdIncident(hippoRoot, args, flags) {
4150
4166
  linkedMemoryIds = [linkRaw];
4151
4167
  }
4152
4168
  else if (linkRaw === true) {
4153
- console.error('--link requires a memory id, e.g. hippo incident "<text>" --link mem_abc123.');
4169
+ printError('--link requires a memory id, e.g. hippo incident "<text>" --link mem_abc123.');
4154
4170
  process.exit(1);
4155
4171
  }
4156
4172
  const incidentPathTags = extractPathTags(process.cwd());
@@ -4176,7 +4192,7 @@ function parsePositiveProcessId(idRaw) {
4176
4192
  const s = String(idRaw ?? '').trim();
4177
4193
  const id = parseInt(s, 10);
4178
4194
  if (!/^\d+$/.test(s) || id <= 0) {
4179
- console.error(`Invalid process id: "${idRaw}" (expected a positive integer).`);
4195
+ printError(`Invalid process id: "${idRaw}" (expected a positive integer).`);
4180
4196
  process.exit(1);
4181
4197
  }
4182
4198
  return id;
@@ -4189,7 +4205,7 @@ function collectProcessSteps(stepRaw) {
4189
4205
  if (typeof stepRaw === 'string')
4190
4206
  return [stepRaw];
4191
4207
  if (stepRaw === true) {
4192
- console.error('--step requires a value, e.g. hippo process new "<name>" --step "do X".');
4208
+ printError('--step requires a value, e.g. hippo process new "<name>" --step "do X".');
4193
4209
  process.exit(1);
4194
4210
  }
4195
4211
  return [];
@@ -4204,7 +4220,7 @@ function cmdProcess(hippoRoot, args, flags) {
4204
4220
  const limitRaw = flags['limit'];
4205
4221
  const limit = limitRaw !== undefined ? parseInt(String(limitRaw), 10) : 100;
4206
4222
  if (!Number.isFinite(limit) || limit <= 0) {
4207
- console.error(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
4223
+ printError(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
4208
4224
  process.exit(1);
4209
4225
  }
4210
4226
  let results;
@@ -4213,7 +4229,7 @@ function cmdProcess(hippoRoot, args, flags) {
4213
4229
  }
4214
4230
  else {
4215
4231
  if (!processesModule.VALID_PROCESS_STATES.has(status)) {
4216
- console.error(`Invalid --status: "${status}". Must be one of: active | superseded | closed | all.`);
4232
+ printError(`Invalid --status: "${status}". Must be one of: active | superseded | closed | all.`);
4217
4233
  process.exit(1);
4218
4234
  }
4219
4235
  results = processesModule.loadProcesses(hippoRoot, tenantId, {
@@ -4237,13 +4253,13 @@ function cmdProcess(hippoRoot, args, flags) {
4237
4253
  if (subcommand === 'get') {
4238
4254
  const idRaw = args[1];
4239
4255
  if (!idRaw) {
4240
- console.error('Usage: hippo process get <id>');
4256
+ printError('Usage: hippo process get <id>');
4241
4257
  process.exit(1);
4242
4258
  }
4243
4259
  const id = parsePositiveProcessId(idRaw);
4244
4260
  const proc = processesModule.loadProcessById(hippoRoot, tenantId, id);
4245
4261
  if (!proc) {
4246
- console.error(`Process ${id} not found.`);
4262
+ printError(`Process ${id} not found.`);
4247
4263
  process.exit(1);
4248
4264
  }
4249
4265
  console.log(`Process #${proc.id}`);
@@ -4272,13 +4288,13 @@ function cmdProcess(hippoRoot, args, flags) {
4272
4288
  if (subcommand === 'supersede') {
4273
4289
  const idRaw = args[1];
4274
4290
  if (!idRaw) {
4275
- console.error('Usage: hippo process supersede <id> --step "<text>" [--step ...] [--change "<summary>"] [--description "<text>"]');
4291
+ printError('Usage: hippo process supersede <id> --step "<text>" [--step ...] [--change "<summary>"] [--description "<text>"]');
4276
4292
  process.exit(1);
4277
4293
  }
4278
4294
  const id = parsePositiveProcessId(idRaw);
4279
4295
  const steps = collectProcessSteps(flags['step']);
4280
4296
  if (steps.length === 0) {
4281
- console.error('hippo process supersede requires at least one --step "<text>" for the new version.');
4297
+ printError('hippo process supersede requires at least one --step "<text>" for the new version.');
4282
4298
  process.exit(1);
4283
4299
  }
4284
4300
  // A supersession is a new version of the SAME process, so the new row reuses
@@ -4287,7 +4303,7 @@ function cmdProcess(hippoRoot, args, flags) {
4287
4303
  // preflight is the authoritative active-state check.
4288
4304
  const existing = processesModule.loadProcessById(hippoRoot, tenantId, id);
4289
4305
  if (!existing) {
4290
- console.error(`Process ${id} not found.`);
4306
+ printError(`Process ${id} not found.`);
4291
4307
  process.exit(1);
4292
4308
  }
4293
4309
  const changeRaw = flags['change'];
@@ -4311,7 +4327,7 @@ function cmdProcess(hippoRoot, args, flags) {
4311
4327
  if (subcommand === 'close') {
4312
4328
  const idRaw = args[1];
4313
4329
  if (!idRaw) {
4314
- console.error('Usage: hippo process close <id>');
4330
+ printError('Usage: hippo process close <id>');
4315
4331
  process.exit(1);
4316
4332
  }
4317
4333
  const id = parsePositiveProcessId(idRaw);
@@ -4324,11 +4340,11 @@ function cmdProcess(hippoRoot, args, flags) {
4324
4340
  // `new` keyword the name is args[1], otherwise args[0] IS the name.
4325
4341
  const processName = subcommand === 'new' ? (args[1] ?? '') : subcommand;
4326
4342
  if (!processName) {
4327
- console.error('Usage: hippo process new "<name>" --step "<text>" [--step ...] [--description "<text>"]');
4328
- console.error(' hippo process list [--status active|superseded|closed|all] [--limit N]');
4329
- console.error(' hippo process get <id>');
4330
- console.error(' hippo process supersede <id> --step "<text>" [--change "<summary>"]');
4331
- console.error(' hippo process close <id>');
4343
+ printError('Usage: hippo process new "<name>" --step "<text>" [--step ...] [--description "<text>"]');
4344
+ printError(' hippo process list [--status active|superseded|closed|all] [--limit N]');
4345
+ printError(' hippo process get <id>');
4346
+ printError(' hippo process supersede <id> --step "<text>" [--change "<summary>"]');
4347
+ printError(' hippo process close <id>');
4332
4348
  process.exit(1);
4333
4349
  }
4334
4350
  const steps = collectProcessSteps(flags['step']);
@@ -4351,7 +4367,7 @@ function parsePositivePolicyId(idRaw) {
4351
4367
  const s = String(idRaw ?? '').trim();
4352
4368
  const id = parseInt(s, 10);
4353
4369
  if (!/^\d+$/.test(s) || id <= 0) {
4354
- console.error(`Invalid policy id: "${idRaw}" (expected a positive integer).`);
4370
+ printError(`Invalid policy id: "${idRaw}" (expected a positive integer).`);
4355
4371
  process.exit(1);
4356
4372
  }
4357
4373
  return id;
@@ -4373,7 +4389,7 @@ function cmdPolicy(hippoRoot, args, flags) {
4373
4389
  const limitRaw = flags['limit'];
4374
4390
  const limit = limitRaw !== undefined ? parseInt(String(limitRaw), 10) : 100;
4375
4391
  if (!Number.isFinite(limit) || limit <= 0) {
4376
- console.error(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
4392
+ printError(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
4377
4393
  process.exit(1);
4378
4394
  }
4379
4395
  let results;
@@ -4382,7 +4398,7 @@ function cmdPolicy(hippoRoot, args, flags) {
4382
4398
  }
4383
4399
  else {
4384
4400
  if (!policiesModule.VALID_POLICY_STATES.has(status)) {
4385
- console.error(`Invalid --status: "${status}". Must be one of: active | superseded | closed | all.`);
4401
+ printError(`Invalid --status: "${status}". Must be one of: active | superseded | closed | all.`);
4386
4402
  process.exit(1);
4387
4403
  }
4388
4404
  results = policiesModule.loadPolicies(hippoRoot, tenantId, {
@@ -4402,7 +4418,7 @@ function cmdPolicy(hippoRoot, args, flags) {
4402
4418
  if (subcommand === 'asof') {
4403
4419
  const dateRaw = args[1];
4404
4420
  if (!dateRaw) {
4405
- console.error('Usage: hippo policy asof <iso-date> [--name "<policy>"]');
4421
+ printError('Usage: hippo policy asof <iso-date> [--name "<policy>"]');
4406
4422
  process.exit(1);
4407
4423
  }
4408
4424
  const nameRaw = flags['name'];
@@ -4412,7 +4428,7 @@ function cmdPolicy(hippoRoot, args, flags) {
4412
4428
  results = policiesModule.loadPoliciesAsOf(hippoRoot, tenantId, dateRaw, { name });
4413
4429
  }
4414
4430
  catch (e) {
4415
- console.error(e.message);
4431
+ printError(e.message);
4416
4432
  process.exit(1);
4417
4433
  }
4418
4434
  if (results.length === 0) {
@@ -4427,13 +4443,13 @@ function cmdPolicy(hippoRoot, args, flags) {
4427
4443
  if (subcommand === 'get') {
4428
4444
  const idRaw = args[1];
4429
4445
  if (!idRaw) {
4430
- console.error('Usage: hippo policy get <id>');
4446
+ printError('Usage: hippo policy get <id>');
4431
4447
  process.exit(1);
4432
4448
  }
4433
4449
  const id = parsePositivePolicyId(idRaw);
4434
4450
  const p = policiesModule.loadPolicyById(hippoRoot, tenantId, id);
4435
4451
  if (!p) {
4436
- console.error(`Policy ${id} not found.`);
4452
+ printError(`Policy ${id} not found.`);
4437
4453
  process.exit(1);
4438
4454
  }
4439
4455
  console.log(`Policy #${p.id}`);
@@ -4459,18 +4475,18 @@ function cmdPolicy(hippoRoot, args, flags) {
4459
4475
  if (subcommand === 'supersede') {
4460
4476
  const idRaw = args[1];
4461
4477
  if (!idRaw) {
4462
- console.error('Usage: hippo policy supersede <id> --text "<rule>" [--from <iso>] [--to <iso>] [--change "<summary>"]');
4478
+ printError('Usage: hippo policy supersede <id> --text "<rule>" [--from <iso>] [--to <iso>] [--change "<summary>"]');
4463
4479
  process.exit(1);
4464
4480
  }
4465
4481
  const id = parsePositivePolicyId(idRaw);
4466
4482
  const textRaw = flags['text'];
4467
4483
  if (typeof textRaw !== 'string' || !textRaw.trim()) {
4468
- console.error('hippo policy supersede requires --text "<rule>" for the new version.');
4484
+ printError('hippo policy supersede requires --text "<rule>" for the new version.');
4469
4485
  process.exit(1);
4470
4486
  }
4471
4487
  const existing = policiesModule.loadPolicyById(hippoRoot, tenantId, id);
4472
4488
  if (!existing) {
4473
- console.error(`Policy ${id} not found.`);
4489
+ printError(`Policy ${id} not found.`);
4474
4490
  process.exit(1);
4475
4491
  }
4476
4492
  const fromRaw = flags['from'];
@@ -4491,7 +4507,7 @@ function cmdPolicy(hippoRoot, args, flags) {
4491
4507
  console.log(` memory: ${created.memoryId}`);
4492
4508
  }
4493
4509
  catch (e) {
4494
- console.error(e.message);
4510
+ printError(e.message);
4495
4511
  process.exit(1);
4496
4512
  }
4497
4513
  return;
@@ -4499,7 +4515,7 @@ function cmdPolicy(hippoRoot, args, flags) {
4499
4515
  if (subcommand === 'close') {
4500
4516
  const idRaw = args[1];
4501
4517
  if (!idRaw) {
4502
- console.error('Usage: hippo policy close <id>');
4518
+ printError('Usage: hippo policy close <id>');
4503
4519
  process.exit(1);
4504
4520
  }
4505
4521
  const id = parsePositivePolicyId(idRaw);
@@ -4512,12 +4528,12 @@ function cmdPolicy(hippoRoot, args, flags) {
4512
4528
  const policyName = subcommand === 'new' ? (args[1] ?? '') : subcommand;
4513
4529
  const textRaw = flags['text'];
4514
4530
  if (!policyName || typeof textRaw !== 'string' || !textRaw.trim()) {
4515
- console.error('Usage: hippo policy new "<name>" --text "<rule>" [--from <iso>] [--to <iso>]');
4516
- console.error(' hippo policy list [--status active|superseded|closed|all] [--limit N]');
4517
- console.error(' hippo policy get <id>');
4518
- console.error(' hippo policy asof <iso-date> [--name "<policy>"]');
4519
- console.error(' hippo policy supersede <id> --text "<rule>" [--from] [--to] [--change "<summary>"]');
4520
- console.error(' hippo policy close <id>');
4531
+ printError('Usage: hippo policy new "<name>" --text "<rule>" [--from <iso>] [--to <iso>]');
4532
+ printError(' hippo policy list [--status active|superseded|closed|all] [--limit N]');
4533
+ printError(' hippo policy get <id>');
4534
+ printError(' hippo policy asof <iso-date> [--name "<policy>"]');
4535
+ printError(' hippo policy supersede <id> --text "<rule>" [--from] [--to] [--change "<summary>"]');
4536
+ printError(' hippo policy close <id>');
4521
4537
  process.exit(1);
4522
4538
  }
4523
4539
  const fromRaw = flags['from'];
@@ -4536,7 +4552,7 @@ function cmdPolicy(hippoRoot, args, flags) {
4536
4552
  console.log(` memory: ${created.memoryId}`);
4537
4553
  }
4538
4554
  catch (e) {
4539
- console.error(e.message);
4555
+ printError(e.message);
4540
4556
  process.exit(1);
4541
4557
  }
4542
4558
  }
@@ -4546,7 +4562,7 @@ function parsePositiveSkillId(idRaw) {
4546
4562
  const s = String(idRaw ?? '').trim();
4547
4563
  const id = parseInt(s, 10);
4548
4564
  if (!/^\d+$/.test(s) || id <= 0) {
4549
- console.error(`Invalid skill id: "${idRaw}" (expected a positive integer).`);
4565
+ printError(`Invalid skill id: "${idRaw}" (expected a positive integer).`);
4550
4566
  process.exit(1);
4551
4567
  }
4552
4568
  return id;
@@ -4568,7 +4584,7 @@ function cmdSkill(hippoRoot, args, flags) {
4568
4584
  const limitRaw = flags['limit'];
4569
4585
  const limit = limitRaw !== undefined ? parseInt(String(limitRaw), 10) : 100;
4570
4586
  if (!Number.isFinite(limit) || limit <= 0) {
4571
- console.error(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
4587
+ printError(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
4572
4588
  process.exit(1);
4573
4589
  }
4574
4590
  let results;
@@ -4577,7 +4593,7 @@ function cmdSkill(hippoRoot, args, flags) {
4577
4593
  }
4578
4594
  else {
4579
4595
  if (!skillsModule.VALID_SKILL_STATES.has(status)) {
4580
- console.error(`Invalid --status: "${status}". Must be one of: active | superseded | closed | all.`);
4596
+ printError(`Invalid --status: "${status}". Must be one of: active | superseded | closed | all.`);
4581
4597
  process.exit(1);
4582
4598
  }
4583
4599
  results = skillsModule.loadSkills(hippoRoot, tenantId, {
@@ -4606,13 +4622,13 @@ function cmdSkill(hippoRoot, args, flags) {
4606
4622
  if (subcommand === 'get') {
4607
4623
  const idRaw = args[1];
4608
4624
  if (!idRaw) {
4609
- console.error('Usage: hippo skill get <id>');
4625
+ printError('Usage: hippo skill get <id>');
4610
4626
  process.exit(1);
4611
4627
  }
4612
4628
  const id = parsePositiveSkillId(idRaw);
4613
4629
  const s = skillsModule.loadSkillById(hippoRoot, tenantId, id);
4614
4630
  if (!s) {
4615
- console.error(`Skill ${id} not found.`);
4631
+ printError(`Skill ${id} not found.`);
4616
4632
  process.exit(1);
4617
4633
  }
4618
4634
  console.log(`Skill #${s.id}`);
@@ -4638,18 +4654,18 @@ function cmdSkill(hippoRoot, args, flags) {
4638
4654
  if (subcommand === 'supersede') {
4639
4655
  const idRaw = args[1];
4640
4656
  if (!idRaw) {
4641
- console.error('Usage: hippo skill supersede <id> --instructions "<text>" [--trigger "<when>"] [--change "<summary>"]');
4657
+ printError('Usage: hippo skill supersede <id> --instructions "<text>" [--trigger "<when>"] [--change "<summary>"]');
4642
4658
  process.exit(1);
4643
4659
  }
4644
4660
  const id = parsePositiveSkillId(idRaw);
4645
4661
  const instrRaw = flags['instructions'];
4646
4662
  if (typeof instrRaw !== 'string' || !instrRaw.trim()) {
4647
- console.error('hippo skill supersede requires --instructions "<text>" for the new version.');
4663
+ printError('hippo skill supersede requires --instructions "<text>" for the new version.');
4648
4664
  process.exit(1);
4649
4665
  }
4650
4666
  const existing = skillsModule.loadSkillById(hippoRoot, tenantId, id);
4651
4667
  if (!existing) {
4652
- console.error(`Skill ${id} not found.`);
4668
+ printError(`Skill ${id} not found.`);
4653
4669
  process.exit(1);
4654
4670
  }
4655
4671
  const trigRaw = flags['trigger'];
@@ -4668,7 +4684,7 @@ function cmdSkill(hippoRoot, args, flags) {
4668
4684
  console.log(` memory: ${created.memoryId}`);
4669
4685
  }
4670
4686
  catch (e) {
4671
- console.error(e.message);
4687
+ printError(e.message);
4672
4688
  process.exit(1);
4673
4689
  }
4674
4690
  return;
@@ -4676,7 +4692,7 @@ function cmdSkill(hippoRoot, args, flags) {
4676
4692
  if (subcommand === 'close') {
4677
4693
  const idRaw = args[1];
4678
4694
  if (!idRaw) {
4679
- console.error('Usage: hippo skill close <id>');
4695
+ printError('Usage: hippo skill close <id>');
4680
4696
  process.exit(1);
4681
4697
  }
4682
4698
  const id = parsePositiveSkillId(idRaw);
@@ -4689,12 +4705,12 @@ function cmdSkill(hippoRoot, args, flags) {
4689
4705
  const skillName = subcommand === 'new' ? (args[1] ?? '') : subcommand;
4690
4706
  const instrRaw = flags['instructions'];
4691
4707
  if (!skillName || typeof instrRaw !== 'string' || !instrRaw.trim()) {
4692
- console.error('Usage: hippo skill new "<name>" --instructions "<text>" [--trigger "<when>"]');
4693
- console.error(' hippo skill list [--status active|superseded|closed|all] [--limit N]');
4694
- console.error(' hippo skill get <id>');
4695
- console.error(' hippo skill export (render active skills as an AGENTS.md/CLAUDE.md block)');
4696
- console.error(' hippo skill supersede <id> --instructions "<text>" [--trigger] [--change "<summary>"]');
4697
- console.error(' hippo skill close <id>');
4708
+ printError('Usage: hippo skill new "<name>" --instructions "<text>" [--trigger "<when>"]');
4709
+ printError(' hippo skill list [--status active|superseded|closed|all] [--limit N]');
4710
+ printError(' hippo skill get <id>');
4711
+ printError(' hippo skill export (render active skills as an AGENTS.md/CLAUDE.md block)');
4712
+ printError(' hippo skill supersede <id> --instructions "<text>" [--trigger] [--change "<summary>"]');
4713
+ printError(' hippo skill close <id>');
4698
4714
  process.exit(1);
4699
4715
  }
4700
4716
  const trigRaw = flags['trigger'];
@@ -4710,7 +4726,7 @@ function cmdSkill(hippoRoot, args, flags) {
4710
4726
  console.log(` memory: ${created.memoryId}`);
4711
4727
  }
4712
4728
  catch (e) {
4713
- console.error(e.message);
4729
+ printError(e.message);
4714
4730
  process.exit(1);
4715
4731
  }
4716
4732
  }
@@ -4718,7 +4734,7 @@ function parsePositiveBriefId(idRaw) {
4718
4734
  const s = String(idRaw ?? '').trim();
4719
4735
  const id = parseInt(s, 10);
4720
4736
  if (!/^\d+$/.test(s) || id <= 0) {
4721
- console.error(`Invalid brief id: "${idRaw}" (expected a positive integer).`);
4737
+ printError(`Invalid brief id: "${idRaw}" (expected a positive integer).`);
4722
4738
  process.exit(1);
4723
4739
  }
4724
4740
  return id;
@@ -4729,12 +4745,12 @@ function printBriefRow(b) {
4729
4745
  console.log(` change: ${b.changeSummary}`);
4730
4746
  }
4731
4747
  function briefUsage() {
4732
- console.error('Usage: hippo brief new "<repo>" --summary "<text>"');
4733
- console.error(' hippo brief list [--status active|superseded|closed|all] [--repo "<repo>"] [--limit N]');
4734
- console.error(' hippo brief get <id>');
4735
- console.error(' hippo brief supersede <id> --summary "<text>" [--change "<summary>"]');
4736
- console.error(' hippo brief close <id>');
4737
- console.error(' hippo brief refresh "<repo>" [--dry-run] (auto-assemble the brief from the repo\'s receipts)');
4748
+ printError('Usage: hippo brief new "<repo>" --summary "<text>"');
4749
+ printError(' hippo brief list [--status active|superseded|closed|all] [--repo "<repo>"] [--limit N]');
4750
+ printError(' hippo brief get <id>');
4751
+ printError(' hippo brief supersede <id> --summary "<text>" [--change "<summary>"]');
4752
+ printError(' hippo brief close <id>');
4753
+ printError(' hippo brief refresh "<repo>" [--dry-run] (auto-assemble the brief from the repo\'s receipts)');
4738
4754
  }
4739
4755
  function cmdProjectBrief(hippoRoot, args, flags) {
4740
4756
  requireInit(hippoRoot);
@@ -4748,13 +4764,13 @@ function cmdProjectBrief(hippoRoot, args, flags) {
4748
4764
  const limitRaw = flags['limit'];
4749
4765
  const limit = limitRaw !== undefined ? parseInt(String(limitRaw), 10) : 100;
4750
4766
  if (!Number.isFinite(limit) || limit <= 0) {
4751
- console.error(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
4767
+ printError(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
4752
4768
  process.exit(1);
4753
4769
  }
4754
4770
  const opts = { limit, repo };
4755
4771
  if (status !== 'all') {
4756
4772
  if (!briefsModule.VALID_BRIEF_STATES.has(status)) {
4757
- console.error(`Invalid --status: "${status}". Must be one of: active | superseded | closed | all.`);
4773
+ printError(`Invalid --status: "${status}". Must be one of: active | superseded | closed | all.`);
4758
4774
  process.exit(1);
4759
4775
  }
4760
4776
  opts.status = status;
@@ -4772,14 +4788,14 @@ function cmdProjectBrief(hippoRoot, args, flags) {
4772
4788
  if (subcommand === 'refresh') {
4773
4789
  const repoRaw = args[1];
4774
4790
  if (!repoRaw) {
4775
- console.error('Usage: hippo brief refresh "<repo>" [--dry-run]');
4791
+ printError('Usage: hippo brief refresh "<repo>" [--dry-run]');
4776
4792
  process.exit(1);
4777
4793
  }
4778
4794
  const dryRun = Boolean(flags['dry-run']);
4779
4795
  try {
4780
4796
  if (dryRun) {
4781
4797
  const { markdown, receiptCount } = briefsModule.assembleBriefFromReceipts(hippoRoot, tenantId, repoRaw);
4782
- console.error(`(dry-run: assembled from ${receiptCount} receipt(s); brief NOT written)`);
4798
+ printError(`(dry-run: assembled from ${receiptCount} receipt(s); brief NOT written)`);
4783
4799
  console.log(markdown);
4784
4800
  return;
4785
4801
  }
@@ -4791,7 +4807,7 @@ function cmdProjectBrief(hippoRoot, args, flags) {
4791
4807
  console.log(` memory: ${created.memoryId}`);
4792
4808
  }
4793
4809
  catch (e) {
4794
- console.error(e.message);
4810
+ printError(e.message);
4795
4811
  process.exit(1);
4796
4812
  }
4797
4813
  return;
@@ -4799,13 +4815,13 @@ function cmdProjectBrief(hippoRoot, args, flags) {
4799
4815
  if (subcommand === 'get') {
4800
4816
  const idRaw = args[1];
4801
4817
  if (!idRaw) {
4802
- console.error('Usage: hippo brief get <id>');
4818
+ printError('Usage: hippo brief get <id>');
4803
4819
  process.exit(1);
4804
4820
  }
4805
4821
  const id = parsePositiveBriefId(idRaw);
4806
4822
  const b = briefsModule.loadProjectBriefById(hippoRoot, tenantId, id);
4807
4823
  if (!b) {
4808
- console.error(`Project brief ${id} not found.`);
4824
+ printError(`Project brief ${id} not found.`);
4809
4825
  process.exit(1);
4810
4826
  }
4811
4827
  console.log(`Project brief #${b.id}`);
@@ -4829,18 +4845,18 @@ function cmdProjectBrief(hippoRoot, args, flags) {
4829
4845
  if (subcommand === 'supersede') {
4830
4846
  const idRaw = args[1];
4831
4847
  if (!idRaw) {
4832
- console.error('Usage: hippo brief supersede <id> --summary "<text>" [--change "<summary>"]');
4848
+ printError('Usage: hippo brief supersede <id> --summary "<text>" [--change "<summary>"]');
4833
4849
  process.exit(1);
4834
4850
  }
4835
4851
  const id = parsePositiveBriefId(idRaw);
4836
4852
  const summaryRaw = flags['summary'];
4837
4853
  if (typeof summaryRaw !== 'string' || !summaryRaw.trim()) {
4838
- console.error('hippo brief supersede requires --summary "<text>" for the new version.');
4854
+ printError('hippo brief supersede requires --summary "<text>" for the new version.');
4839
4855
  process.exit(1);
4840
4856
  }
4841
4857
  const existing = briefsModule.loadProjectBriefById(hippoRoot, tenantId, id);
4842
4858
  if (!existing) {
4843
- console.error(`Project brief ${id} not found.`);
4859
+ printError(`Project brief ${id} not found.`);
4844
4860
  process.exit(1);
4845
4861
  }
4846
4862
  const changeRaw = flags['change'];
@@ -4857,7 +4873,7 @@ function cmdProjectBrief(hippoRoot, args, flags) {
4857
4873
  console.log(` memory: ${created.memoryId}`);
4858
4874
  }
4859
4875
  catch (e) {
4860
- console.error(e.message);
4876
+ printError(e.message);
4861
4877
  process.exit(1);
4862
4878
  }
4863
4879
  return;
@@ -4865,7 +4881,7 @@ function cmdProjectBrief(hippoRoot, args, flags) {
4865
4881
  if (subcommand === 'close') {
4866
4882
  const idRaw = args[1];
4867
4883
  if (!idRaw) {
4868
- console.error('Usage: hippo brief close <id>');
4884
+ printError('Usage: hippo brief close <id>');
4869
4885
  process.exit(1);
4870
4886
  }
4871
4887
  const id = parsePositiveBriefId(idRaw);
@@ -4892,7 +4908,7 @@ function cmdProjectBrief(hippoRoot, args, flags) {
4892
4908
  console.log(` memory: ${created.memoryId}`);
4893
4909
  }
4894
4910
  catch (e) {
4895
- console.error(e.message);
4911
+ printError(e.message);
4896
4912
  process.exit(1);
4897
4913
  }
4898
4914
  }
@@ -4900,7 +4916,7 @@ function parsePositiveNoteId(idRaw) {
4900
4916
  const s = String(idRaw ?? '').trim();
4901
4917
  const id = parseInt(s, 10);
4902
4918
  if (!/^\d+$/.test(s) || id <= 0) {
4903
- console.error(`Invalid note id: "${idRaw}" (expected a positive integer).`);
4919
+ printError(`Invalid note id: "${idRaw}" (expected a positive integer).`);
4904
4920
  process.exit(1);
4905
4921
  }
4906
4922
  return id;
@@ -4911,11 +4927,11 @@ function printNoteRow(n) {
4911
4927
  console.log(` change: ${n.changeSummary}`);
4912
4928
  }
4913
4929
  function noteUsage() {
4914
- console.error('Usage: hippo note new "<customer>" --text "<note>"');
4915
- console.error(' hippo note list [--status active|superseded|closed|all] [--customer "<id>"] [--limit N]');
4916
- console.error(' hippo note get <id>');
4917
- console.error(' hippo note supersede <id> --text "<note>" [--change "<summary>"]');
4918
- console.error(' hippo note close <id>');
4930
+ printError('Usage: hippo note new "<customer>" --text "<note>"');
4931
+ printError(' hippo note list [--status active|superseded|closed|all] [--customer "<id>"] [--limit N]');
4932
+ printError(' hippo note get <id>');
4933
+ printError(' hippo note supersede <id> --text "<note>" [--change "<summary>"]');
4934
+ printError(' hippo note close <id>');
4919
4935
  }
4920
4936
  function cmdGraph(hippoRoot, args, flags) {
4921
4937
  requireInit(hippoRoot);
@@ -4929,7 +4945,7 @@ function cmdGraph(hippoRoot, args, flags) {
4929
4945
  const supersedes = result.relations - result.references;
4930
4946
  console.log(`Graph extracted: ${result.entities} entities (${byType}) + ${result.relations} relations (${supersedes} supersedes, ${result.references} references).`);
4931
4947
  if (result.truncated.length > 0) {
4932
- console.error(`WARNING: under-extracted (hit the per-type cap): ${result.truncated.join(', ')}. The graph is incomplete for those types.`);
4948
+ printError(`WARNING: under-extracted (hit the per-type cap): ${result.truncated.join(', ')}. The graph is incomplete for those types.`);
4933
4949
  }
4934
4950
  return;
4935
4951
  }
@@ -4968,7 +4984,7 @@ function cmdGraph(hippoRoot, args, flags) {
4968
4984
  if (subcommand === 'view') {
4969
4985
  const format = typeof flags['format'] === 'string' ? flags['format'] : 'html';
4970
4986
  if (format !== 'html' && format !== 'canvas') {
4971
- console.error("graph view: --format must be 'html' or 'canvas'");
4987
+ printError("graph view: --format must be 'html' or 'canvas'");
4972
4988
  process.exit(1);
4973
4989
  }
4974
4990
  const model = buildGraphModel(hippoRoot, tenantId, { entity, limit: DEFAULT_VIEW_LIMIT });
@@ -4998,7 +5014,7 @@ function cmdGraph(hippoRoot, args, flags) {
4998
5014
  }
4999
5015
  return;
5000
5016
  }
5001
- console.error('Usage:\n' +
5017
+ printError('Usage:\n' +
5002
5018
  ' hippo graph extract Rebuild the entity/relation graph from consolidated objects\n' +
5003
5019
  ' hippo graph show [--entity NAME] [--json] Inspect entities + their edges (text or JSON)\n' +
5004
5020
  ' hippo graph view [--out FILE] [--open] [--format html|canvas] [--entity NAME] Generate an interactive node-link diagram');
@@ -5016,13 +5032,13 @@ function cmdCustomerNote(hippoRoot, args, flags) {
5016
5032
  const limitRaw = flags['limit'];
5017
5033
  const limit = limitRaw !== undefined ? parseInt(String(limitRaw), 10) : 100;
5018
5034
  if (!Number.isFinite(limit) || limit <= 0) {
5019
- console.error(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
5035
+ printError(`Invalid --limit: "${limitRaw}". Must be a positive integer.`);
5020
5036
  process.exit(1);
5021
5037
  }
5022
5038
  const opts = { limit, customer };
5023
5039
  if (status !== 'all') {
5024
5040
  if (!customerNotesModule.VALID_NOTE_STATES.has(status)) {
5025
- console.error(`Invalid --status: "${status}". Must be one of: active | superseded | closed | all.`);
5041
+ printError(`Invalid --status: "${status}". Must be one of: active | superseded | closed | all.`);
5026
5042
  process.exit(1);
5027
5043
  }
5028
5044
  opts.status = status;
@@ -5040,13 +5056,13 @@ function cmdCustomerNote(hippoRoot, args, flags) {
5040
5056
  if (subcommand === 'get') {
5041
5057
  const idRaw = args[1];
5042
5058
  if (!idRaw) {
5043
- console.error('Usage: hippo note get <id>');
5059
+ printError('Usage: hippo note get <id>');
5044
5060
  process.exit(1);
5045
5061
  }
5046
5062
  const id = parsePositiveNoteId(idRaw);
5047
5063
  const n = customerNotesModule.loadCustomerNoteById(hippoRoot, tenantId, id);
5048
5064
  if (!n) {
5049
- console.error(`Customer note ${id} not found.`);
5065
+ printError(`Customer note ${id} not found.`);
5050
5066
  process.exit(1);
5051
5067
  }
5052
5068
  console.log(`Customer note #${n.id}`);
@@ -5070,18 +5086,18 @@ function cmdCustomerNote(hippoRoot, args, flags) {
5070
5086
  if (subcommand === 'supersede') {
5071
5087
  const idRaw = args[1];
5072
5088
  if (!idRaw) {
5073
- console.error('Usage: hippo note supersede <id> --text "<note>" [--change "<summary>"]');
5089
+ printError('Usage: hippo note supersede <id> --text "<note>" [--change "<summary>"]');
5074
5090
  process.exit(1);
5075
5091
  }
5076
5092
  const id = parsePositiveNoteId(idRaw);
5077
5093
  const textRaw = flags['text'];
5078
5094
  if (typeof textRaw !== 'string' || !textRaw.trim()) {
5079
- console.error('hippo note supersede requires --text "<note>" for the new version.');
5095
+ printError('hippo note supersede requires --text "<note>" for the new version.');
5080
5096
  process.exit(1);
5081
5097
  }
5082
5098
  const existing = customerNotesModule.loadCustomerNoteById(hippoRoot, tenantId, id);
5083
5099
  if (!existing) {
5084
- console.error(`Customer note ${id} not found.`);
5100
+ printError(`Customer note ${id} not found.`);
5085
5101
  process.exit(1);
5086
5102
  }
5087
5103
  const changeRaw = flags['change'];
@@ -5098,7 +5114,7 @@ function cmdCustomerNote(hippoRoot, args, flags) {
5098
5114
  console.log(` memory: ${created.memoryId}`);
5099
5115
  }
5100
5116
  catch (e) {
5101
- console.error(e.message);
5117
+ printError(e.message);
5102
5118
  process.exit(1);
5103
5119
  }
5104
5120
  return;
@@ -5106,7 +5122,7 @@ function cmdCustomerNote(hippoRoot, args, flags) {
5106
5122
  if (subcommand === 'close') {
5107
5123
  const idRaw = args[1];
5108
5124
  if (!idRaw) {
5109
- console.error('Usage: hippo note close <id>');
5125
+ printError('Usage: hippo note close <id>');
5110
5126
  process.exit(1);
5111
5127
  }
5112
5128
  const id = parsePositiveNoteId(idRaw);
@@ -5133,7 +5149,7 @@ function cmdCustomerNote(hippoRoot, args, flags) {
5133
5149
  console.log(` memory: ${created.memoryId}`);
5134
5150
  }
5135
5151
  catch (e) {
5136
- console.error(e.message);
5152
+ printError(e.message);
5137
5153
  process.exit(1);
5138
5154
  }
5139
5155
  }
@@ -5188,9 +5204,21 @@ function cmdCurrent(hippoRoot, args, flags) {
5188
5204
  }
5189
5205
  return;
5190
5206
  }
5191
- console.error('Usage: hippo current <show>');
5207
+ printError('Usage: hippo current <show>');
5192
5208
  process.exit(1);
5193
5209
  }
5210
+ /** Hook commands share one handle per store and wait at most HOOK_DB_WAIT_MS for a lock; a store still busy after that skips the hook's work with one warning, exit 0. */
5211
+ async function runHookWithStores(fn) {
5212
+ try {
5213
+ return await withSharedStoreHandles(fn, { busyWaitMs: HOOK_DB_WAIT_MS });
5214
+ }
5215
+ catch (error) {
5216
+ if (!isSqliteBusy(error))
5217
+ throw error;
5218
+ noteStoreBusy('hook skipped');
5219
+ return undefined;
5220
+ }
5221
+ }
5194
5222
  async function cmdContext(hippoRoot, args, flags, stdinText) {
5195
5223
  const rec = startDeliveryRecorder(hippoRoot, flags, stdinText);
5196
5224
  // No try/finally: a render throw keeps its own exit code and writes no event.
@@ -5216,7 +5244,8 @@ function startDeliveryRecorder(hippoRoot, flags, stdinText) {
5216
5244
  });
5217
5245
  }
5218
5246
  catch (error) {
5219
- console.error(`[hippo] delivery ledger skipped: ${error instanceof Error ? error.message : String(error)}`);
5247
+ // The hook's one-line stderr contract pins this exact text, so it bypasses the leveled logger.
5248
+ printError(`[hippo] delivery ledger skipped:${error instanceof Error ? error.message : String(error)}`);
5220
5249
  return null;
5221
5250
  }
5222
5251
  }
@@ -5228,9 +5257,26 @@ function flushDeliveryRecorder(rec, db) {
5228
5257
  rec.flush((input) => (db ? writeDeliveryEventOnHandle(db, input) : writeDeliveryEventAtRoot(rec.root, input)));
5229
5258
  }
5230
5259
  catch (error) {
5231
- console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
5260
+ // Pinned stderr text, as in the recorder build above.
5261
+ printError(`[hippo] delivery ledger write failed:${error instanceof Error ? error.message : String(error)}`);
5232
5262
  }
5233
5263
  }
5264
+ /**
5265
+ * Whether this session sits in the pilot's holdout arm (src/pilot-arm.ts). Off at rate 0 and with no session id.
5266
+ * `write` books the arm row; a read-only caller (env-only id, sub-agent) follows the stored arm, else the hash.
5267
+ */
5268
+ function inPilotHoldout(hippoRoot, tenantId, sessionId, write) {
5269
+ if (sessionId === undefined || sessionId.trim() === '')
5270
+ return false;
5271
+ const root = isInitialized(hippoRoot) ? hippoRoot : isInitialized(getGlobalRoot()) ? getGlobalRoot() : null;
5272
+ if (root === null)
5273
+ return false;
5274
+ const rate = loadConfig(root).pilot.holdoutRateBp;
5275
+ if (rate <= 0)
5276
+ return false;
5277
+ const arm = withLedgerDb(hippoRoot, (db) => write ? ensurePilotArm(db, tenantId, sessionId, rate) : readPilotArm(db, sessionId) ?? hashArm(sessionId, rate));
5278
+ return (arm ?? hashArm(sessionId, rate)) === 'holdout';
5279
+ }
5234
5280
  async function renderContext(hippoRoot, args, flags, stdinText, rec) {
5235
5281
  // --pinned-only fires on every UserPromptSubmit — including in directories
5236
5282
  // that don't have a local .hippo. Skip requireInit for that path and fall
@@ -5240,30 +5286,6 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
5240
5286
  if (!pinnedOnly) {
5241
5287
  requireInit(hippoRoot);
5242
5288
  }
5243
- const budget = parseBudgetFlag(flags['budget'], 1500);
5244
- if (budget <= 0) {
5245
- rec?.disabled();
5246
- return;
5247
- }
5248
- // Resolve query: explicit args, --auto (git diff via CLI-side helper), or
5249
- // fall through to api.getContext's '*' fallback. api.getContext is host-
5250
- // agnostic so the auto-detect (which shells out to git) stays CLI-side.
5251
- let query = args.join(' ').trim();
5252
- if (!query && flags['auto']) {
5253
- query = autoDetectContext();
5254
- }
5255
- // Scope detection (CLI-side: uses cwd). api.getContext takes the resolved
5256
- // scope via opts.scope to stay host-agnostic.
5257
- const ctxExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
5258
- const ctxActiveScope = ctxExplicitScope || detectScope();
5259
- const ctx = {
5260
- hippoRoot,
5261
- tenantId: resolveTenantId({}),
5262
- actor: api.adminActor('cli'),
5263
- };
5264
- // v39 memory scope isolation: --cross-project re-includes other-project
5265
- // memories (rendered under a demarcated section below).
5266
- const crossProject = flags['cross-project'] === true;
5267
5289
  // DF1 T2: resolve the calling session's id for the bounded active-task-
5268
5290
  // snapshot read (api.getContext -> loadFreshActiveTaskSnapshot). Stdin
5269
5291
  // payload (the UserPromptSubmit hook JSON) wins; falls back to
@@ -5291,6 +5313,36 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
5291
5313
  const ledgerSessionId = subagent ? undefined : currentSessionId;
5292
5314
  if (subagent)
5293
5315
  payloadSessionId = undefined;
5316
+ // The pilot arm is booked at the first hook call whatever the flags, so the holdout sees no budget or content branch.
5317
+ const resolvedTenant = resolveTenantId({});
5318
+ if (inPilotHoldout(hippoRoot, resolvedTenant, currentSessionId, payloadSessionId !== undefined)) {
5319
+ rec?.disabled();
5320
+ return;
5321
+ }
5322
+ const budget = parseBudgetFlag(flags['budget'], 1500);
5323
+ if (budget <= 0) {
5324
+ rec?.disabled();
5325
+ return;
5326
+ }
5327
+ // Resolve query: explicit args, --auto (git diff via CLI-side helper), or
5328
+ // fall through to api.getContext's '*' fallback. api.getContext is host-
5329
+ // agnostic so the auto-detect (which shells out to git) stays CLI-side.
5330
+ let query = args.join(' ').trim();
5331
+ if (!query && flags['auto']) {
5332
+ query = autoDetectContext();
5333
+ }
5334
+ // Scope detection (CLI-side: uses cwd). api.getContext takes the resolved
5335
+ // scope via opts.scope to stay host-agnostic.
5336
+ const ctxExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
5337
+ const ctxActiveScope = ctxExplicitScope || detectScope();
5338
+ const ctx = {
5339
+ hippoRoot,
5340
+ tenantId: resolvedTenant,
5341
+ actor: api.adminActor('cli'),
5342
+ };
5343
+ // v39 memory scope isolation: --cross-project re-includes other-project
5344
+ // memories (rendered under a demarcated section below).
5345
+ const crossProject = flags['cross-project'] === true;
5294
5346
  const format = String(flags['format'] ?? 'markdown');
5295
5347
  const framing = String(flags['framing'] ?? 'observe');
5296
5348
  const opts = {
@@ -5447,8 +5499,12 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
5447
5499
  tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface, event: 'inject',
5448
5500
  items: staticItems.length, tokens: estimateTokens(finalStatic), hash: blockHash(finalStatic),
5449
5501
  });
5502
+ // Best-effort row: only a busy store is actionable, and a ledger failure must not break the hook.
5503
+ }
5504
+ catch (error) {
5505
+ if (isSqliteBusy(error))
5506
+ noteStoreBusy('token ledger row skipped');
5450
5507
  }
5451
- catch { /* best effort; see withLedgerDb doc comment */ }
5452
5508
  }
5453
5509
  if (recallBlock) {
5454
5510
  try {
@@ -5456,8 +5512,12 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
5456
5512
  tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: 'hook_recall', event: 'inject',
5457
5513
  items: recallItems.length, tokens: estimateTokens(recallBlock), hash: blockHash(recallBlock),
5458
5514
  });
5515
+ // Same best-effort rule as the inject row above.
5516
+ }
5517
+ catch (error) {
5518
+ if (isSqliteBusy(error))
5519
+ noteStoreBusy('token ledger row skipped');
5459
5520
  }
5460
- catch { /* best effort; see withLedgerDb doc comment */ }
5461
5521
  }
5462
5522
  flushDeliveryRecorder(rec, db);
5463
5523
  });
@@ -5560,7 +5620,7 @@ async function cmdEmbed(hippoRoot, flags) {
5560
5620
  return resolveEmbeddingProvider(root);
5561
5621
  }
5562
5622
  catch (err) {
5563
- console.error(err instanceof Error ? err.message : String(err));
5623
+ printError(err instanceof Error ? err.message : String(err));
5564
5624
  return null;
5565
5625
  }
5566
5626
  })();
@@ -5578,8 +5638,8 @@ async function cmdEmbed(hippoRoot, flags) {
5578
5638
  console.log(' npm install @huggingface/transformers');
5579
5639
  }
5580
5640
  else {
5581
- console.error(`Embedding provider '${embedProvider.kind}' is configured but ${embedProvider.keyEnv} is not set.`);
5582
- console.error(`Export ${embedProvider.keyEnv}, or set config.embeddings.provider back to 'local'.`);
5641
+ printError(`Embedding provider '${embedProvider.kind}' is configured but ${embedProvider.keyEnv} is not set.`);
5642
+ printError(`Export ${embedProvider.keyEnv}, or set config.embeddings.provider back to 'local'.`);
5583
5643
  process.exitCode = 1;
5584
5644
  }
5585
5645
  return;
@@ -5590,9 +5650,9 @@ async function cmdEmbed(hippoRoot, flags) {
5590
5650
  count = await embedAll(root, resolveEmbeddingModel(root));
5591
5651
  }
5592
5652
  catch (err) {
5593
- console.error(`Embedding failed: ${err instanceof Error ? err.message : String(err)}`);
5653
+ printError(`Embedding failed: ${err instanceof Error ? err.message : String(err)}`);
5594
5654
  const partial = loadEmbeddingIndex(root);
5595
- console.error(`Partial progress saved: ${Object.keys(partial).length} embeddings on disk. Re-run \`hippo embed\` to resume.`);
5655
+ printError(`Partial progress saved: ${Object.keys(partial).length} embeddings on disk. Re-run \`hippo embed\` to resume.`);
5596
5656
  process.exitCode = 1;
5597
5657
  return;
5598
5658
  }
@@ -5605,7 +5665,7 @@ async function cmdEmbed(hippoRoot, flags) {
5605
5665
  // ---------------------------------------------------------------------------
5606
5666
  async function cmdWatch(command, hippoRoot) {
5607
5667
  if (!command) {
5608
- console.error('Usage: hippo watch "<command>"');
5668
+ printError('Usage: hippo watch "<command>"');
5609
5669
  process.exit(1);
5610
5670
  }
5611
5671
  const { exitCode, stderr } = await runWatched(command);
@@ -5615,7 +5675,7 @@ async function cmdWatch(command, hippoRoot) {
5615
5675
  }
5616
5676
  // Only create memory if hippo is initialized
5617
5677
  if (!isInitialized(hippoRoot)) {
5618
- console.error('Command failed but .hippo not initialized. Run `hippo init` to enable auto-learn.');
5678
+ printError('Command failed but .hippo not initialized. Run `hippo init` to enable auto-learn.');
5619
5679
  process.exit(exitCode);
5620
5680
  }
5621
5681
  const entry = captureError(exitCode, stderr, command, resolveTenantId({}));
@@ -5634,11 +5694,11 @@ async function cmdWatch(command, hippoRoot) {
5634
5694
  updateStats(hippoRoot, { remembered: 1 });
5635
5695
  void embedMemory(hippoRoot, entry);
5636
5696
  const preview = stderr.trim().slice(0, 80);
5637
- console.error(`\nHippo learned from failure: "${preview}"`);
5697
+ printError(`\nHippo learned from failure: "${preview}"`);
5638
5698
  }
5639
5699
  catch (err) {
5640
5700
  if (err instanceof RejectedValueError) {
5641
- console.error(`\nHippo: this failure matches a rejected value (${err.reason ?? 'no reason given'}); not stored.`);
5701
+ printError(`\nHippo: this failure matches a rejected value (${err.reason ?? 'no reason given'}); not stored.`);
5642
5702
  }
5643
5703
  else {
5644
5704
  throw err;
@@ -5652,7 +5712,7 @@ async function cmdWatch(command, hippoRoot) {
5652
5712
  function cmdLearn(hippoRoot, flags) {
5653
5713
  requireInit(hippoRoot);
5654
5714
  if (!flags['git']) {
5655
- console.error('Usage: hippo learn --git [--days <n>] [--repos <paths>]');
5715
+ printError('Usage: hippo learn --git [--days <n>] [--repos <paths>]');
5656
5716
  process.exit(1);
5657
5717
  }
5658
5718
  const days = parseInt(String(flags['days'] ?? '7'), 10);
@@ -5678,6 +5738,10 @@ function cmdLearn(hippoRoot, flags) {
5678
5738
  // ---------------------------------------------------------------------------
5679
5739
  // Import command
5680
5740
  // ---------------------------------------------------------------------------
5741
+ function warnRedacted(count) {
5742
+ if (count)
5743
+ printError(`Warning: secret-shaped text was redacted from ${count} imported ${count === 1 ? 'entry' : 'entries'} before storing`);
5744
+ }
5681
5745
  function cmdImport(hippoRoot, args, flags) {
5682
5746
  const useGlobal = Boolean(flags['global']);
5683
5747
  const dryRun = Boolean(flags['dry-run']);
@@ -5696,7 +5760,7 @@ function cmdImport(hippoRoot, args, flags) {
5696
5760
  for (const line of detailLines(report, dryRun))
5697
5761
  console.log(line);
5698
5762
  for (const warning of report.warnings)
5699
- console.error(`hippo: agent memories: ${warning}`);
5763
+ printError(`hippo: agent memories: ${warning}`);
5700
5764
  return;
5701
5765
  }
5702
5766
  if (useGlobal) {
@@ -5720,11 +5784,11 @@ function cmdImport(hippoRoot, args, flags) {
5720
5784
  if (flags['vault']) {
5721
5785
  const folderPath = String(flags['vault']);
5722
5786
  if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) {
5723
- console.error(`Vault folder not found (or not a directory): ${folderPath}`);
5787
+ printError(`Vault folder not found (or not a directory): ${folderPath}`);
5724
5788
  process.exit(1);
5725
5789
  }
5726
5790
  if (useGlobal) {
5727
- console.error('hippo import --vault does not support --global (raw rows are tenant-local).');
5791
+ printError('hippo import --vault does not support --global (raw rows are tenant-local).');
5728
5792
  process.exit(1);
5729
5793
  }
5730
5794
  if (typeof flags['name'] !== 'string' || !flags['name'].trim()) {
@@ -5733,14 +5797,14 @@ function cmdImport(hippoRoot, args, flags) {
5733
5797
  // clobber each other (codex R10 P2). A valueless `--name` parses as boolean
5734
5798
  // true, and String(true) === "true" would silently import under vault:true:*
5735
5799
  // - reject a non-string so it fails fast instead (codex R11 P2).
5736
- console.error('hippo import --vault requires --name <vault> (a non-empty identity key for source-deletion sync).');
5800
+ printError('hippo import --vault requires --name <vault> (a non-empty identity key for source-deletion sync).');
5737
5801
  process.exit(1);
5738
5802
  }
5739
5803
  if (flags['scope'] !== undefined && (typeof flags['scope'] !== 'string' || !flags['scope'].trim())) {
5740
5804
  // Same valueless-flag trap: a bare `--scope` must not become scope "true".
5741
5805
  // Example uses the source-prefixed private form, since a bare `private` scope
5742
5806
  // is NOT treated as private by recall and importVault rejects it (R13 P2).
5743
- console.error('hippo import --vault: --scope requires a value (e.g. --scope vault:private:notes).');
5807
+ printError('hippo import --vault: --scope requires a value (e.g. --scope vault:private:notes).');
5744
5808
  process.exit(1);
5745
5809
  }
5746
5810
  const tenantId = resolveTenantId({});
@@ -5758,6 +5822,7 @@ function cmdImport(hippoRoot, args, flags) {
5758
5822
  if ((vaultResult.rejected ?? 0) > 0) {
5759
5823
  console.log(` Rejected (tombstoned): ${vaultResult.rejected}`);
5760
5824
  }
5825
+ warnRedacted(vaultResult.redacted);
5761
5826
  console.log(` ${dryRun ? 'Would archive: ' : 'Archived (removed): '}${vaultResult.archived ?? 0}`);
5762
5827
  console.log(` Store: ${hippoRoot}`);
5763
5828
  // Batch producer, same contract as the single-file import below: vault rows
@@ -5804,11 +5869,11 @@ function cmdImport(hippoRoot, args, flags) {
5804
5869
  importerName = 'File';
5805
5870
  }
5806
5871
  if (!filePath || !importer) {
5807
- console.error('Usage: hippo import <--chatgpt|--claude|--cursor|--file|--markdown|--vault> <path>, or hippo import --agents [--dry-run]');
5872
+ printError('Usage: hippo import <--chatgpt|--claude|--cursor|--file|--markdown|--vault> <path>, or hippo import --agents [--dry-run]');
5808
5873
  process.exit(1);
5809
5874
  }
5810
5875
  if (!fs.existsSync(filePath)) {
5811
- console.error(`File not found: ${filePath}`);
5876
+ printError(`File not found: ${filePath}`);
5812
5877
  process.exit(1);
5813
5878
  }
5814
5879
  const result = importer(filePath, importOptions);
@@ -5829,6 +5894,7 @@ function cmdImport(hippoRoot, args, flags) {
5829
5894
  if ((result.rejected ?? 0) > 0) {
5830
5895
  console.log(` Rejected (tombstoned): ${result.rejected}`);
5831
5896
  }
5897
+ warnRedacted(result.redacted);
5832
5898
  if (dryRun) {
5833
5899
  console.log('\n (dry run - nothing written)');
5834
5900
  if (result.entries.length > 0) {
@@ -5851,7 +5917,7 @@ function cmdImport(hippoRoot, args, flags) {
5851
5917
  function cmdPromote(hippoRoot, id) {
5852
5918
  requireInit(hippoRoot);
5853
5919
  if (!id) {
5854
- console.error('Usage: hippo promote <id>');
5920
+ printError('Usage: hippo promote <id>');
5855
5921
  process.exit(1);
5856
5922
  }
5857
5923
  const ctx = {
@@ -5865,7 +5931,7 @@ function cmdPromote(hippoRoot, id) {
5865
5931
  console.log(` Global store: ${getGlobalRoot()}`);
5866
5932
  }
5867
5933
  catch (err) {
5868
- console.error(`Failed to promote: ${err.message}`);
5934
+ printError(`Failed to promote: ${err.message}`);
5869
5935
  process.exit(1);
5870
5936
  }
5871
5937
  }
@@ -5903,8 +5969,8 @@ function cmdHook(args, flags) {
5903
5969
  }
5904
5970
  if (subcommand === 'install') {
5905
5971
  if (!target || !HOOKS[target]) {
5906
- console.error(`Unknown hook target: ${target ?? '(none)'}`);
5907
- console.error(` Available: ${Object.keys(HOOKS).join(', ')}`);
5972
+ printError(`Unknown hook target: ${target ?? '(none)'}`);
5973
+ printError(` Available: ${Object.keys(HOOKS).join(', ')}`);
5908
5974
  process.exit(1);
5909
5975
  }
5910
5976
  const hook = HOOKS[target];
@@ -6004,7 +6070,7 @@ function cmdHook(args, flags) {
6004
6070
  }
6005
6071
  if (subcommand === 'uninstall') {
6006
6072
  if (!target || !HOOKS[target]) {
6007
- console.error(`Unknown hook target: ${target ?? '(none)'}`);
6073
+ printError(`Unknown hook target: ${target ?? '(none)'}`);
6008
6074
  process.exit(1);
6009
6075
  }
6010
6076
  const hook = HOOKS[target];
@@ -6064,7 +6130,7 @@ function cmdHook(args, flags) {
6064
6130
  }
6065
6131
  return;
6066
6132
  }
6067
- console.error('Usage: hippo hook <install|uninstall|list> [target]');
6133
+ printError('Usage: hippo hook <install|uninstall|list> [target]');
6068
6134
  process.exit(1);
6069
6135
  }
6070
6136
  function withoutHookBlock(text) {
@@ -6211,7 +6277,7 @@ function cmdDailyRunner() {
6211
6277
  const globalRoot = getGlobalRoot();
6212
6278
  // No workspace sleep ever opens the global store, yet hooks in folders without a store compact into it.
6213
6279
  if (isInitialized(globalRoot)) {
6214
- const finished = replayCompactionsAt(globalRoot, (message) => console.error(`compaction replay: ${message}`));
6280
+ const finished = replayCompactionsAt(globalRoot, (message) => log.warn(`compaction replay: ${message}`));
6215
6281
  if (finished > 0)
6216
6282
  console.log(`Finished saving ${finished} compaction${finished === 1 ? '' : 's'} left over in the global store.`);
6217
6283
  }
@@ -6237,7 +6303,7 @@ function cmdDailyRunner() {
6237
6303
  catch (err) {
6238
6304
  failed++;
6239
6305
  const action = args.join(' ');
6240
- console.error(`[hippo] daily-runner failed in ${cwd} during \`${action}\`: ${err.message}`);
6306
+ log.error(`daily-runner failed in ${cwd} during \`${action}\`: ${err.message}`);
6241
6307
  }
6242
6308
  });
6243
6309
  console.log(`Daily maintenance complete: ${processed} workspace${processed === 1 ? '' : 's'} processed, ${failed} command failure${failed === 1 ? '' : 's'}.`);
@@ -6255,7 +6321,7 @@ function cmdWm(hippoRoot, args, flags) {
6255
6321
  const sessionId = flags['session'] ? String(flags['session']).trim() : undefined;
6256
6322
  const taskId = flags['task'] ? String(flags['task']).trim() : undefined;
6257
6323
  if (!content) {
6258
- console.error('Usage: hippo wm push --scope <scope> --content "..." [--importance 0.8] [--session <id>] [--task <id>]');
6324
+ printError('Usage: hippo wm push --scope <scope> --content "..." [--importance 0.8] [--session <id>] [--task <id>]');
6259
6325
  process.exit(1);
6260
6326
  }
6261
6327
  const id = wmPush(hippoRoot, {
@@ -6306,7 +6372,7 @@ function cmdWm(hippoRoot, args, flags) {
6306
6372
  console.log(`Flushed ${count} working memory entries.`);
6307
6373
  return;
6308
6374
  }
6309
- console.error('Usage: hippo wm <push|read|clear|flush>');
6375
+ printError('Usage: hippo wm <push|read|clear|flush>');
6310
6376
  process.exit(1);
6311
6377
  }
6312
6378
  function cmdDag(hippoRoot, flags) {
@@ -6423,7 +6489,7 @@ function cmdDrillDown(hippoRoot, summaryId, flags) {
6423
6489
  let depth;
6424
6490
  if (rawDepth !== undefined) {
6425
6491
  if (!Number.isInteger(rawDepth) || rawDepth < 1 || rawDepth > 10) {
6426
- console.error(`--depth must be an integer between 1 and 10 (got ${flags['depth']})`);
6492
+ printError(`--depth must be an integer between 1 and 10 (got ${flags['depth']})`);
6427
6493
  process.exit(2);
6428
6494
  }
6429
6495
  depth = rawDepth;
@@ -6444,10 +6510,10 @@ function cmdDrillDown(hippoRoot, summaryId, flags) {
6444
6510
  // intentionally collapses cross-tenant + scope-blocked + missing
6445
6511
  // (codex round 3 P1: distinguishing scope_blocked leaked existence).
6446
6512
  if (r.failure === 'not_drillable') {
6447
- console.error(`Id ${summaryId} is a leaf row, not a level-2+ summary; nothing to drill into.`);
6513
+ printError(`Id ${summaryId} is a leaf row, not a level-2+ summary; nothing to drill into.`);
6448
6514
  }
6449
6515
  else {
6450
- console.error(`No drillable summary at id=${summaryId}.`);
6516
+ printError(`No drillable summary at id=${summaryId}.`);
6451
6517
  }
6452
6518
  process.exit(1);
6453
6519
  }
@@ -6477,7 +6543,7 @@ function cmdAuthCreate(hippoRoot, flags) {
6477
6543
  let role = 'admin';
6478
6544
  if (roleFlag !== undefined) {
6479
6545
  if (roleFlag !== 'admin' && roleFlag !== 'member') {
6480
- console.error(`Invalid --role value: '${roleFlag}'. Use 'admin' or 'member'.`);
6546
+ printError(`Invalid --role value: '${roleFlag}'. Use 'admin' or 'member'.`);
6481
6547
  process.exit(1);
6482
6548
  }
6483
6549
  role = roleFlag;
@@ -6556,7 +6622,7 @@ function cmdAuthRevoke(hippoRoot, keyId, flags) {
6556
6622
  closeHippoDb(db);
6557
6623
  }
6558
6624
  if (keyTenant === undefined) {
6559
- console.error(`Unknown key_id: ${keyId}`);
6625
+ printError(`Unknown key_id: ${keyId}`);
6560
6626
  process.exit(1);
6561
6627
  }
6562
6628
  const ctx = { hippoRoot: root, tenantId: keyTenant, actor: api.adminActor('cli') };
@@ -6565,7 +6631,7 @@ function cmdAuthRevoke(hippoRoot, keyId, flags) {
6565
6631
  revokedAt = api.authRevoke(ctx, keyId).revokedAt;
6566
6632
  }
6567
6633
  catch (err) {
6568
- console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
6634
+ printError(`Error: ${err instanceof Error ? err.message : String(err)}`);
6569
6635
  process.exit(1);
6570
6636
  }
6571
6637
  if (flags['json']) {
@@ -6589,7 +6655,7 @@ function cmdAuthScopeGrant(hippoRoot, keyId, scope, grant, flags) {
6589
6655
  closeHippoDb(db);
6590
6656
  }
6591
6657
  if (keyTenant === undefined) {
6592
- console.error(`Unknown key_id: ${keyId}`);
6658
+ printError(`Unknown key_id: ${keyId}`);
6593
6659
  process.exit(1);
6594
6660
  }
6595
6661
  const ctx = { hippoRoot: root, tenantId: keyTenant, actor: api.adminActor('cli') };
@@ -6600,7 +6666,7 @@ function cmdAuthScopeGrant(hippoRoot, keyId, scope, grant, flags) {
6600
6666
  api.authUngrant(ctx, keyId, scope);
6601
6667
  }
6602
6668
  catch (err) {
6603
- console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
6669
+ printError(`Error: ${err instanceof Error ? err.message : String(err)}`);
6604
6670
  process.exit(1);
6605
6671
  }
6606
6672
  if (flags['json']) {
@@ -6627,13 +6693,13 @@ function cmdAuditList(hippoRoot, flags) {
6627
6693
  // Regenerate from Set to prevent future drift (v1.11.5: pre-v1.11.5 message
6628
6694
  // was hand-maintained and had drifted — missed 'auth_revoke' and 'outcome').
6629
6695
  const expected = Array.from(VALID_AUDIT_OPS).join(' | ');
6630
- console.error(`Unknown --op value: ${opFlag}. Expected one of: ${expected}.`);
6696
+ printError(`Unknown --op value: ${opFlag}. Expected one of: ${expected}.`);
6631
6697
  process.exit(1);
6632
6698
  }
6633
6699
  const op = opFlag;
6634
6700
  const since = typeof flags['since'] === 'string' ? flags['since'] : undefined;
6635
6701
  if (since !== undefined && !Number.isFinite(new Date(since).getTime())) {
6636
- console.error(`Invalid --since: ${since} (expected an ISO timestamp like 2026-04-22 or 2026-04-22T12:00:00Z).`);
6702
+ printError(`Invalid --since: ${since} (expected an ISO timestamp like 2026-04-22 or 2026-04-22T12:00:00Z).`);
6637
6703
  process.exit(1);
6638
6704
  }
6639
6705
  const limitRaw = flags['limit'];
@@ -6641,13 +6707,13 @@ function cmdAuditList(hippoRoot, flags) {
6641
6707
  if (limitRaw !== undefined && typeof limitRaw !== 'boolean') {
6642
6708
  const parsed = parseInt(String(limitRaw), 10);
6643
6709
  if (!Number.isFinite(parsed)) {
6644
- console.error(`Invalid --limit value: ${String(limitRaw)} (expected a positive integer).`);
6710
+ printError(`Invalid --limit value: ${String(limitRaw)} (expected a positive integer).`);
6645
6711
  process.exit(1);
6646
6712
  }
6647
6713
  limit = parsed;
6648
6714
  }
6649
6715
  if (limit < 1 || limit > 10000) {
6650
- console.error(`--limit must be between 1 and 10000 (got ${limit}).`);
6716
+ printError(`--limit must be between 1 and 10000 (got ${limit}).`);
6651
6717
  process.exit(1);
6652
6718
  }
6653
6719
  const ctx = { hippoRoot: root, tenantId, actor: { subject: 'cli', role: 'admin' } };
@@ -6675,7 +6741,7 @@ function printAuditPruneUsage() {
6675
6741
  function cmdAuditPrune(hippoRoot, flags) {
6676
6742
  const olderThanRaw = typeof flags['older-than'] === 'string' ? flags['older-than'] : '';
6677
6743
  if (!olderThanRaw) {
6678
- console.error('Usage: hippo audit prune --older-than <Nd> [--dry-run] [--tenant <t>]');
6744
+ printError('Usage: hippo audit prune --older-than <Nd> [--dry-run] [--tenant <t>]');
6679
6745
  process.exit(1);
6680
6746
  }
6681
6747
  let olderThanDays;
@@ -6683,7 +6749,7 @@ function cmdAuditPrune(hippoRoot, flags) {
6683
6749
  olderThanDays = parseOlderThanFlag(olderThanRaw);
6684
6750
  }
6685
6751
  catch (e) {
6686
- console.error(e.message);
6752
+ printError(e.message);
6687
6753
  process.exit(1);
6688
6754
  }
6689
6755
  const tenantId = typeof flags['tenant'] === 'string'
@@ -6719,7 +6785,7 @@ function cmdAuditLog(hippoRoot, args, flags) {
6719
6785
  cmdAuditPrune(hippoRoot, flags);
6720
6786
  return;
6721
6787
  }
6722
- console.error(`Unknown audit subcommand: ${sub}. Expected: list | prune.`);
6788
+ printError(`Unknown audit subcommand: ${sub}. Expected: list | prune.`);
6723
6789
  process.exit(1);
6724
6790
  }
6725
6791
  // ---------------------------------------------------------------------------
@@ -6740,7 +6806,7 @@ function resolveGoalSession(flags) {
6740
6806
  ? String(flags['session-id'])
6741
6807
  : process.env.HIPPO_SESSION_ID ?? '').trim();
6742
6808
  if (!sessionId) {
6743
- console.error('session id required (set HIPPO_SESSION_ID or pass --session-id)');
6809
+ printError('session id required (set HIPPO_SESSION_ID or pass --session-id)');
6744
6810
  process.exit(1);
6745
6811
  }
6746
6812
  const tenantId = (flags['tenant-id'] !== undefined
@@ -6751,51 +6817,51 @@ function resolveGoalSession(flags) {
6751
6817
  function cmdGoalPush(hippoRoot, args, flags) {
6752
6818
  const rawName = args.join(' ').trim();
6753
6819
  if (!rawName) {
6754
- console.error('Usage: hippo goal push <name> [--policy <type>] [--success "<condition>"] [--level N] [--parent <goalId>]');
6820
+ printError('Usage: hippo goal push <name> [--policy <type>] [--success "<condition>"] [--level N] [--parent <goalId>]');
6755
6821
  process.exit(1);
6756
6822
  }
6757
6823
  // Sanitize at WRITE time so corrupt names never enter the DB.
6758
6824
  const name = sanitizeGoalName(rawName);
6759
6825
  if (name !== rawName) {
6760
- console.error('note: stripped control characters from goal name');
6826
+ printError('note: stripped control characters from goal name');
6761
6827
  }
6762
6828
  const { sessionId, tenantId } = resolveGoalSession(flags);
6763
6829
  let policy;
6764
6830
  const policyRaw = flags['policy'];
6765
6831
  if (policyRaw === true) {
6766
- console.error('--policy requires a value (e.g., --policy error-prioritized)');
6832
+ printError('--policy requires a value (e.g., --policy error-prioritized)');
6767
6833
  process.exit(1);
6768
6834
  }
6769
6835
  if (typeof policyRaw === 'string') {
6770
6836
  if (!GOAL_POLICY_TYPES.includes(policyRaw)) {
6771
- console.error(`Unknown --policy '${policyRaw}'. Expected one of: ${GOAL_POLICY_TYPES.join(' | ')}.`);
6837
+ printError(`Unknown --policy '${policyRaw}'. Expected one of: ${GOAL_POLICY_TYPES.join(' | ')}.`);
6772
6838
  process.exit(1);
6773
6839
  }
6774
6840
  policy = { policyType: policyRaw };
6775
6841
  }
6776
6842
  const successRaw = flags['success'];
6777
6843
  if (successRaw === true) {
6778
- console.error('--success requires a value (e.g., --success "<condition>")');
6844
+ printError('--success requires a value (e.g., --success "<condition>")');
6779
6845
  process.exit(1);
6780
6846
  }
6781
6847
  const successCondition = typeof successRaw === 'string' ? successRaw : undefined;
6782
6848
  const levelRaw = flags['level'];
6783
6849
  let level;
6784
6850
  if (levelRaw === true) {
6785
- console.error('--level requires a value (e.g., --level 1)');
6851
+ printError('--level requires a value (e.g., --level 1)');
6786
6852
  process.exit(1);
6787
6853
  }
6788
6854
  if (levelRaw !== undefined) {
6789
6855
  const parsed = Number(levelRaw);
6790
6856
  if (!Number.isFinite(parsed) || parsed < 0 || parsed > 2 || !Number.isInteger(parsed)) {
6791
- console.error('--level must be an integer in [0, 2]');
6857
+ printError('--level must be an integer in [0, 2]');
6792
6858
  process.exit(1);
6793
6859
  }
6794
6860
  level = parsed;
6795
6861
  }
6796
6862
  const parentRaw = flags['parent'];
6797
6863
  if (parentRaw === true) {
6798
- console.error('--parent requires a value (e.g., --parent <goalId>)');
6864
+ printError('--parent requires a value (e.g., --parent <goalId>)');
6799
6865
  process.exit(1);
6800
6866
  }
6801
6867
  const parentGoalId = typeof parentRaw === 'string' ? parentRaw : undefined;
@@ -6861,19 +6927,19 @@ function cmdGoalList(hippoRoot, flags) {
6861
6927
  function cmdGoalComplete(hippoRoot, args, flags) {
6862
6928
  const id = args[0];
6863
6929
  if (!id) {
6864
- console.error('Usage: hippo goal complete <id> [--outcome <0..1>] [--no-propagate]');
6930
+ printError('Usage: hippo goal complete <id> [--outcome <0..1>] [--no-propagate]');
6865
6931
  process.exit(1);
6866
6932
  }
6867
6933
  let outcomeScore;
6868
6934
  const outcomeRaw = flags['outcome'];
6869
6935
  if (outcomeRaw === true) {
6870
- console.error('--outcome requires a value (e.g., --outcome 0.9)');
6936
+ printError('--outcome requires a value (e.g., --outcome 0.9)');
6871
6937
  process.exit(1);
6872
6938
  }
6873
6939
  if (outcomeRaw !== undefined) {
6874
6940
  const parsed = Number(outcomeRaw);
6875
6941
  if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
6876
- console.error('--outcome must be a number in [0, 1]');
6942
+ printError('--outcome must be a number in [0, 1]');
6877
6943
  process.exit(1);
6878
6944
  }
6879
6945
  outcomeScore = parsed;
@@ -6885,7 +6951,7 @@ function cmdGoalComplete(hippoRoot, args, flags) {
6885
6951
  function cmdGoalSuspend(hippoRoot, args) {
6886
6952
  const id = args[0];
6887
6953
  if (!id) {
6888
- console.error('Usage: hippo goal suspend <id>');
6954
+ printError('Usage: hippo goal suspend <id>');
6889
6955
  process.exit(1);
6890
6956
  }
6891
6957
  suspendGoal(hippoRoot, id);
@@ -6894,7 +6960,7 @@ function cmdGoalSuspend(hippoRoot, args) {
6894
6960
  function cmdGoalResume(hippoRoot, args) {
6895
6961
  const id = args[0];
6896
6962
  if (!id) {
6897
- console.error('Usage: hippo goal resume <id>');
6963
+ printError('Usage: hippo goal resume <id>');
6898
6964
  process.exit(1);
6899
6965
  }
6900
6966
  resumeGoal(hippoRoot, id);
@@ -6903,7 +6969,7 @@ function cmdGoalResume(hippoRoot, args) {
6903
6969
  function cmdGoal(hippoRoot, args, flags) {
6904
6970
  const sub = args[0];
6905
6971
  if (!sub) {
6906
- console.error('Usage: hippo goal <push|list|complete|suspend|resume> [args]');
6972
+ printError('Usage: hippo goal <push|list|complete|suspend|resume> [args]');
6907
6973
  process.exit(1);
6908
6974
  }
6909
6975
  const subArgs = args.slice(1);
@@ -6924,14 +6990,14 @@ function cmdGoal(hippoRoot, args, flags) {
6924
6990
  cmdGoalResume(hippoRoot, subArgs);
6925
6991
  return;
6926
6992
  default:
6927
- console.error(`Unknown goal subcommand: ${sub}. Expected: push | list | complete | suspend | resume.`);
6993
+ printError(`Unknown goal subcommand: ${sub}. Expected: push | list | complete | suspend | resume.`);
6928
6994
  process.exit(1);
6929
6995
  }
6930
6996
  }
6931
6997
  function cmdAuth(hippoRoot, args, flags) {
6932
6998
  const sub = args[0];
6933
6999
  if (!sub) {
6934
- console.error('Usage: hippo auth <create|list|revoke|grant|ungrant> [options]');
7000
+ printError('Usage: hippo auth <create|list|revoke|grant|ungrant> [options]');
6935
7001
  process.exit(1);
6936
7002
  }
6937
7003
  const subArgs = args.slice(1);
@@ -6945,7 +7011,7 @@ function cmdAuth(hippoRoot, args, flags) {
6945
7011
  case 'revoke': {
6946
7012
  const keyId = subArgs[0];
6947
7013
  if (!keyId) {
6948
- console.error('Usage: hippo auth revoke <key_id>');
7014
+ printError('Usage: hippo auth revoke <key_id>');
6949
7015
  process.exit(1);
6950
7016
  }
6951
7017
  cmdAuthRevoke(hippoRoot, keyId, flags);
@@ -6955,14 +7021,14 @@ function cmdAuth(hippoRoot, args, flags) {
6955
7021
  case 'ungrant': {
6956
7022
  const [keyId, scope] = subArgs;
6957
7023
  if (!keyId || !scope) {
6958
- console.error(`Usage: hippo auth ${sub} <key_id> <scope>`);
7024
+ printError(`Usage: hippo auth ${sub} <key_id> <scope>`);
6959
7025
  process.exit(1);
6960
7026
  }
6961
7027
  cmdAuthScopeGrant(hippoRoot, keyId, scope, sub === 'grant', flags);
6962
7028
  return;
6963
7029
  }
6964
7030
  default:
6965
- console.error(`Unknown auth subcommand: ${sub}. Expected: create | list | revoke | grant | ungrant.`);
7031
+ printError(`Unknown auth subcommand: ${sub}. Expected: create | list | revoke | grant | ungrant.`);
6966
7032
  process.exit(1);
6967
7033
  }
6968
7034
  }
@@ -6983,7 +7049,7 @@ function cmdSlackBackfill(hippoRoot, flags) {
6983
7049
  // Real fetcher requires SLACK_BOT_TOKEN with channels:history scope.
6984
7050
  const token = process.env.SLACK_BOT_TOKEN;
6985
7051
  if (!token) {
6986
- console.error('SLACK_BOT_TOKEN is not set. Backfill requires a Slack bot token with channels:history scope.');
7052
+ printError('SLACK_BOT_TOKEN is not set. Backfill requires a Slack bot token with channels:history scope.');
6987
7053
  process.exit(2);
6988
7054
  }
6989
7055
  // --since is advisory in V1: the slack_cursors row drives resume, so the
@@ -7006,7 +7072,7 @@ function cmdSlackBackfill(hippoRoot, flags) {
7006
7072
  console.log(`backfill ${channel}: ${r.ingested} new messages across ${r.pages} pages`);
7007
7073
  })
7008
7074
  .catch((e) => {
7009
- console.error('backfill failed:', e.message);
7075
+ printError('backfill failed:', e.message);
7010
7076
  process.exit(3);
7011
7077
  });
7012
7078
  }
@@ -7026,12 +7092,12 @@ function cmdSlackDlqList(hippoRoot, _flags) {
7026
7092
  function cmdSlackDlqReplay(hippoRoot, args, flags) {
7027
7093
  const idArg = args[2];
7028
7094
  if (!idArg) {
7029
- console.error('Usage: hippo slack dlq replay <id> [--force]');
7095
+ printError('Usage: hippo slack dlq replay <id> [--force]');
7030
7096
  process.exit(1);
7031
7097
  }
7032
7098
  const id = Number(idArg);
7033
7099
  if (!Number.isFinite(id) || !Number.isInteger(id) || id < 1) {
7034
- console.error(`replay: invalid id ${idArg}`);
7100
+ printError(`replay: invalid id ${idArg}`);
7035
7101
  process.exit(1);
7036
7102
  }
7037
7103
  const force = flags.force === true;
@@ -7040,7 +7106,7 @@ function cmdSlackDlqReplay(hippoRoot, args, flags) {
7040
7106
  signingSecret: process.env.SLACK_SIGNING_SECRET,
7041
7107
  });
7042
7108
  if (!result.ok) {
7043
- console.error(`replay failed: status=${result.status} retry_count=${result.retryCount}${result.reason ? ` reason=${result.reason}` : ''}`);
7109
+ printError(`replay failed: status=${result.status} retry_count=${result.retryCount}${result.reason ? ` reason=${result.reason}` : ''}`);
7044
7110
  process.exit(1);
7045
7111
  }
7046
7112
  console.log(`replay ok: status=${result.status} memory_id=${result.memoryId ?? '(none)'} retry_count=${result.retryCount}`);
@@ -7055,7 +7121,7 @@ function cmdSlackWorkspacesAdd(hippoRoot, flags) {
7055
7121
  const teamId = typeof flags['team'] === 'string' ? flags['team'].trim() : '';
7056
7122
  const tenantId = typeof flags['tenant'] === 'string' ? flags['tenant'].trim() : '';
7057
7123
  if (!teamId || !tenantId) {
7058
- console.error('Usage: hippo slack workspaces add --team <T> --tenant <t>');
7124
+ printError('Usage: hippo slack workspaces add --team <T> --tenant <t>');
7059
7125
  process.exit(1);
7060
7126
  }
7061
7127
  const db = openHippoDb(hippoRoot);
@@ -7086,14 +7152,14 @@ function cmdSlackWorkspacesList(hippoRoot) {
7086
7152
  function cmdSlackWorkspacesRemove(hippoRoot, flags) {
7087
7153
  const teamId = typeof flags['team'] === 'string' ? flags['team'].trim() : '';
7088
7154
  if (!teamId) {
7089
- console.error('Usage: hippo slack workspaces remove --team <T>');
7155
+ printError('Usage: hippo slack workspaces remove --team <T>');
7090
7156
  process.exit(1);
7091
7157
  }
7092
7158
  const db = openHippoDb(hippoRoot);
7093
7159
  try {
7094
7160
  const removed = removeSlackWorkspace(db, teamId);
7095
7161
  if (!removed) {
7096
- console.error(`no workspace registered for team ${teamId}`);
7162
+ printError(`no workspace registered for team ${teamId}`);
7097
7163
  process.exit(1);
7098
7164
  }
7099
7165
  console.log(`removed: ${teamId}`);
@@ -7133,7 +7199,7 @@ function cmdSlack(hippoRoot, args, flags) {
7133
7199
  printSlackWorkspacesUsage();
7134
7200
  process.exit(1);
7135
7201
  }
7136
- console.error('Usage: hippo slack <backfill|dlq list|dlq replay <id> [--force]|workspaces add|workspaces list|workspaces remove> [...]');
7202
+ printError('Usage: hippo slack <backfill|dlq list|dlq replay <id> [--force]|workspaces add|workspaces list|workspaces remove> [...]');
7137
7203
  process.exit(1);
7138
7204
  }
7139
7205
  export function usageText() {
@@ -7207,17 +7273,21 @@ Commands:
7207
7273
  where cost_factor = min(0.3, tokens / 10000). Re-sorts
7208
7274
  results by utility. Default off. RESEARCH.md §PFC.OFC.
7209
7275
  --reranker <name> Apply a reranker pass after retrieval
7210
- (cross-encoder|jev|llm). Looks up the named
7211
- reranker from src/rerankers/index.ts and re-orders
7212
- the top-K candidates. Default unset (no reranker).
7276
+ (cross-encoder|jev|clef-flash|clef|llm). Looks up
7277
+ the named reranker from src/rerankers/index.ts and
7278
+ re-orders the top-K candidates. Default unset (no reranker).
7213
7279
  jev calls the hosted TypeSafe Jev API: it needs
7214
7280
  TYPESAFE_API_KEY, sends the query and candidate
7215
7281
  text to that API, costs about 0.0004 USD a recall,
7216
7282
  and falls back to cross-encoder on any failure.
7283
+ clef-flash and clef send the same request to
7284
+ Cloudflare Workers AI (CLOUDFLARE_ACCOUNT_ID and
7285
+ CLOUDFLARE_API_TOKEN) or to HIPPO_CLEF_ENDPOINT, and
7286
+ keep the native order on any failure.
7217
7287
  See docs/evals/2026-09-19-jev-reranker.md and
7218
7288
  docs/plans/2026-05-10-f6-reranker-hardening.md.
7219
7289
  --reranker-top-k <n> Cap candidates passed to the reranker (default 50;
7220
- 40 for jev).
7290
+ 40 for jev, clef-flash and clef).
7221
7291
  --goal <tag> dlPFC goal-conditioned recall: memories tagged with
7222
7292
  the goal tag get a 1.5x score boost and results are
7223
7293
  re-sorted. Default off. RESEARCH.md §PFC.dlPFC.
@@ -7356,7 +7426,16 @@ Commands:
7356
7426
  --global Operate on the global store
7357
7427
  dormant restore <id> Bring a dormant memory back to active memory
7358
7428
  dormant forget <id> Delete a dormant memory permanently
7359
- quarantine [list] List memories a connector flagged as an instruction attempt, pending review
7429
+ projects [list] List the project names in a store, with a hint for old worktree names
7430
+ --json Output as JSON
7431
+ --global Operate on the global store
7432
+ projects merge <from> <into> [--apply]
7433
+ Fold one project name into another (dry run unless --apply;
7434
+ writes a backup and one audit event first)
7435
+ projects repair [--apply]
7436
+ Re-tag merged rows older versions of sleep saved as user-global,
7437
+ by their parents' project (dry run unless --apply)
7438
+ quarantine [list] List memories a connector flagged as an instruction attempt, pending review
7360
7439
  --all Include approved and rejected rows too (default: pending only)
7361
7440
  --json Output as JSON
7362
7441
  --global Operate on the global store
@@ -7828,7 +7907,7 @@ async function main(command, args, flags, hippoRoot) {
7828
7907
  * --hops value-less guard, so every current and future command - including the
7829
7908
  * thin-client dispatch relays - sees --scope only as a non-empty string. */
7830
7909
  if ('scope' in flags && (typeof flags['scope'] !== 'string' || !flags['scope'].trim())) {
7831
- console.error('--scope requires a non-empty value (e.g. --scope slack:private:C1).');
7910
+ printError('--scope requires a non-empty value (e.g. --scope slack:private:C1).');
7832
7911
  process.exit(1);
7833
7912
  }
7834
7913
  // parseArgs stores a value-less flag as boolean true, and NaN then survives every
@@ -7842,7 +7921,7 @@ async function main(command, args, flags, hippoRoot) {
7842
7921
  if (raw === undefined)
7843
7922
  continue;
7844
7923
  if (typeof raw !== 'string' || !raw.trim() || !Number.isFinite(Number(raw))) {
7845
- console.error(`--${key} requires a numeric value.`);
7924
+ printError(`--${key} requires a numeric value.`);
7846
7925
  process.exit(1);
7847
7926
  }
7848
7927
  }
@@ -7850,7 +7929,7 @@ async function main(command, args, flags, hippoRoot) {
7850
7929
  // so no single coercion of an inline value would be correct for every one of them.
7851
7930
  for (const key of BOOLEAN_FLAGS) {
7852
7931
  if (Object.hasOwn(flags, key) && typeof flags[key] !== 'boolean') {
7853
- console.error(`--${key} takes no value`);
7932
+ printError(`--${key} takes no value`);
7854
7933
  process.exit(1);
7855
7934
  }
7856
7935
  }
@@ -7859,14 +7938,14 @@ async function main(command, args, flags, hippoRoot) {
7859
7938
  if (unknownFlags.length > 0) {
7860
7939
  const names = unknownFlags.map((key) => `--${key}`).join(', ');
7861
7940
  if (DESTRUCTIVE_COMMANDS.has(command)) {
7862
- console.error(`Unknown flag ${names} for hippo ${command}. Nothing was changed.`);
7941
+ printError(`Unknown flag ${names} for hippo ${command}. Nothing was changed.`);
7863
7942
  process.exit(2);
7864
7943
  }
7865
- console.error(`hippo: ignoring unknown flag ${names}. A later release will reject it.`);
7944
+ printError(`hippo: ignoring unknown flag ${names}. A later release will reject it.`);
7866
7945
  }
7867
7946
  const refusal = Object.hasOwn(flags, 'dry-run') ? dryRunRefusal(command, args, flags) : null;
7868
7947
  if (refusal) {
7869
- console.error(refusal);
7948
+ printError(refusal);
7870
7949
  process.exit(2);
7871
7950
  }
7872
7951
  switch (command) {
@@ -7882,7 +7961,7 @@ async function main(command, args, flags, hippoRoot) {
7882
7961
  text = args.join(' ').trim();
7883
7962
  }
7884
7963
  if (!text || text.length < 3) {
7885
- console.error('Memory content too short (minimum 3 characters).');
7964
+ printError('Memory content too short (minimum 3 characters).');
7886
7965
  process.exit(1);
7887
7966
  }
7888
7967
  // Thin-client routing. When a server is up, simple `remember` calls go
@@ -7906,11 +7985,11 @@ async function main(command, args, flags, hippoRoot) {
7906
7985
  const thinOwnerRaw = typeof flags['owner'] === 'string' ? flags['owner'] : undefined;
7907
7986
  const thinOwnerCheck = validateOwner(thinOwnerRaw, { strict: isStrictOwnerEnv() });
7908
7987
  if (!thinOwnerCheck.ok) {
7909
- console.error(thinOwnerCheck.message);
7988
+ printError(thinOwnerCheck.message);
7910
7989
  process.exit(1);
7911
7990
  }
7912
7991
  if (thinOwnerCheck.message)
7913
- console.error(thinOwnerCheck.message);
7992
+ printError(thinOwnerCheck.message);
7914
7993
  const remembered = await runViaServerIfAvailable(hippoRoot, async (info, apiKey) => {
7915
7994
  const result = await client.remember(info.url, apiKey, {
7916
7995
  content: text,
@@ -7922,6 +8001,8 @@ async function main(command, args, flags, hippoRoot) {
7922
8001
  });
7923
8002
  console.log(`Remembered [${result.id}] (via ${info.url})`);
7924
8003
  console.log(` Kind: ${result.kind} | Tenant: ${result.tenantId}`);
8004
+ for (const w of result.warnings ?? [])
8005
+ printError(`Warning: ${w}`);
7925
8006
  });
7926
8007
  if (remembered)
7927
8008
  break;
@@ -7933,7 +8014,7 @@ async function main(command, args, flags, hippoRoot) {
7933
8014
  case 'recall': {
7934
8015
  const query = args.join(' ').trim();
7935
8016
  if (!query) {
7936
- console.error('Please provide a search query.');
8017
+ printError('Please provide a search query.');
7937
8018
  process.exit(1);
7938
8019
  }
7939
8020
  await cmdRecall(hookStoreRoot(hippoRoot), query, flags);
@@ -7942,7 +8023,7 @@ async function main(command, args, flags, hippoRoot) {
7942
8023
  case 'drill': {
7943
8024
  const summaryId = args[0];
7944
8025
  if (!summaryId) {
7945
- console.error('Usage: hippo drill <summary-id> [--limit N] [--budget N]');
8026
+ printError('Usage: hippo drill <summary-id> [--limit N] [--budget N]');
7946
8027
  process.exit(1);
7947
8028
  }
7948
8029
  cmdDrillDown(hippoRoot, summaryId, flags);
@@ -7951,7 +8032,7 @@ async function main(command, args, flags, hippoRoot) {
7951
8032
  case 'assemble': {
7952
8033
  const sessionId = typeof flags['session'] === 'string' ? flags['session'] : args[0];
7953
8034
  if (!sessionId) {
7954
- console.error('Usage: hippo assemble --session <id> [--budget N] [--fresh-tail N] [--no-summarize-older] [--json]');
8035
+ printError('Usage: hippo assemble --session <id> [--budget N] [--fresh-tail N] [--no-summarize-older] [--json]');
7955
8036
  process.exit(1);
7956
8037
  }
7957
8038
  cmdAssemble(hippoRoot, sessionId, flags);
@@ -7961,7 +8042,7 @@ async function main(command, args, flags, hippoRoot) {
7961
8042
  const oldId = args[0];
7962
8043
  const newContent = args.slice(1).join(' ').trim();
7963
8044
  if (!oldId || !newContent) {
7964
- console.error('Usage: hippo supersede <old-id> "<new content>" [--layer L] [--tag T] [--pin]');
8045
+ printError('Usage: hippo supersede <old-id> "<new content>" [--layer L] [--tag T] [--pin]');
7965
8046
  process.exit(1);
7966
8047
  }
7967
8048
  cmdSupersede(hippoRoot, oldId, newContent, flags);
@@ -7970,7 +8051,7 @@ async function main(command, args, flags, hippoRoot) {
7970
8051
  case 'explain': {
7971
8052
  const query = args.join(' ').trim();
7972
8053
  if (!query) {
7973
- console.error('Please provide a search query.');
8054
+ printError('Please provide a search query.');
7974
8055
  process.exit(1);
7975
8056
  }
7976
8057
  await cmdExplain(hippoRoot, query, flags);
@@ -7988,7 +8069,7 @@ async function main(command, args, flags, hippoRoot) {
7988
8069
  break;
7989
8070
  }
7990
8071
  if (!sub) {
7991
- console.error('Usage: hippo trace <memory-id> | hippo trace record --task <t> --steps <json> --outcome <o>');
8072
+ printError('Usage: hippo trace <memory-id> | hippo trace record --task <t> --steps <json> --outcome <o>');
7992
8073
  process.exit(1);
7993
8074
  }
7994
8075
  cmdTrace(hippoRoot, sub, flags);
@@ -8012,11 +8093,13 @@ async function main(command, args, flags, hippoRoot) {
8012
8093
  case 'pre-compact': {
8013
8094
  // Bounded wait, not a TTY guard: an idle non-TTY pipe must not hang.
8014
8095
  const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
8015
- resetHookInjection(hippoRoot, stdinText, null);
8016
- await cmdPreCompact(hookStoreRoot(hippoRoot), {
8017
- stdinText,
8018
- stdinTimedOut,
8019
- logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
8096
+ await runHookWithStores(async () => {
8097
+ resetHookInjection(hippoRoot, stdinText, null);
8098
+ await cmdPreCompact(hookStoreRoot(hippoRoot), {
8099
+ stdinText,
8100
+ stdinTimedOut,
8101
+ logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
8102
+ });
8020
8103
  });
8021
8104
  break;
8022
8105
  }
@@ -8025,7 +8108,7 @@ async function main(command, args, flags, hippoRoot) {
8025
8108
  const { text } = await readStdinBounded();
8026
8109
  const logFlag = flags['log-file'];
8027
8110
  const store = hookStoreRoot(hippoRoot);
8028
- const line = cmdPostCompact(store, {
8111
+ const line = await runHookWithStores(() => cmdPostCompact(store, {
8029
8112
  stdinText: text,
8030
8113
  logFile: logFlag === true || logFlag === false || Array.isArray(logFlag) ? undefined : logFlag,
8031
8114
  // Passed in, since capture.ts importing the sync would close an import cycle.
@@ -8037,8 +8120,8 @@ async function main(command, args, flags, hippoRoot) {
8037
8120
  for (const warning of report.warnings)
8038
8121
  log(`agent memories: ${warning}`);
8039
8122
  },
8040
- });
8041
- if (line !== null)
8123
+ }));
8124
+ if (line !== null && line !== undefined)
8042
8125
  console.log(line);
8043
8126
  break;
8044
8127
  }
@@ -8051,7 +8134,8 @@ async function main(command, args, flags, hippoRoot) {
8051
8134
  const payload = (text ?? '').trim();
8052
8135
  if (isInitialized(root) && payload) {
8053
8136
  // SAFETY: JSON.parse returns a JSON value by definition.
8054
- captureToolFailure(root, resolveTenantId({}), JSON.parse(payload));
8137
+ const failure = JSON.parse(payload);
8138
+ await runHookWithStores(() => captureToolFailure(root, resolveTenantId({}), failure));
8055
8139
  }
8056
8140
  }
8057
8141
  catch {
@@ -8061,8 +8145,10 @@ async function main(command, args, flags, hippoRoot) {
8061
8145
  }
8062
8146
  case 'compact-resume': {
8063
8147
  const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
8064
- resetHookInjection(hippoRoot, stdinText, 'compact');
8065
- cmdCompactResume(hookStoreRoot(hippoRoot), stdinText, stdinTimedOut);
8148
+ await runHookWithStores(() => {
8149
+ resetHookInjection(hippoRoot, stdinText, 'compact');
8150
+ cmdCompactResume(hookStoreRoot(hippoRoot), stdinText, stdinTimedOut);
8151
+ });
8066
8152
  break;
8067
8153
  }
8068
8154
  case 'codex-run':
@@ -8212,6 +8298,9 @@ async function main(command, args, flags, hippoRoot) {
8212
8298
  case 'dormant':
8213
8299
  cmdDormant(hippoRoot, args, flags);
8214
8300
  break;
8301
+ case 'projects':
8302
+ (await import('./cli/projects.js')).cmdProjects(hippoRoot, args, flags);
8303
+ break;
8215
8304
  case 'quarantine':
8216
8305
  cmdQuarantine(hippoRoot, args, flags);
8217
8306
  break;
@@ -8233,7 +8322,7 @@ async function main(command, args, flags, hippoRoot) {
8233
8322
  case 'support-bundle': {
8234
8323
  const outFlag = cardStringFlag(flags, 'out');
8235
8324
  if (outFlag === '') {
8236
- console.error('--out requires a file path.');
8325
+ printError('--out requires a file path.');
8237
8326
  process.exit(1);
8238
8327
  }
8239
8328
  const includeLogs = flags['include-logs'] === true;
@@ -8248,10 +8337,10 @@ async function main(command, args, flags, hippoRoot) {
8248
8337
  }
8249
8338
  catch (err) {
8250
8339
  if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
8251
- console.error(`${file} already exists; pass --out to choose another file. Nothing was written.`);
8340
+ printError(`${file} already exists; pass --out to choose another file. Nothing was written.`);
8252
8341
  }
8253
8342
  else {
8254
- console.error(err instanceof Error ? err.message : String(err));
8343
+ printError(err instanceof Error ? err.message : String(err));
8255
8344
  }
8256
8345
  process.exit(1);
8257
8346
  }
@@ -8283,7 +8372,7 @@ async function main(command, args, flags, hippoRoot) {
8283
8372
  case 'forget': {
8284
8373
  const id = args[0];
8285
8374
  if (!id) {
8286
- console.error('Please provide a memory ID.');
8375
+ printError('Please provide a memory ID.');
8287
8376
  process.exit(1);
8288
8377
  }
8289
8378
  // Archive has its own HTTP route (POST /v1/memories/:id/archive); route
@@ -8291,7 +8380,7 @@ async function main(command, args, flags, hippoRoot) {
8291
8380
  const archive = flags['archive'] === true;
8292
8381
  const reason = typeof flags['reason'] === 'string' ? flags['reason'] : null;
8293
8382
  if (archive && !reason) {
8294
- console.error(ARCHIVE_REASON_REQUIRED);
8383
+ printError(ARCHIVE_REASON_REQUIRED);
8295
8384
  process.exit(1);
8296
8385
  }
8297
8386
  if (flags['dry-run'] === true) {
@@ -8315,7 +8404,7 @@ async function main(command, args, flags, hippoRoot) {
8315
8404
  if (client.classifyTransportFailure(err) !== 'none')
8316
8405
  throw err;
8317
8406
  const msg = err instanceof Error ? err.message : String(err);
8318
- console.error(archive ? `Could not archive ${id}: ${msg}` : msg);
8407
+ printError(archive ? `Could not archive ${id}: ${msg}` : msg);
8319
8408
  process.exit(1);
8320
8409
  }
8321
8410
  });
@@ -8327,7 +8416,7 @@ async function main(command, args, flags, hippoRoot) {
8327
8416
  case 'inspect': {
8328
8417
  const id = args[0];
8329
8418
  if (!id) {
8330
- console.error('Please provide a memory ID.');
8419
+ printError('Please provide a memory ID.');
8331
8420
  process.exit(1);
8332
8421
  }
8333
8422
  cmdInspect(hippoRoot, id);
@@ -8337,7 +8426,7 @@ async function main(command, args, flags, hippoRoot) {
8337
8426
  // Bounded, not a TTY guard (DF1 T2, docs/plans/2026-08-23-df1-snapshot-lifecycle.md):
8338
8427
  // the hot stdin path and a manual run share this one command.
8339
8428
  const { text: stdinText } = await readStdinBounded();
8340
- await cmdContext(hookStoreRoot(hippoRoot), args, flags, stdinText);
8429
+ await runHookWithStores(() => cmdContext(hookStoreRoot(hippoRoot), args, flags, stdinText));
8341
8430
  break;
8342
8431
  }
8343
8432
  case 'hook':
@@ -8363,7 +8452,7 @@ async function main(command, args, flags, hippoRoot) {
8363
8452
  case 'promote': {
8364
8453
  const id = args[0];
8365
8454
  if (!id) {
8366
- console.error('Please provide a memory ID.');
8455
+ printError('Please provide a memory ID.');
8367
8456
  process.exit(1);
8368
8457
  }
8369
8458
  const promoted = await runViaServerIfAvailable(hippoRoot, async (info, apiKey) => {
@@ -8372,7 +8461,7 @@ async function main(command, args, flags, hippoRoot) {
8372
8461
  console.log(`Promoted ${id} to global store as ${result.globalId}`);
8373
8462
  }
8374
8463
  catch (err) {
8375
- console.error(`Failed to promote: ${err.message}`);
8464
+ printError(`Failed to promote: ${err.message}`);
8376
8465
  process.exit(1);
8377
8466
  }
8378
8467
  });
@@ -8426,13 +8515,13 @@ async function main(command, args, flags, hippoRoot) {
8426
8515
  console.log('Use --force to share anyway.');
8427
8516
  }
8428
8517
  else {
8429
- console.error(`Memory not found: ${shareId}`);
8518
+ printError(`Memory not found: ${shareId}`);
8430
8519
  process.exit(1);
8431
8520
  }
8432
8521
  }
8433
8522
  }
8434
8523
  else {
8435
- console.error('Usage: hippo share <memory_id> [--force] or hippo share --auto [--dry-run]');
8524
+ printError('Usage: hippo share <memory_id> [--force] or hippo share --auto [--dry-run]');
8436
8525
  process.exit(1);
8437
8526
  }
8438
8527
  break;
@@ -8510,7 +8599,7 @@ async function main(command, args, flags, hippoRoot) {
8510
8599
  captureSource = 'last-session';
8511
8600
  }
8512
8601
  if (!captureSource) {
8513
- console.error('Usage: hippo capture --stdin|--file <path>|--last-session [--transcript <path>] [--log-file <path>] [--dry-run] [--global]');
8602
+ printError('Usage: hippo capture --stdin|--file <path>|--last-session [--transcript <path>] [--log-file <path>] [--dry-run] [--global]');
8514
8603
  process.exit(1);
8515
8604
  }
8516
8605
  // Bounded, and only when last-session has no explicit path: the
@@ -8558,7 +8647,7 @@ async function main(command, args, flags, hippoRoot) {
8558
8647
  const portRaw = flags['port'] ?? process.env['HIPPO_PORT'] ?? '6789';
8559
8648
  const port = Number(portRaw);
8560
8649
  if (!Number.isFinite(port) || port < 0) {
8561
- console.error(`Invalid --port: ${String(portRaw)}`);
8650
+ printError(`Invalid --port: ${String(portRaw)}`);
8562
8651
  process.exit(1);
8563
8652
  }
8564
8653
  const host = typeof flags['host'] === 'string' ? flags['host'] : '127.0.0.1';
@@ -8575,19 +8664,19 @@ async function main(command, args, flags, hippoRoot) {
8575
8664
  requireInit(hippoRoot);
8576
8665
  if (flags['churn'] === true) {
8577
8666
  if (args[0] || flags['id'] !== undefined) {
8578
- console.error('Usage: hippo invalidate --churn [--dry-run]');
8579
- console.error('--churn takes no pattern or --id.');
8667
+ printError('Usage: hippo invalidate --churn [--dry-run]');
8668
+ printError('--churn takes no pattern or --id.');
8580
8669
  process.exit(1);
8581
8670
  }
8582
8671
  if (!isGitRepo(process.cwd())) {
8583
- console.error('hippo invalidate --churn must run inside a git repository.');
8672
+ printError('hippo invalidate --churn must run inside a git repository.');
8584
8673
  process.exit(1);
8585
8674
  }
8586
8675
  const churnDryRun = flags['dry-run'] === true;
8587
8676
  let churnFailed = false;
8588
8677
  for (const { root, result } of runChurnStaleForRepo(hippoRoot, churnDryRun)) {
8589
8678
  if (result.error) {
8590
- console.error(`Churn-staleness check failed for ${root}: ${result.error}`);
8679
+ printError(`Churn-staleness check failed for ${root}: ${result.error}`);
8591
8680
  churnFailed = true;
8592
8681
  continue;
8593
8682
  }
@@ -8613,21 +8702,21 @@ async function main(command, args, flags, hippoRoot) {
8613
8702
  if (flags['id'] === true) {
8614
8703
  // Value-less --id must never silently fall through to pattern mode
8615
8704
  // (pattern mode writes broadly; an ignored --id reverses user intent).
8616
- console.error('--id requires a memory id');
8705
+ printError('--id requires a memory id');
8617
8706
  process.exit(1);
8618
8707
  }
8619
8708
  const onlyId = typeof flags['id'] === 'string' ? flags['id'] : undefined;
8620
8709
  if (typeof flags['dry-run'] === 'string') {
8621
8710
  // Dead: the earlier global BOOLEAN_FLAGS guard now exits first on any --dry-run=<v>.
8622
8711
  // Kept as defence in depth on a destructive command.
8623
- console.error('--dry-run takes no value');
8712
+ printError('--dry-run takes no value');
8624
8713
  process.exit(1);
8625
8714
  }
8626
8715
  const dryRun = flags['dry-run'] === true;
8627
8716
  if ((target && onlyId) || (!target && !onlyId)) {
8628
- console.error('Usage: hippo invalidate "<old pattern>" [--dry-run] [--reason "<why>"]');
8629
- console.error(' hippo invalidate --id <memory-id> [--dry-run] [--reason "<why>"]');
8630
- console.error('Pass a pattern OR --id, not both. Tag matching is EXACT: the full pattern must equal a tag.');
8717
+ printError('Usage: hippo invalidate "<old pattern>" [--dry-run] [--reason "<why>"]');
8718
+ printError(' hippo invalidate --id <memory-id> [--dry-run] [--reason "<why>"]');
8719
+ printError('Pass a pattern OR --id, not both. Tag matching is EXACT: the full pattern must equal a tag.');
8631
8720
  process.exit(1);
8632
8721
  }
8633
8722
  const reason = flags['reason'] || null;
@@ -8686,7 +8775,7 @@ async function main(command, args, flags, hippoRoot) {
8686
8775
  cmdGraph(hippoRoot, args, flags);
8687
8776
  break;
8688
8777
  default:
8689
- console.error(`Unknown command: ${command}`);
8778
+ printError(`Unknown command: ${command}`);
8690
8779
  printUsage();
8691
8780
  process.exit(1);
8692
8781
  }
@@ -8697,7 +8786,7 @@ export async function runCli(argv = process.argv) {
8697
8786
  await main(command, args, flags, getHippoRoot(process.cwd()));
8698
8787
  }
8699
8788
  catch (err) {
8700
- console.error('Error:', err instanceof Error ? err.message : err);
8789
+ printError('Error:', err instanceof Error ? err.message : err);
8701
8790
  process.exit(1);
8702
8791
  }
8703
8792
  }