hippo-memory 1.38.10 → 1.40.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 (61) hide show
  1. package/README.md +8 -0
  2. package/dist/api.d.ts +3 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +36 -28
  5. package/dist/api.js.map +1 -1
  6. package/dist/card.d.ts +45 -0
  7. package/dist/card.d.ts.map +1 -0
  8. package/dist/card.js +16 -0
  9. package/dist/card.js.map +1 -0
  10. package/dist/cli.d.ts +1 -0
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +451 -36
  13. package/dist/cli.js.map +1 -1
  14. package/dist/connectors/slack/backfill.js +1 -1
  15. package/dist/db.d.ts.map +1 -1
  16. package/dist/db.js +168 -1
  17. package/dist/db.js.map +1 -1
  18. package/dist/handoff.d.ts +22 -0
  19. package/dist/handoff.d.ts.map +1 -1
  20. package/dist/handoff.js +34 -0
  21. package/dist/handoff.js.map +1 -1
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/mcp/server.d.ts.map +1 -1
  27. package/dist/mcp/server.js +19 -17
  28. package/dist/mcp/server.js.map +1 -1
  29. package/dist/src/api.js +36 -28
  30. package/dist/src/api.js.map +1 -1
  31. package/dist/src/card.js +16 -0
  32. package/dist/src/card.js.map +1 -0
  33. package/dist/src/cli.js +451 -36
  34. package/dist/src/cli.js.map +1 -1
  35. package/dist/src/connectors/slack/backfill.js +1 -1
  36. package/dist/src/db.js +168 -1
  37. package/dist/src/db.js.map +1 -1
  38. package/dist/src/handoff.js +34 -0
  39. package/dist/src/handoff.js.map +1 -1
  40. package/dist/src/index.js +5 -1
  41. package/dist/src/index.js.map +1 -1
  42. package/dist/src/mcp/server.js +19 -17
  43. package/dist/src/mcp/server.js.map +1 -1
  44. package/dist/src/store.js +533 -35
  45. package/dist/src/store.js.map +1 -1
  46. package/dist/src/version.js +1 -1
  47. package/dist/src/version.js.map +1 -1
  48. package/dist/store.d.ts +59 -5
  49. package/dist/store.d.ts.map +1 -1
  50. package/dist/store.js +533 -35
  51. package/dist/store.js.map +1 -1
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.d.ts.map +1 -1
  54. package/dist/version.js +1 -1
  55. package/dist/version.js.map +1 -1
  56. package/dist-ui/index.html +12 -12
  57. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  58. package/extensions/openclaw-plugin/package.json +1 -1
  59. package/openclaw.plugin.json +1 -1
  60. package/package.json +1 -1
  61. package/dist/benchmarks/e1.3/scenarios.json +0 -2587
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * hippo snapshot <save|show|clear>
14
14
  * hippo session <log|show|latest|resume|complete>
15
15
  * hippo handoff <create|latest|show>
16
+ * hippo card <create|show|list|claim|block|review|complete|comment>
16
17
  * hippo current <show>
17
18
  * hippo forget <id> [--archive --reason "<why>"]
18
19
  * hippo reject <id>|--value "<text>" --reason "<why>"
@@ -37,9 +38,12 @@ import { execFileSync, execSync, spawn } from 'child_process';
37
38
  import { installJsonHooks, uninstallJsonHooks, resolveJsonHookPaths, detectInstalledTools, defaultSleepLogPath, ensureCodexWrapperInstalled, installCodexWrapper, isCodexWrapperInstalled, repairCodexWrapperIfInstalled, uninstallCodexWrapper, resolveCodexSessionTranscript, resolveCodexWrapperPaths, installOpencodePlugin, uninstallOpencodePlugin, resolveOpencodePluginPath, } from './hooks.js';
38
39
  import { createMemory, calculateStrength, calculateRewardFactor, deriveHalfLife, resolveConfidence, confidenceFacets, confidenceLabel, computeSchemaFit, Layer, } from './memory.js';
39
40
  import { detectSecret } from './secret-detect.js';
40
- import { getHippoRoot, isInitialized, initStore, writeEntry, readEntry, deleteEntry, loadAllEntries, loadSearchEntries, loadRecallSearchEntries, loadIndex, saveIndex, loadStats, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, closeTaskSnapshotsForSession, clearActiveTaskSnapshot, appendSessionEvent, listSessionEvents, listMemoryConflicts, resolveConflict, saveSessionHandoff, loadLatestHandoff, loadHandoffById, RECALL_DEFAULT_DENY_SCOPES, } from './store.js';
41
+ import { getHippoRoot, isInitialized, initStore, writeEntry, readEntry, deleteEntry, loadAllEntries, loadSearchEntries, loadRecallSearchEntries, loadIndex, saveIndex, loadStats, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, closeTaskSnapshotsForSession, clearActiveTaskSnapshot, appendSessionEvent, listSessionEvents, listMemoryConflicts, resolveConflict, saveSessionHandoff, loadLatestHandoff, loadHandoffById, stampHandoffOutcome, writeSessionEndHandoff, createCard, loadCard, listCards, loadCardDeps, loadCardRuns, loadCardComments, claimCard, blockCard, reviewCard, completeCard, addCardComment, loadLatestHandoffForCard, } from './store.js';
41
42
  import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
42
43
  import { RejectedValueError } from './rejection.js';
44
+ import { isHandoffOutcome, formatHandoffEvidenceLine } from './handoff.js';
45
+ import { isCardStatus } from './card.js';
46
+ import { passesScopeFilterForRecall } from './recall-scope.js';
43
47
  import { markRetrieved, hybridSearch, physicsSearch, explainMatch, textOverlap, tokenize as tokenizeQuery } from './search.js';
44
48
  import { compareEntryIdentity } from './compare.js';
45
49
  import { renderTraceContent, parseSteps } from './trace.js';
@@ -251,8 +255,8 @@ export function parseArgs(argv) {
251
255
  i++;
252
256
  }
253
257
  else {
254
- // Check if it's a repeatable flag (tag, artifact, link, step)
255
- if (key === 'tag' || key === 'artifact' || key === 'link' || key === 'step') {
258
+ // Check if it's a repeatable flag (tag, artifact, link, step, constraint, depends-on)
259
+ if (key === 'tag' || key === 'artifact' || key === 'link' || key === 'step' || key === 'constraint' || key === 'depends-on') {
256
260
  if (Array.isArray(flags[key])) {
257
261
  flags[key].push(next);
258
262
  }
@@ -1544,37 +1548,16 @@ async function cmdRecall(hippoRoot, query, flags) {
1544
1548
  const rawEvents = sessionId
1545
1549
  ? listSessionEvents(hippoRoot, tenantId, { session_id: sessionId, limit: 5 })
1546
1550
  : [];
1547
- // Mirror the api.recall default-deny rule for forward compat: when v1.2.0
1548
- // continuity writers start setting scope, a no-scope caller must not see
1549
- // private-channel-derived rows. Today scope is NULL on all continuity
1550
- // rows so this is a no-op. Caller-supplied --scope bypasses the gate
1551
- // (matches existing memory recall behavior).
1552
- // Scope filter mirrors api.recall: exact match when scope is set, default-deny
1553
- // on ANY `<source>:private:*` and 'unknown:legacy' otherwise. recallActiveScope
1554
- // merges --scope flag and detectScope() so auto-detected source contexts get
1555
- // matched. v1.2.1: generalized from slack-only to source-agnostic via
1556
- // api.isPrivateScope so v1.3 GitHub (and future sources) cannot leak.
1557
- const effectiveScope = recallActiveScope ?? '';
1558
- const passesScopeFilter = (s) => {
1559
- if (effectiveScope) {
1560
- return s === effectiveScope;
1561
- }
1562
- if (s === null)
1563
- return true;
1564
- if (api.isPrivateScope(s))
1565
- return false;
1566
- // v1.7.2: read from RECALL_DEFAULT_DENY_SCOPES (single source of truth
1567
- // shared with SQL + api.passesScopeFilterForRecall).
1568
- if (RECALL_DEFAULT_DENY_SCOPES.includes(s))
1569
- return false;
1570
- return true;
1571
- };
1551
+ // W1: was its own copy of passesScopeFilterForRecall (cloned 3x);
1552
+ // calls the shared helper directly now (recallActiveScope merges
1553
+ // --scope and detectScope()).
1554
+ const effectiveScope = recallActiveScope || undefined;
1572
1555
  const rowScope = (r) => r?.scope ?? null;
1573
1556
  activeSnapshot =
1574
- rawSnapshot && passesScopeFilter(rowScope(rawSnapshot)) ? rawSnapshot : null;
1557
+ rawSnapshot && passesScopeFilterForRecall(rowScope(rawSnapshot), effectiveScope) ? rawSnapshot : null;
1575
1558
  sessionHandoff =
1576
- rawHandoff && passesScopeFilter(rowScope(rawHandoff)) ? rawHandoff : null;
1577
- recentSessionEvents = rawEvents.filter((e) => passesScopeFilter(rowScope(e)));
1559
+ rawHandoff && passesScopeFilterForRecall(rowScope(rawHandoff), effectiveScope) ? rawHandoff : null;
1560
+ recentSessionEvents = rawEvents.filter((e) => passesScopeFilterForRecall(rowScope(e), effectiveScope));
1578
1561
  const tokenize = (s) => s ? Math.ceil(s.length / 4) : 0;
1579
1562
  continuityTokens =
1580
1563
  tokenize(activeSnapshot?.task) +
@@ -1583,6 +1566,11 @@ async function cmdRecall(hippoRoot, query, flags) {
1583
1566
  tokenize(sessionHandoff?.summary) +
1584
1567
  tokenize(sessionHandoff?.nextAction) +
1585
1568
  (sessionHandoff?.artifacts ?? []).reduce((acc, a) => acc + tokenize(a), 0) +
1569
+ (sessionHandoff?.constraints ?? []).reduce((acc, c) => acc + tokenize(c), 0) +
1570
+ tokenize(sessionHandoff?.evidence ? formatHandoffEvidenceLine(sessionHandoff.evidence) : null) +
1571
+ tokenize(sessionHandoff?.outcome) +
1572
+ tokenize(sessionHandoff?.targetRuntime) +
1573
+ tokenize(sessionHandoff?.cardId) +
1586
1574
  recentSessionEvents.reduce((acc, e) => acc + tokenize(e.content), 0);
1587
1575
  }
1588
1576
  const hasContinuity = activeSnapshot !== null
@@ -2936,6 +2924,30 @@ async function cmdSessionEnd(hippoRoot, flags) {
2936
2924
  * `__session-end-worker` subcommand (not user-facing). Failures in one stage
2937
2925
  * do not block the other.
2938
2926
  */
2927
+ // Best-effort git state; a missing git, non-repo cwd, or the timeout all
2928
+ // yield null fields rather than throw (autolearn.ts execFileSync shape).
2929
+ function collectHandoffEvidence(cwd, testStatus) {
2930
+ let gitRef = null;
2931
+ try {
2932
+ gitRef = execFileSync('git', ['rev-parse', 'HEAD'], {
2933
+ cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
2934
+ }).trim() || null;
2935
+ }
2936
+ catch {
2937
+ gitRef = null;
2938
+ }
2939
+ let dirtyTree = null;
2940
+ try {
2941
+ const status = execFileSync('git', ['status', '--porcelain'], {
2942
+ cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
2943
+ });
2944
+ dirtyTree = status.trim().length > 0;
2945
+ }
2946
+ catch {
2947
+ dirtyTree = null;
2948
+ }
2949
+ return { gitRef, dirtyTree, testStatus };
2950
+ }
2939
2951
  async function cmdSessionEndWorker(hippoRoot, flags) {
2940
2952
  try {
2941
2953
  await cmdSleep(hippoRoot, flags);
@@ -2972,6 +2984,25 @@ async function cmdSessionEndWorker(hippoRoot, flags) {
2972
2984
  // in loadFreshActiveTaskSnapshot is the backstop layer, not this close.
2973
2985
  const closeLogFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
2974
2986
  const closeSessionId = typeof flags['session-id'] === 'string' ? flags['session-id'] : null;
2987
+ // Handoff write happens BEFORE the snapshot close below, while the
2988
+ // snapshot writeSessionEndHandoff reads is still active.
2989
+ if (closeSessionId) {
2990
+ try {
2991
+ const activeForHandoff = loadActiveTaskSnapshot(hippoRoot, resolveTenantId({}));
2992
+ if (!activeForHandoff || activeForHandoff.session_id !== closeSessionId) {
2993
+ appendSessionEndCloseLog(closeLogFile, 'skip: no active snapshot for session');
2994
+ }
2995
+ else {
2996
+ const evidence = collectHandoffEvidence(process.cwd(), 'unknown');
2997
+ const handoff = writeSessionEndHandoff(hippoRoot, resolveTenantId({}), closeSessionId, evidence);
2998
+ appendSessionEndCloseLog(closeLogFile, handoff ? `wrote handoff for session ${closeSessionId}` : 'skip: handoff newer than snapshot');
2999
+ }
3000
+ }
3001
+ catch (err) {
3002
+ // SAFETY: catch clauses bind unknown, but Node/V8 always throws an Error here.
3003
+ appendSessionEndCloseLog(closeLogFile, `handoff write failed: ${err.message}`);
3004
+ }
3005
+ }
2975
3006
  try {
2976
3007
  if (closeSessionId) {
2977
3008
  const closed = closeTaskSnapshotsForSession(hippoRoot, resolveTenantId({}), closeSessionId);
@@ -3753,17 +3784,17 @@ function cmdSession(hippoRoot, args, flags) {
3753
3784
  return;
3754
3785
  }
3755
3786
  if (subcommand === 'complete') {
3756
- const outcome = String(flags['outcome'] ?? '').trim();
3787
+ const outcomeRaw = String(flags['outcome'] ?? '').trim();
3757
3788
  const summary = String(flags['summary'] ?? '').trim();
3758
- const validOutcomes = ['success', 'failure', 'partial'];
3759
3789
  if (!sessionId) {
3760
3790
  console.error('Usage: hippo session complete --session <session-id> --outcome <success|failure|partial> [--summary "..."]');
3761
3791
  process.exit(1);
3762
3792
  }
3763
- if (!validOutcomes.includes(outcome)) {
3764
- console.error(`Invalid outcome: "${outcome}". Must be one of: ${validOutcomes.join(', ')}.`);
3793
+ if (!isHandoffOutcome(outcomeRaw)) {
3794
+ console.error(`Invalid outcome: "${outcomeRaw}". Must be one of: success, failure, partial.`);
3765
3795
  process.exit(1);
3766
3796
  }
3797
+ const outcome = outcomeRaw;
3767
3798
  const metadata = { ended_at: new Date().toISOString() };
3768
3799
  if (summary)
3769
3800
  metadata.summary = summary;
@@ -3776,6 +3807,10 @@ function cmdSession(hippoRoot, args, flags) {
3776
3807
  metadata,
3777
3808
  });
3778
3809
  console.log(`Completed session ${event.session_id} with outcome=${outcome} (event #${event.id})`);
3810
+ const stamped = stampHandoffOutcome(hippoRoot, resolveTenantId({}), sessionId, outcome);
3811
+ if (stamped > 0) {
3812
+ console.log(`Stamped outcome on handoff for session ${sessionId}`);
3813
+ }
3779
3814
  return;
3780
3815
  }
3781
3816
  if (subcommand === 'resume') {
@@ -3794,6 +3829,12 @@ function cmdSession(hippoRoot, args, flags) {
3794
3829
  lines.push(`- Task: ${handoff.taskId}`);
3795
3830
  if (handoff.repoRoot)
3796
3831
  lines.push(`- Repo: ${handoff.repoRoot}`);
3832
+ if (handoff.outcome)
3833
+ lines.push(`- Outcome: ${handoff.outcome}`);
3834
+ if (handoff.targetRuntime)
3835
+ lines.push(`- Target runtime: ${handoff.targetRuntime}`);
3836
+ if (handoff.cardId)
3837
+ lines.push(`- Card: ${handoff.cardId}`);
3797
3838
  lines.push('', '### Summary', handoff.summary);
3798
3839
  if (handoff.nextAction) {
3799
3840
  lines.push('', '### Next action', handoff.nextAction);
@@ -3804,6 +3845,15 @@ function cmdSession(hippoRoot, args, flags) {
3804
3845
  lines.push(`- ${artifact}`);
3805
3846
  }
3806
3847
  }
3848
+ if (handoff.constraints && handoff.constraints.length > 0) {
3849
+ lines.push('', '### Constraints');
3850
+ for (const constraint of handoff.constraints) {
3851
+ lines.push(`- ${constraint}`);
3852
+ }
3853
+ }
3854
+ if (handoff.evidence) {
3855
+ lines.push('', '### Evidence', formatHandoffEvidenceLine(handoff.evidence));
3856
+ }
3807
3857
  lines.push('');
3808
3858
  console.log(lines.join('\n'));
3809
3859
  return;
@@ -3819,6 +3869,12 @@ function printHandoff(handoff) {
3819
3869
  console.log(`- Task: ${handoff.taskId}`);
3820
3870
  if (handoff.repoRoot)
3821
3871
  console.log(`- Repo: ${handoff.repoRoot}`);
3872
+ if (handoff.outcome)
3873
+ console.log(`- Outcome: ${handoff.outcome}`);
3874
+ if (handoff.targetRuntime)
3875
+ console.log(`- Target runtime: ${handoff.targetRuntime}`);
3876
+ if (handoff.cardId)
3877
+ console.log(`- Card: ${handoff.cardId}`);
3822
3878
  console.log('');
3823
3879
  console.log('### Summary');
3824
3880
  console.log(handoff.summary);
@@ -3834,6 +3890,18 @@ function printHandoff(handoff) {
3834
3890
  console.log(`- ${artifact}`);
3835
3891
  }
3836
3892
  }
3893
+ if (handoff.constraints && handoff.constraints.length > 0) {
3894
+ console.log('');
3895
+ console.log('### Constraints');
3896
+ for (const constraint of handoff.constraints) {
3897
+ console.log(`- ${constraint}`);
3898
+ }
3899
+ }
3900
+ if (handoff.evidence) {
3901
+ console.log('');
3902
+ console.log('### Evidence');
3903
+ console.log(formatHandoffEvidenceLine(handoff.evidence));
3904
+ }
3837
3905
  console.log('');
3838
3906
  }
3839
3907
  function cmdHandoff(hippoRoot, args, flags) {
@@ -3842,7 +3910,12 @@ function cmdHandoff(hippoRoot, args, flags) {
3842
3910
  if (subcommand === 'create') {
3843
3911
  const summary = String(flags['summary'] ?? '').trim();
3844
3912
  if (!summary) {
3845
- console.error('Usage: hippo handoff create --summary "..." [--next "..."] [--session <id>] [--task <id>] [--artifact <path>...]');
3913
+ 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>]');
3914
+ process.exit(1);
3915
+ }
3916
+ const outcomeRaw = flags['outcome'];
3917
+ if (outcomeRaw !== undefined && !isHandoffOutcome(outcomeRaw)) {
3918
+ console.error(`Invalid outcome: "${String(outcomeRaw)}". Must be one of: success, failure, partial.`);
3846
3919
  process.exit(1);
3847
3920
  }
3848
3921
  const sessionId = String(flags['session'] ?? flags['id'] ?? '').trim() || `fallback-${Date.now()}-${process.pid}`;
@@ -3852,6 +3925,22 @@ function cmdHandoff(hippoRoot, args, flags) {
3852
3925
  const artifacts = Array.isArray(artifactFlag)
3853
3926
  ? artifactFlag
3854
3927
  : (typeof artifactFlag === 'string' ? [artifactFlag] : []);
3928
+ const constraintFlag = flags['constraint'];
3929
+ const isFlagString = (v) => typeof v === 'string';
3930
+ const constraints = Array.isArray(constraintFlag)
3931
+ ? constraintFlag
3932
+ : (isFlagString(constraintFlag) ? [constraintFlag] : []);
3933
+ for (const name of ['target-runtime', 'card-id']) {
3934
+ // parseArgs turns a value-less flag into `true`; refuse rather than store "true".
3935
+ if (flags[name] === true) {
3936
+ console.error(`--${name} needs a value`);
3937
+ process.exit(1);
3938
+ }
3939
+ }
3940
+ const targetRuntime = String(flags['target-runtime'] ?? '').trim() || undefined;
3941
+ const cardId = String(flags['card-id'] ?? '').trim() || undefined;
3942
+ const testStatus = String(flags['tests'] ?? '').trim();
3943
+ const evidence = collectHandoffEvidence(process.cwd(), testStatus === 'pass' || testStatus === 'fail' ? testStatus : 'unknown');
3855
3944
  const handoff = saveSessionHandoff(hippoRoot, resolveTenantId({}), {
3856
3945
  version: 1,
3857
3946
  sessionId,
@@ -3860,6 +3949,12 @@ function cmdHandoff(hippoRoot, args, flags) {
3860
3949
  summary,
3861
3950
  nextAction,
3862
3951
  artifacts,
3952
+ constraints,
3953
+ evidence,
3954
+ // SAFETY: isHandoffOutcome above already refused any non-matching value.
3955
+ outcome: outcomeRaw,
3956
+ targetRuntime,
3957
+ cardId,
3863
3958
  });
3864
3959
  console.log(`Created session handoff for session ${handoff.sessionId}`);
3865
3960
  console.log(` Summary: ${handoff.summary}`);
@@ -3868,6 +3963,17 @@ function cmdHandoff(hippoRoot, args, flags) {
3868
3963
  if (handoff.artifacts && handoff.artifacts.length > 0) {
3869
3964
  console.log(` Artifacts: ${handoff.artifacts.join(', ')}`);
3870
3965
  }
3966
+ if (handoff.constraints && handoff.constraints.length > 0) {
3967
+ console.log(` Constraints: ${handoff.constraints.join(', ')}`);
3968
+ }
3969
+ if (handoff.outcome)
3970
+ console.log(` Outcome: ${handoff.outcome}`);
3971
+ if (handoff.targetRuntime)
3972
+ console.log(` Target runtime: ${handoff.targetRuntime}`);
3973
+ if (handoff.cardId)
3974
+ console.log(` Card: ${handoff.cardId}`);
3975
+ if (handoff.evidence)
3976
+ console.log(` Evidence: ${formatHandoffEvidenceLine(handoff.evidence)}`);
3871
3977
  return;
3872
3978
  }
3873
3979
  if (subcommand === 'latest') {
@@ -3920,6 +4026,281 @@ function cmdHandoff(hippoRoot, args, flags) {
3920
4026
  console.error('Usage: hippo handoff <create|latest|show>');
3921
4027
  process.exit(1);
3922
4028
  }
4029
+ // Mirrors ARCHIVE_REASON_REQUIRED so the block message can't drift from its usage line.
4030
+ const CARD_BLOCK_REASON_REQUIRED = 'hippo card block <id> requires --reason "<why>" (recorded as a comment).';
4031
+ function printCard(hippoRoot, tenantId, card) {
4032
+ console.log(`## Card ${card.id}\n`);
4033
+ console.log(`- Title: ${card.title}`);
4034
+ console.log(`- Status: ${card.status}`);
4035
+ if (card.assigneeRuntime)
4036
+ console.log(`- Assignee: ${card.assigneeRuntime}`);
4037
+ if (card.repo)
4038
+ console.log(`- Repo: ${card.repo}`);
4039
+ if (card.contract)
4040
+ console.log(`- Contract: ${card.contract}`);
4041
+ if (card.budget !== null)
4042
+ console.log(`- Budget: ${card.budget}`);
4043
+ console.log(`- Updated: ${card.updatedAt}`);
4044
+ const deps = loadCardDeps(hippoRoot, tenantId, card.id);
4045
+ if (deps.parents.length > 0)
4046
+ console.log(`- Parents: ${deps.parents.join(', ')}`);
4047
+ if (deps.children.length > 0)
4048
+ console.log(`- Children: ${deps.children.join(', ')}`);
4049
+ const runs = loadCardRuns(hippoRoot, tenantId, card.id);
4050
+ if (runs.length > 0) {
4051
+ console.log('\n### Runs');
4052
+ for (const run of runs) {
4053
+ console.log(`- ${run.runtime} started ${run.started}${run.ended ? ` ended ${run.ended} (${run.outcome})` : ' (open)'}`);
4054
+ }
4055
+ }
4056
+ const comments = loadCardComments(hippoRoot, tenantId, card.id);
4057
+ if (comments.length > 0) {
4058
+ console.log('\n### Comments');
4059
+ for (const comment of comments) {
4060
+ console.log(`- [${comment.createdAt}] ${comment.author}: ${comment.body}`);
4061
+ }
4062
+ }
4063
+ const handoff = loadLatestHandoffForCard(hippoRoot, tenantId, card.id);
4064
+ if (handoff) {
4065
+ console.log('\n### Latest handoff');
4066
+ console.log(`- Session: ${handoff.sessionId}, updated ${handoff.updatedAt}`);
4067
+ console.log(handoff.summary);
4068
+ }
4069
+ console.log('');
4070
+ }
4071
+ // parseArgs turns a value-less flag into `true`; refuse rather than silently
4072
+ // stringifying it (String(true) === 'true'), mirroring cmdHandoff's guard.
4073
+ function cardStringFlag(flags, key) {
4074
+ const v = flags[key];
4075
+ if (v === undefined)
4076
+ return undefined;
4077
+ if (v === true || v === false || Array.isArray(v)) {
4078
+ console.error(`--${key} requires a value`);
4079
+ process.exit(1);
4080
+ }
4081
+ return v.trim();
4082
+ }
4083
+ const CARD_SUBCOMMAND_FLAGS = {
4084
+ create: ['title', 'repo', 'contract', 'budget', 'depends-on'],
4085
+ show: ['json'],
4086
+ list: ['status', 'json'],
4087
+ claim: ['runtime', 'session'],
4088
+ block: ['reason'],
4089
+ review: new Array(),
4090
+ complete: ['outcome'],
4091
+ comment: ['body', 'author'],
4092
+ };
4093
+ function cmdCard(hippoRoot, args, flags) {
4094
+ requireInit(hippoRoot);
4095
+ const tenantId = resolveTenantId({});
4096
+ const subcommand = args[0] ?? '';
4097
+ if (Object.hasOwn(CARD_SUBCOMMAND_FLAGS, subcommand)) {
4098
+ // SAFETY: hasOwn, unlike `in`, skips inherited keys such as constructor, so subcommand is a real key.
4099
+ const allowedFlags = CARD_SUBCOMMAND_FLAGS[subcommand];
4100
+ for (const key of Object.keys(flags)) {
4101
+ if (!allowedFlags.includes(key)) {
4102
+ // --flag=value never splits on '=' (see parseArgs), so it lands here as one long key.
4103
+ const hint = key.includes('=') ? ` Use --${key.slice(0, key.indexOf('='))} <value>, not --${key}.` : '';
4104
+ const valid = allowedFlags.length > 0 ? allowedFlags.map((f) => `--${f}`).join(', ') : '(none)';
4105
+ console.error(`Unknown flag --${key} for hippo card ${subcommand}.${hint} Valid flags: ${valid}`);
4106
+ process.exit(1);
4107
+ }
4108
+ }
4109
+ }
4110
+ if (subcommand === 'create') {
4111
+ const title = cardStringFlag(flags, 'title') ?? '';
4112
+ if (!title) {
4113
+ console.error('Usage: hippo card create --title "..." [--repo <name>] [--contract <text>] [--budget <n>] [--depends-on <id>...]');
4114
+ process.exit(1);
4115
+ }
4116
+ const repo = cardStringFlag(flags, 'repo') || undefined;
4117
+ const contract = cardStringFlag(flags, 'contract') || undefined;
4118
+ const budgetRaw = cardStringFlag(flags, 'budget');
4119
+ let budget;
4120
+ if (budgetRaw !== undefined) {
4121
+ if (!/^\d+$/.test(budgetRaw)) {
4122
+ console.error(`Invalid budget: "${budgetRaw}" (expected a positive integer)`);
4123
+ process.exit(1);
4124
+ }
4125
+ budget = Number(budgetRaw);
4126
+ }
4127
+ const dependsOnFlag = flags['depends-on'];
4128
+ if (dependsOnFlag === true) {
4129
+ console.error('--depends-on requires a value');
4130
+ process.exit(1);
4131
+ }
4132
+ const dependsOn = Array.isArray(dependsOnFlag) ? dependsOnFlag : [];
4133
+ let card;
4134
+ try {
4135
+ card = createCard(hippoRoot, tenantId, { title, repo, contract, budget, dependsOn });
4136
+ }
4137
+ catch (error) {
4138
+ console.error(error instanceof Error ? error.message : String(error));
4139
+ process.exit(1);
4140
+ }
4141
+ console.log(`Created card ${card.id} (status: ${card.status})`);
4142
+ return;
4143
+ }
4144
+ if (subcommand === 'show') {
4145
+ const id = args[1];
4146
+ if (!id) {
4147
+ console.error('Usage: hippo card show <id> [--json]');
4148
+ process.exit(1);
4149
+ }
4150
+ const card = loadCard(hippoRoot, tenantId, id);
4151
+ if (!card) {
4152
+ console.error(`No card found with id ${id}.`);
4153
+ process.exit(1);
4154
+ }
4155
+ if (flags['json']) {
4156
+ console.log(JSON.stringify({
4157
+ card,
4158
+ deps: loadCardDeps(hippoRoot, tenantId, id),
4159
+ runs: loadCardRuns(hippoRoot, tenantId, id),
4160
+ comments: loadCardComments(hippoRoot, tenantId, id),
4161
+ handoff: loadLatestHandoffForCard(hippoRoot, tenantId, id),
4162
+ }, null, 2));
4163
+ return;
4164
+ }
4165
+ printCard(hippoRoot, tenantId, card);
4166
+ return;
4167
+ }
4168
+ if (subcommand === 'list') {
4169
+ const status = cardStringFlag(flags, 'status');
4170
+ if (status !== undefined && !isCardStatus(status)) {
4171
+ console.error(`Invalid status: "${status}".`);
4172
+ process.exit(1);
4173
+ }
4174
+ const cards = listCards(hippoRoot, tenantId, { status });
4175
+ if (flags['json']) {
4176
+ console.log(JSON.stringify({ cards }, null, 2));
4177
+ return;
4178
+ }
4179
+ if (cards.length === 0) {
4180
+ console.log('No cards found.');
4181
+ return;
4182
+ }
4183
+ for (const card of cards) {
4184
+ console.log(`${card.id}\t${card.status}\t${card.title}${card.assigneeRuntime ? `\t(${card.assigneeRuntime})` : ''}`);
4185
+ }
4186
+ return;
4187
+ }
4188
+ if (subcommand === 'claim') {
4189
+ const id = args[1];
4190
+ const runtime = cardStringFlag(flags, 'runtime') ?? '';
4191
+ if (!id || !runtime) {
4192
+ console.error('Usage: hippo card claim <id> --runtime <name> [--session <id>]');
4193
+ process.exit(1);
4194
+ }
4195
+ const sessionId = cardStringFlag(flags, 'session') || undefined;
4196
+ let card;
4197
+ try {
4198
+ card = claimCard(hippoRoot, tenantId, id, runtime, sessionId);
4199
+ }
4200
+ catch (error) {
4201
+ console.error(error instanceof Error ? error.message : String(error));
4202
+ process.exit(1);
4203
+ }
4204
+ if (!card) {
4205
+ console.error(`Could not claim card ${id} (not ready/blocked, or already claimed).`);
4206
+ process.exit(1);
4207
+ }
4208
+ console.log(`Claimed card ${card.id} for ${runtime}`);
4209
+ return;
4210
+ }
4211
+ if (subcommand === 'block') {
4212
+ const id = args[1];
4213
+ const reason = cardStringFlag(flags, 'reason') ?? '';
4214
+ if (!id || !reason) {
4215
+ console.error(CARD_BLOCK_REASON_REQUIRED);
4216
+ process.exit(1);
4217
+ }
4218
+ let card;
4219
+ try {
4220
+ card = blockCard(hippoRoot, tenantId, id, reason);
4221
+ }
4222
+ catch (error) {
4223
+ console.error(error instanceof Error ? error.message : String(error));
4224
+ process.exit(1);
4225
+ }
4226
+ if (!card) {
4227
+ console.error(`Could not block card ${id} (not running).`);
4228
+ process.exit(1);
4229
+ }
4230
+ console.log(`Blocked card ${card.id}`);
4231
+ return;
4232
+ }
4233
+ if (subcommand === 'review') {
4234
+ const id = args[1];
4235
+ if (!id) {
4236
+ console.error('Usage: hippo card review <id>');
4237
+ process.exit(1);
4238
+ }
4239
+ let card;
4240
+ try {
4241
+ card = reviewCard(hippoRoot, tenantId, id);
4242
+ }
4243
+ catch (error) {
4244
+ console.error(error instanceof Error ? error.message : String(error));
4245
+ process.exit(1);
4246
+ }
4247
+ if (!card) {
4248
+ console.error(`Could not move card ${id} to review (not running).`);
4249
+ process.exit(1);
4250
+ }
4251
+ console.log(`Card ${card.id} moved to review`);
4252
+ return;
4253
+ }
4254
+ if (subcommand === 'complete') {
4255
+ const id = args[1];
4256
+ const outcomeRaw = flags['outcome'];
4257
+ if (!id || !isHandoffOutcome(outcomeRaw)) {
4258
+ console.error('Usage: hippo card complete <id> --outcome <success|failure|partial>');
4259
+ process.exit(1);
4260
+ }
4261
+ let result;
4262
+ try {
4263
+ result = completeCard(hippoRoot, tenantId, id, outcomeRaw);
4264
+ }
4265
+ catch (error) {
4266
+ console.error(error instanceof Error ? error.message : String(error));
4267
+ process.exit(1);
4268
+ }
4269
+ if (!result) {
4270
+ console.error(`Could not complete card ${id} (not in review).`);
4271
+ process.exit(1);
4272
+ }
4273
+ console.log(`Completed card ${result.card.id} (status: ${result.card.status})`);
4274
+ if (result.promotedChildren.length > 0) {
4275
+ console.log(`Promoted to ready: ${result.promotedChildren.join(', ')}`);
4276
+ }
4277
+ return;
4278
+ }
4279
+ if (subcommand === 'comment') {
4280
+ const id = args[1];
4281
+ if (!id) {
4282
+ console.error('Usage: hippo card comment <id> --body "..." [--author <name>]');
4283
+ process.exit(1);
4284
+ }
4285
+ // Only show and comment look the card up directly; claim/block/review/complete throw from the store instead.
4286
+ const card = loadCard(hippoRoot, tenantId, id);
4287
+ if (!card) {
4288
+ console.error(`No card found with id ${id}.`);
4289
+ process.exit(1);
4290
+ }
4291
+ const body = cardStringFlag(flags, 'body') ?? '';
4292
+ if (!body) {
4293
+ console.error('Usage: hippo card comment <id> --body "..." [--author <name>]');
4294
+ process.exit(1);
4295
+ }
4296
+ const author = cardStringFlag(flags, 'author') || 'cli';
4297
+ const comment = addCardComment(hippoRoot, tenantId, id, author, body);
4298
+ console.log(`Added comment ${comment.id} to card ${id}`);
4299
+ return;
4300
+ }
4301
+ console.error('Usage: hippo card <create|show|list|claim|block|review|complete|comment>');
4302
+ process.exit(1);
4303
+ }
3923
4304
  // ---------------------------------------------------------------------------
3924
4305
  // E2 prediction first-class object (v0.31)
3925
4306
  // docs/plans/2026-05-26-e2-prediction-object.md
@@ -7823,10 +8204,37 @@ Commands:
7823
8204
  --session <id> Session ID (auto-generated if omitted)
7824
8205
  --task <id> Associated task ID
7825
8206
  --artifact <path> Related file path (repeatable)
8207
+ --constraint <text> Constraint for the successor to respect (repeatable)
8208
+ --outcome <o> success | failure | partial
8209
+ --target-runtime <n> Name of the runtime the successor will run in
8210
+ --card-id <id> Associated card/ticket ID
8211
+ --tests <status> pass | fail | unknown (default: unknown)
7826
8212
  handoff latest Show the most recent handoff
7827
8213
  --session <id> Filter by session
7828
8214
  --json Output as JSON
7829
8215
  handoff show <id> Show a specific handoff by ID
8216
+ card <sub> Manage claimable work-queue cards
8217
+ card create Create a new card
8218
+ --title <text> Card title (required)
8219
+ --repo <name> Associated repo
8220
+ --contract <text> Associated contract
8221
+ --budget <n> Token/step budget
8222
+ --depends-on <id> Parent card id (repeatable)
8223
+ card show <id> Show a card, its deps, runs, comments and latest handoff
8224
+ --json Output as JSON
8225
+ card list List cards, newest-updated first
8226
+ --status <status> Filter by status
8227
+ card claim <id> Claim a ready or blocked card
8228
+ --runtime <name> Claiming runtime (required)
8229
+ --session <id> Session ID
8230
+ card block <id> Block a running card
8231
+ --reason "<why>" Reason recorded as a card comment (required)
8232
+ card review <id> Move a running card to review
8233
+ card complete <id> Complete a card in review
8234
+ --outcome <o> success | failure | partial (required)
8235
+ card comment <id> Add a comment to a card
8236
+ --body <text> Comment body (required)
8237
+ --author <name> Comment author (default: cli)
7830
8238
  current <sub> Show compact current state for agent injection
7831
8239
  current show Active task + recent session events (default)
7832
8240
  --json Output as JSON
@@ -8051,6 +8459,10 @@ Examples:
8051
8459
  hippo session resume
8052
8460
  hippo snapshot save --task "Ship feature" --summary "Tests are green" --next-step "Open the PR" --session sess_123
8053
8461
  hippo handoff create --summary "PR is open, tests green" --next "Merge after review" --session sess_123 --artifact src/foo.ts
8462
+ hippo handoff create --summary s --constraint a --constraint b --outcome partial --target-runtime codex --card-id c1 --tests pass
8463
+ hippo card create --title "Add cards table" --repo hippo --depends-on card_abc
8464
+ hippo card claim card_abc --runtime codex
8465
+ hippo card complete card_abc --outcome success
8054
8466
  hippo embed --status
8055
8467
  hippo watch "npm run build"
8056
8468
  hippo learn --git --days 30
@@ -8438,6 +8850,9 @@ async function main() {
8438
8850
  case 'handoff':
8439
8851
  cmdHandoff(hippoRoot, args, flags);
8440
8852
  break;
8853
+ case 'card':
8854
+ cmdCard(hippoRoot, args, flags);
8855
+ break;
8441
8856
  case 'predict':
8442
8857
  cmdPredict(hippoRoot, args, flags);
8443
8858
  break;