@davesheffer/hunch 1.38.0 → 1.39.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 (48) hide show
  1. package/dist/cli/index.js +255 -9
  2. package/dist/cli/serve.js +1 -0
  3. package/dist/client/readOrCompute.d.ts +77 -0
  4. package/dist/client/readOrCompute.js +85 -0
  5. package/dist/client/state.d.ts +1 -0
  6. package/dist/client/state.js +1 -0
  7. package/dist/constitution/g2.d.ts +1 -0
  8. package/dist/constitution/service.js +8 -0
  9. package/dist/constitution/sourceMutation.js +23 -18
  10. package/dist/core/config.d.ts +16 -0
  11. package/dist/core/config.js +13 -0
  12. package/dist/core/machine.d.ts +20 -0
  13. package/dist/core/machine.js +101 -0
  14. package/dist/core/taskRecord.js +6 -3
  15. package/dist/core/taskReport.d.ts +25 -2
  16. package/dist/core/taskReport.js +92 -18
  17. package/dist/core/taskReportEvidence.d.ts +4 -1
  18. package/dist/core/taskReportEvidence.js +5 -2
  19. package/dist/core/taskReportHook.d.ts +16 -3
  20. package/dist/core/taskReportHook.js +65 -13
  21. package/dist/core/taskTouched.d.ts +4 -0
  22. package/dist/core/taskTouched.js +30 -10
  23. package/dist/core/types.d.ts +66 -1
  24. package/dist/core/types.js +3 -0
  25. package/dist/core/workspace.d.ts +234 -0
  26. package/dist/core/workspace.js +335 -0
  27. package/dist/extractors/helm.d.ts +17 -28
  28. package/dist/extractors/helm.js +12 -12
  29. package/dist/extractors/indexer.js +171 -7
  30. package/dist/extractors/k8sManifest.d.ts +59 -0
  31. package/dist/extractors/k8sManifest.js +507 -0
  32. package/dist/extractors/workspaces.d.ts +18 -0
  33. package/dist/extractors/workspaces.js +350 -0
  34. package/dist/integrations/claudemd.js +1 -0
  35. package/dist/integrations/hooks.d.ts +2 -0
  36. package/dist/integrations/hooks.js +25 -0
  37. package/dist/integrations/scaffold.js +11 -0
  38. package/dist/integrations/workspaceLedger.d.ts +73 -0
  39. package/dist/integrations/workspaceLedger.js +201 -0
  40. package/dist/mcp/server.js +54 -0
  41. package/dist/mcp/taskReportTools.d.ts +9 -0
  42. package/dist/mcp/taskReportTools.js +13 -5
  43. package/dist/serve/app.d.ts +2 -0
  44. package/dist/serve/app.js +107 -92
  45. package/dist/serve/mcpHttp.d.ts +27 -0
  46. package/dist/serve/mcpHttp.js +95 -0
  47. package/package.json +1 -1
  48. package/server.json +2 -2
package/dist/cli/index.js CHANGED
@@ -52,7 +52,7 @@ import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
52
52
  import { extractInlineIntent } from "../extractors/comments.js";
53
53
  import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
54
54
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
55
- import { installPostCommitHook, installPreCommitHook, installPostMergeHook, hookStatus } from "../integrations/hooks.js";
55
+ import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus } from "../integrations/hooks.js";
56
56
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
57
57
  import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
58
58
  import { installMergeDriver } from "../integrations/mergeDriver.js";
@@ -78,7 +78,10 @@ import { deriveChangeProof } from "../core/changeProof.js";
78
78
  import { discoverProjectDna, evaluateProjectDnaMatch } from "../core/projectDna.js";
79
79
  import { diffProjectDna } from "../core/projectDnaDelta.js";
80
80
  import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
81
- import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
81
+ import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness, workspacesConfig } from "../core/config.js";
82
+ import { loadOrCreateMachine, setMachineLabel, machineFile, labelLeaksIdentity } from "../core/machine.js";
83
+ import { worktreeRows, branchRows } from "../core/workspace.js";
84
+ import { workspaceLedgerView, recordWorkspaceSnapshot, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, prunePlanFor, renderPrunePlan, applyPrune, confirmPrune } from "../integrations/workspaceLedger.js";
82
85
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
83
86
  import { isHumanConfirmed } from "../core/strictgate.js";
84
87
  import { appendEvent, readEvents } from "../core/events.js";
@@ -88,7 +91,7 @@ import { recordServed, servedSummary } from "../core/served.js";
88
91
  import { recordTaskDelivery, reportActivity, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
89
92
  import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
90
93
  import { renderRecalledLine } from "../core/taskReportRender.js";
91
- import { closeHookTask, hookReportTaskId, nativeHookCwd, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
94
+ import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
92
95
  import { persistTaskRecord } from "../core/taskRecord.js";
93
96
  import { recordHookObservation } from "../core/hookObservations.js";
94
97
  import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
@@ -393,6 +396,8 @@ program
393
396
  console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
394
397
  const pm = installPostMergeHook(root, inv.shell);
395
398
  console.log(` ✓ post-merge hook ${pm.action} (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)`);
399
+ const pc = installPostCheckoutHook(root, inv.shell);
400
+ console.log(` ✓ post-checkout hook ${pc.action} (workspace ledger: records this machine's branches + worktrees on checkout)`);
396
401
  const m = installMergeDriver(root, inv.shell);
397
402
  console.log(` ✓ team merge driver ${m.action}`);
398
403
  // Auto-install the pre-commit guard by default (advisory: flags invariants
@@ -481,8 +486,11 @@ program
481
486
  // re-running init by hand. Gated on already having post-commit: `index`
482
487
  // is not a setup command (it runs in CI, on any git repo), so it must
483
488
  // never be what FIRST hooks a repo that never ran init at all.
484
- if (isGitRepo(root) && hookStatus(root).postCommit)
485
- installPostMergeHook(root, resolveInvocation().shell);
489
+ if (isGitRepo(root) && hookStatus(root).postCommit) {
490
+ const inv = resolveInvocation().shell;
491
+ installPostMergeHook(root, inv);
492
+ installPostCheckoutHook(root, inv); // same upgrade path for the workspace-ledger hook
493
+ }
486
494
  const res = indexRepo(store, root, { requireClean: true });
487
495
  const { counts } = store.reindex();
488
496
  const correctionSweep = new ConstitutionService(store, root).upgradeCorrections();
@@ -707,7 +715,7 @@ program
707
715
  const sweep = constitution.g2ShadowSweep();
708
716
  g2Recorded = sweep.recorded.length;
709
717
  if (!opts.quiet) {
710
- console.log(` ↳ G2 shadow: ${sweep.recorded.length} recorded · ${sweep.existing.length} existing · ${sweep.failures.length} failed; authority none`);
718
+ console.log(` ↳ G2 shadow: ${sweep.recorded.length} recorded · ${sweep.existing.length} existing · ${sweep.failures.length} failed${sweep.retired.length ? ` · ${sweep.retired.length} retired (skipped)` : ""}; authority none`);
711
719
  }
712
720
  }
713
721
  }
@@ -1245,6 +1253,8 @@ function configureOverlay(dir, opts, mode) {
1245
1253
  hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
1246
1254
  const pm = installPostMergeHook(root, inv.shell);
1247
1255
  hookNote += ` ✓ post-merge hook ${pm.action} (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)\n`;
1256
+ const pc = installPostCheckoutHook(root, inv.shell);
1257
+ hookNote += ` ✓ post-checkout hook ${pc.action} (workspace ledger: this machine's branches + worktrees sync through the overlay)\n`;
1248
1258
  }
1249
1259
  // 5) one-time migration: MOVE existing public memory INTO the overlay, then make
1250
1260
  // THIS repo code-only. Records are absorbed (union by id) BEFORE the public
@@ -1409,11 +1419,220 @@ program
1409
1419
  openStore = null;
1410
1420
  }
1411
1421
  }
1422
+ // 4) the workspace ledger: the new worktree is exactly what other machines want to know about.
1423
+ let ledgerNote = "";
1424
+ try {
1425
+ const lstore = openTeamStore(root).store;
1426
+ try {
1427
+ const out = recordWorkspaceSnapshot(lstore, root);
1428
+ if (out.status === "written")
1429
+ ledgerNote = `\n ✓ workspace ledger updated (${out.record.worktrees.length} worktree(s) on this machine → ${out.home === "private" ? "overlay" : "public .hunch/"})`;
1430
+ }
1431
+ finally {
1432
+ lstore.close();
1433
+ openStore = null;
1434
+ }
1435
+ }
1436
+ catch { /* the ledger is a side effect; the worktree itself is what this command promised */ }
1412
1437
  console.log(`✓ worktree created → ${dest}${opts.branch ? ` (new branch ${opts.branch})` : ""}\n` +
1413
- `${shareNote}${indexNote}\n` +
1438
+ `${shareNote}${indexNote}${ledgerNote}\n` +
1414
1439
  ` hooks + MCP server are shared (worktree-aware) — open your assistant in the new worktree to start.\n` +
1415
1440
  ` (needs \`hunch\` installed globally; a worktree has no node_modules of its own)`);
1416
1441
  });
1442
+ // ---- workspaces / branches (workspace ledger — docs/workspace-ledger.md) ---------------
1443
+ // One shared code path (src/integrations/workspaceLedger.ts): this machine is always read
1444
+ // LIVE from git, other machines from the store, and stored records are display-only.
1445
+ const workspacesCmd = program
1446
+ .command("workspaces")
1447
+ .description("Workspace ledger: which worktrees are open on which machine (this machine live, other machines from memory). Read-only unless you run `snapshot`.");
1448
+ workspacesCmd
1449
+ .command("list", { isDefault: true })
1450
+ .description("Every worktree across machines: branch, dirty, last commit, when the machine last reported.")
1451
+ .option("--machine <label>", "only this machine")
1452
+ .option("--branch <name>", "only worktrees on this branch")
1453
+ .option("--fetch", "run `git fetch --prune` first (network; off by default)")
1454
+ .option("--json", "emit the rows as JSON")
1455
+ .action((opts) => {
1456
+ const { store, root } = storeFor();
1457
+ try {
1458
+ if (!isGitRepo(root))
1459
+ return fail("`hunch workspaces` needs a git repo");
1460
+ const view = workspaceLedgerView(store, root, { fetch: opts.fetch });
1461
+ let rows = worktreeRows(view.records, { staleAfterDays: view.config.stale_after_days });
1462
+ if (opts.machine)
1463
+ rows = rows.filter((r) => r.machine === opts.machine);
1464
+ if (opts.branch)
1465
+ rows = rows.filter((r) => r.branch === opts.branch);
1466
+ if (opts.json)
1467
+ return console.log(JSON.stringify({ machine: view.machine.label, worktrees: rows }, null, 2));
1468
+ console.log(renderWorktreeTable(view, rows));
1469
+ }
1470
+ finally {
1471
+ store.close();
1472
+ }
1473
+ });
1474
+ workspacesCmd
1475
+ .command("snapshot")
1476
+ .description("Record this machine's worktrees and branches into memory (the overlay when one is configured). Offline unless --fetch. Also run by the post-checkout / post-commit hooks and at MCP session start.")
1477
+ .option("--fetch", "run `git fetch --prune` first (network; off by default)")
1478
+ .option("--dry-run", "print the record that WOULD be written and write nothing")
1479
+ .option("--json", "print the record as JSON")
1480
+ .option("--quiet", "no output on success (for hooks)")
1481
+ .action((opts) => {
1482
+ const { store, root } = storeFor();
1483
+ try {
1484
+ if (!isGitRepo(root))
1485
+ return fail("`hunch workspaces snapshot` needs a git repo");
1486
+ const out = recordWorkspaceSnapshot(store, root, { fetch: opts.fetch, dryRun: opts.dryRun });
1487
+ if (out.status !== "off" && (opts.dryRun || opts.json))
1488
+ console.log(JSON.stringify(out.record, null, 2));
1489
+ if (opts.quiet || out.status === "dry-run")
1490
+ return;
1491
+ switch (out.status) {
1492
+ case "off":
1493
+ return console.log("workspaces.publish is \"off\" in .hunch/config.json — nothing recorded.");
1494
+ case "no-home":
1495
+ console.log("No memory overlay is configured, so this machine's record is not written (queries read this machine live).");
1496
+ console.log(" · run `hunch private` or `hunch shared --repo <url>` to sync workspaces across machines");
1497
+ return console.log(" · or set .hunch/config.json {\"workspaces\":{\"publish_public\":true}} to commit it into this repo's .hunch/");
1498
+ case "unchanged":
1499
+ return console.log(`✓ unchanged since ${out.previous.observed_at} (${out.record.id}) — nothing written`);
1500
+ case "collision":
1501
+ return fail(`not written: ${out.reason}\n · \`hunch workspaces forget ${out.record.id}\` removes the stale copy (a normal, revertable memory move), then snapshot again`);
1502
+ case "written":
1503
+ return console.log(`✓ recorded ${out.record.worktrees.length} worktree(s), ${out.record.branches.length} branch(es) as ${out.record.machine.label} (${out.record.id}, publish=${out.record.publish}) → ${out.home === "private" ? "overlay" : "public .hunch/"}${out.flushed ? `, ${out.flushed}` : ""}`);
1504
+ }
1505
+ }
1506
+ finally {
1507
+ store.close();
1508
+ }
1509
+ });
1510
+ workspacesCmd
1511
+ .command("prune")
1512
+ .description("Branches and worktrees that are provably merged and safe to delete. Prints the exact git commands per machine (dry run). --apply runs them on THIS machine only — never a remote, never another machine — from a live snapshot, with `git branch -d` / `git worktree remove` (no force flags), after confirmation.")
1513
+ .option("--apply", "execute this machine's commands (asks for confirmation; --yes in a non-interactive shell)")
1514
+ .option("--yes", "skip the confirmation prompt (required with --apply when stdin is not a terminal)")
1515
+ .option("--fetch", "run `git fetch --prune` first (network; off by default)")
1516
+ .option("--json", "emit the plan (and results) as JSON")
1517
+ .action(async (opts) => {
1518
+ const { store, root } = storeFor();
1519
+ try {
1520
+ if (!isGitRepo(root))
1521
+ return fail("`hunch workspaces prune` needs a git repo");
1522
+ const view = workspaceLedgerView(store, root, { fetch: opts.fetch });
1523
+ const plan = prunePlanFor(view);
1524
+ if (!opts.apply) {
1525
+ if (opts.json)
1526
+ return console.log(JSON.stringify({ machine: view.machine.label, plan }, null, 2));
1527
+ console.log(renderPrunePlan(view, plan));
1528
+ return console.log(plan.local.length ? "\n(dry run — `hunch workspaces prune --apply` runs this machine's commands after confirmation)" : "\n(dry run — nothing to apply on this machine)");
1529
+ }
1530
+ if (!plan.local.length) {
1531
+ if (opts.json)
1532
+ return console.log(JSON.stringify({ machine: view.machine.label, plan, results: [] }, null, 2));
1533
+ console.log(renderPrunePlan(view, plan));
1534
+ return console.log("\nnothing to apply on this machine");
1535
+ }
1536
+ if (!opts.json)
1537
+ console.log(renderPrunePlan(view, plan));
1538
+ if (!opts.yes) {
1539
+ const worktrees = plan.local.filter((s) => s.worktree).length;
1540
+ const ok = await confirmPrune(`Delete ${plan.local.length} branch(es)${worktrees ? ` and remove ${worktrees} worktree(s)` : ""} on ${view.machine.label}?`);
1541
+ if (!ok)
1542
+ return fail(process.stdin.isTTY ? "not confirmed — nothing was deleted" : "refusing to apply without confirmation: stdin is not a terminal; pass --yes to confirm explicitly");
1543
+ }
1544
+ const results = applyPrune(root, plan.local);
1545
+ // Memory follows what actually happened: re-snapshot so other machines see the change.
1546
+ // Best effort — the deletions above are real whatever the ledger write says.
1547
+ let recorded;
1548
+ try {
1549
+ recorded = recordWorkspaceSnapshot(store, root).status;
1550
+ }
1551
+ catch (error) {
1552
+ recorded = `failed: ${error.message}`;
1553
+ }
1554
+ if (opts.json)
1555
+ return console.log(JSON.stringify({ machine: view.machine.label, plan, results, recorded }, null, 2));
1556
+ console.log("");
1557
+ for (const r of results)
1558
+ console.log(` ${r.outcome === "deleted" ? "✓" : "✗"} ${r.step.branch}: ${r.detail}`);
1559
+ const failed = results.filter((r) => r.outcome === "failed").length;
1560
+ const ledger = recorded === "written" ? " · ledger updated" : recorded === "unchanged" || recorded === "no-home" || recorded === "off" ? "" : ` · ledger not updated (${recorded})`;
1561
+ console.log(`\n${results.length - failed} deleted, ${failed} refused by git${ledger}`);
1562
+ if (failed)
1563
+ process.exitCode = 1;
1564
+ }
1565
+ finally {
1566
+ store.close();
1567
+ }
1568
+ });
1569
+ workspacesCmd
1570
+ .command("label [label]")
1571
+ .description("Show or set this machine's label (what other machines see). Defaults to machine-<id>; never the hostname.")
1572
+ .action((label) => {
1573
+ const identity = label ? setMachineLabel(label) : loadOrCreateMachine();
1574
+ console.log(`${identity.label} (${identity.id}, ${machineFile()})`);
1575
+ const leak = labelLeaksIdentity(identity.label);
1576
+ if (leak)
1577
+ console.log(` ⚠ the label equals this machine's ${leak}; in a shared store every teammate sees it`);
1578
+ });
1579
+ workspacesCmd
1580
+ .command("forget <machine>")
1581
+ .description("Remove a machine's stored workspace record (by label or id) — e.g. a retired laptop. A memory move like any other: revertable via `hunch log`.")
1582
+ .action((machine) => {
1583
+ const { store, root } = storeFor();
1584
+ try {
1585
+ const victims = store.recs("workspaces").filter((r) => r.machine.label === machine || r.machine.id === machine || r.id === machine);
1586
+ if (!victims.length)
1587
+ return fail(`no workspace record for "${machine}" — \`hunch workspaces\` lists the machines in memory`);
1588
+ // Decide each record's home BEFORE deleting it, then flush exactly those homes.
1589
+ const homes = victims.map((v) => store.getPrivateRec("workspaces", v.id) ? "private" : "public");
1590
+ for (const v of victims)
1591
+ store.deleteWhereItLives("workspaces", v.id);
1592
+ pumpMemoryHomes(store, root, homes, `hunch: forget workspace ${machine}`);
1593
+ console.log(`✓ forgot ${victims.length} record(s) for ${machine}`);
1594
+ }
1595
+ finally {
1596
+ store.close();
1597
+ }
1598
+ });
1599
+ program
1600
+ .command("branches")
1601
+ .description("Every local branch across machines with a deterministic verdict: merged (ancestry / squash / rebase), pushed, dirty worktree, and a recommended action. Read-only; never deletes anything.")
1602
+ .option("--merged", "only branches proven merged")
1603
+ .option("--unpushed", "only branches with no upstream")
1604
+ .option("--stale <days>", "only branches whose last commit is older than N days")
1605
+ .option("--machine <label>", "only branches present on this machine")
1606
+ .option("--fetch", "run `git fetch --prune` first (network; off by default)")
1607
+ .option("--json", "emit the rows as JSON")
1608
+ .action((opts) => {
1609
+ const { store, root } = storeFor();
1610
+ try {
1611
+ if (!isGitRepo(root))
1612
+ return fail("`hunch branches` needs a git repo");
1613
+ const view = workspaceLedgerView(store, root, { fetch: opts.fetch });
1614
+ const now = new Date();
1615
+ let rows = branchRows(view.records, { staleAfterDays: view.config.stale_after_days, now });
1616
+ if (opts.merged)
1617
+ rows = rows.filter((r) => r.merged.status === "merged");
1618
+ if (opts.unpushed)
1619
+ rows = rows.filter((r) => r.upstream === null);
1620
+ if (opts.machine)
1621
+ rows = rows.filter((r) => r.machines.includes(opts.machine));
1622
+ if (opts.stale) {
1623
+ const days = Number(opts.stale);
1624
+ if (!Number.isFinite(days) || days < 0)
1625
+ return fail("--stale takes a number of days");
1626
+ rows = rows.filter((r) => !r.last_commit_at || now.getTime() - Date.parse(r.last_commit_at) > days * 86_400_000);
1627
+ }
1628
+ if (opts.json)
1629
+ return console.log(JSON.stringify({ machine: view.machine.label, branches: rows }, null, 2));
1630
+ console.log(renderBranchTable(view, rows));
1631
+ }
1632
+ finally {
1633
+ store.close();
1634
+ }
1635
+ });
1417
1636
  // ---- query ----------------------------------------------------------------
1418
1637
  program
1419
1638
  .command("query")
@@ -4553,9 +4772,10 @@ program
4553
4772
  // or not the agent called finish. Fail-open: the card below still renders.
4554
4773
  try {
4555
4774
  const closed = closeHookTask(root, provider, evt);
4556
- if (closed) {
4775
+ if (closed.length) {
4557
4776
  store ??= new HunchStore(paths);
4558
- persistTaskRecord(root, store, closed);
4777
+ for (const id of closed)
4778
+ persistTaskRecord(root, store, id);
4559
4779
  }
4560
4780
  }
4561
4781
  catch { /* the ledger and the card remain authoritative; the next finish retries */ }
@@ -4586,6 +4806,17 @@ program
4586
4806
  }
4587
4807
  }
4588
4808
  catch { /* passive reporting remains fail-open */ }
4809
+ // A task an earlier prompt of this session left open (interrupted before
4810
+ // its Stop) is over now: close it and keep its record.
4811
+ try {
4812
+ const settled = settleHookSession(root, provider, evt, { keepId: hookReportTaskId(root, provider, evt), keepNewest: true });
4813
+ if (settled.length) {
4814
+ store ??= new HunchStore(paths);
4815
+ for (const id of settled)
4816
+ persistTaskRecord(root, store, id);
4817
+ }
4818
+ }
4819
+ catch { /* the next Stop or prompt retries */ }
4589
4820
  // Pipeline turn bookkeeping (fresh block budget) + the one nag that must
4590
4821
  // repeat: edits from an earlier turn still unverified.
4591
4822
  if (evt.session_id && pipelineEnabled()) {
@@ -6490,6 +6721,10 @@ program
6490
6721
  console.log(`\n📊 ${rankingStatusLine(resolveTaskRankingMode(store.publicRoot, store))}`);
6491
6722
  }
6492
6723
  catch { /* no task records or no cache dir: nothing to say */ }
6724
+ // Workspace ledger, from stored records only (no git) so the hot view stays fast.
6725
+ const ws = workspaceSummaryLine(opts.private ? store.recs("workspaces") : store.json.loadAll("workspaces"), workspacesConfig(readConfig(hunchPaths(store.publicRoot))));
6726
+ if (ws)
6727
+ console.log(`\n${ws}`);
6493
6728
  }
6494
6729
  finally {
6495
6730
  store.close();
@@ -6758,6 +6993,17 @@ program
6758
6993
  console.log(`hooks: ${missing.length
6759
6994
  ? `⚠ missing ${missing.join(", ")} — ${fix}`
6760
6995
  : `post-commit, post-merge installed${hooks.preCommit ? " (+ pre-commit)" : ""}`}`);
6996
+ // Workspace ledger: what this machine is called, whether its record is in memory,
6997
+ // and whether the checkout hook that keeps it fresh is installed.
6998
+ try {
6999
+ const machine = loadOrCreateMachine();
7000
+ const stored = store.recs("workspaces").find((r) => r.machine.id === machine.id);
7001
+ const others = store.recs("workspaces").filter((r) => r.machine.id !== machine.id).length;
7002
+ const leak = labelLeaksIdentity(machine.label);
7003
+ console.log(`workspaces: this machine is ${machine.label}${leak ? ` (⚠ label equals the ${leak})` : ""} · record in memory: ${stored ? `yes (${stored.observed_at})` : "no"} · ${others} other machine(s)` +
7004
+ `${hooks.postCheckout ? " · post-checkout hook installed" : hooks.postCommit ? " · post-checkout hook not installed (`hunch index` adds it)" : ""}`);
7005
+ }
7006
+ catch { /* no machine file writable: nothing to report */ }
6761
7007
  }
6762
7008
  // In unified mode the public .hunch directory is only a routing shell.
6763
7009
  // Report the same effective manifest that `hunch migrate` reads and stamps,
package/dist/cli/serve.js CHANGED
@@ -35,6 +35,7 @@ export function registerServeCommands(program) {
35
35
  app.listen(port, "127.0.0.1", () => {
36
36
  console.log(`hunch ${HUNCH_VERSION} serving nuryel.state/1 on http://127.0.0.1:${port} — ${config.partitions.map((p) => scopePath(p.scope)).join(", ")} (${config.principals.length} principal(s))`);
37
37
  console.log(`Shared state view: http://127.0.0.1:${port}/operator`);
38
+ console.log(`MCP (streamable HTTP): http://127.0.0.1:${port}/nuryel/v1/mcp`);
38
39
  });
39
40
  const stop = () => { app.close(() => { app.closeStores(); process.exit(0); }); };
40
41
  process.on("SIGINT", stop);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * readOrCompute — the reuse rule every derived-state writer otherwise re-derives by hand, and
3
+ * gets wrong first. No dependencies beyond the platform: canonical JSON and WebCrypto SHA-256.
4
+ *
5
+ * Rules applied, in order (docs/nuryel-state-contract.md, "Read or compute"):
6
+ * 1. Read the subject. A current statement under the same transform whose dependency SET equals
7
+ * the given one is reused and nothing is computed. The set, not the order: the server derives
8
+ * a statement's identity from its dependency hashes sorted, so order never makes it new.
9
+ * 2. Otherwise run `compute` once and write the result as the subject's current statement.
10
+ * 3. The idempotency key names the REQUEST: subject, transform and dependencies, the content
11
+ * hash, and computed_at. A key without the content hash is reused when the same evidence
12
+ * yields new wording, and the contract refuses a reused key with another payload for good
13
+ * (the pilot's stuck outbox).
14
+ * 4. `supersedes` names the current statement it replaces under the same transform; the server
15
+ * keeps one current statement per subject and transform and refuses a second.
16
+ * 5. The audience carries forward: without an explicit `visibility` the new statement keeps the
17
+ * one it supersedes (audiences are preserved across supersession). An explicit change sends
18
+ * the predecessor's record hash as `expected_version`, which the server requires.
19
+ * 6. No retries. A refusal or a transport failure surfaces to the caller. Calling again re-reads
20
+ * first, so a write that did land is reused instead of written twice.
21
+ */
22
+ import type { DependencyRef, DerivedState, ReadResponse, RecordsResponse, Scope, WriteResult } from "../core/stateContract.js";
23
+ /** What the helper needs from a client; `createStateClient` satisfies it. */
24
+ export interface ReadOrComputeClient {
25
+ read(request: {
26
+ scope: Scope;
27
+ subject: string;
28
+ facets: ["derived"];
29
+ }): Promise<ReadResponse>;
30
+ records(request: {
31
+ scope: Scope;
32
+ ids: string[];
33
+ }): Promise<RecordsResponse>;
34
+ write(request: {
35
+ scope: Scope;
36
+ facet: "derived";
37
+ record: Record<string, unknown>;
38
+ idempotency_key: string;
39
+ supersedes?: string;
40
+ expected_version?: string;
41
+ }): Promise<WriteResult>;
42
+ }
43
+ export interface ComputedContent {
44
+ content: string;
45
+ field_provenance?: DerivedState["field_provenance"];
46
+ }
47
+ export interface ReadOrComputeRequest {
48
+ scope: Scope;
49
+ subject: string;
50
+ transform_version: string;
51
+ /** What the statement rests on; at least one. Equal sets reuse, whatever their order. */
52
+ dependencies: DependencyRef[];
53
+ provenance: DerivedState["provenance"];
54
+ /** Record audience. Omitted: the superseded statement's audience is kept. */
55
+ visibility?: DerivedState["visibility"];
56
+ /** Runs only when no current statement rests on exactly these dependencies. */
57
+ compute: () => string | ComputedContent | Promise<string | ComputedContent>;
58
+ /** ISO timestamp for computed_at; defaults to the clock. */
59
+ now?: () => string;
60
+ }
61
+ export type ReadOrComputeResult = {
62
+ reused: true;
63
+ record: DerivedState;
64
+ read_receipt: string;
65
+ } | {
66
+ reused: false;
67
+ record: DerivedState;
68
+ write: WriteResult;
69
+ superseded: string | null;
70
+ read_receipt: string;
71
+ };
72
+ /** The server's canonical form (src/core/stateCanonical.ts): keys in code-unit order, undefined
73
+ * dropped, non-finite numbers and `__proto__` refused. Kept in step by test. */
74
+ export declare function canonicalJson(value: unknown): string;
75
+ /** `sha256:<hex>` over the canonical form — the server's stateHash. */
76
+ export declare function stateHash(value: unknown): Promise<string>;
77
+ export declare function readOrCompute(client: ReadOrComputeClient, request: ReadOrComputeRequest): Promise<ReadOrComputeResult>;
@@ -0,0 +1,85 @@
1
+ const DERIVED_SCHEMA = "nuryel.derived/1";
2
+ /** The server's canonical form (src/core/stateCanonical.ts): keys in code-unit order, undefined
3
+ * dropped, non-finite numbers and `__proto__` refused. Kept in step by test. */
4
+ export function canonicalJson(value) {
5
+ return JSON.stringify(canonical(value));
6
+ }
7
+ function canonical(value) {
8
+ if (value === null || typeof value === "string" || typeof value === "boolean")
9
+ return value;
10
+ if (typeof value === "number") {
11
+ if (!Number.isFinite(value))
12
+ throw new Error("canonical form rejects non-finite numbers");
13
+ return value;
14
+ }
15
+ if (Array.isArray(value))
16
+ return value.map(canonical);
17
+ if (typeof value === "object") {
18
+ const out = {};
19
+ for (const key of Object.keys(value).sort()) {
20
+ if (key === "__proto__")
21
+ throw new Error("canonical form rejects reserved key __proto__");
22
+ const v = value[key];
23
+ if (v !== undefined)
24
+ out[key] = canonical(v);
25
+ }
26
+ return out;
27
+ }
28
+ throw new Error(`canonical form rejects ${typeof value}`);
29
+ }
30
+ /** `sha256:<hex>` over the canonical form — the server's stateHash. */
31
+ export async function stateHash(value) {
32
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalJson(value)));
33
+ return `sha256:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`;
34
+ }
35
+ function dependencySet(dependencies) {
36
+ return dependencies.map(canonicalJson).sort().join("\n");
37
+ }
38
+ function sameScope(a, b) {
39
+ return !!a && a.kind === b.kind && a.id === b.id;
40
+ }
41
+ export async function readOrCompute(client, request) {
42
+ const { scope, subject, transform_version, dependencies, provenance } = request;
43
+ if (!Array.isArray(dependencies) || dependencies.length === 0)
44
+ throw new Error("readOrCompute: derived state needs at least one dependency (a statement nothing can invalidate is not state)");
45
+ const read = await client.read({ scope, subject, facets: ["derived"] });
46
+ const refs = (read.state_of_record?.current ?? []).filter((ref) => ref.facet === "derived" && sameScope(ref.scope, scope));
47
+ const recordHash = new Map(refs.map((ref) => [ref.id, ref.record_hash]));
48
+ let records = read.records ?? {};
49
+ const unseen = refs.map((ref) => ref.id).filter((id) => !records[id]);
50
+ // Hosts that predate `records` on the read answer by id instead.
51
+ if (unseen.length)
52
+ records = { ...records, ...(await client.records({ scope, ids: unseen })).records };
53
+ const current = refs
54
+ .map((ref) => records[ref.id])
55
+ .filter((r) => !!r && r.schema === DERIVED_SCHEMA && r.subject === subject && r.transform_version === transform_version && r.state === "current" && r.valid_to == null);
56
+ const wanted = dependencySet(dependencies);
57
+ const reusable = current.find((r) => dependencySet(r.dependencies) === wanted);
58
+ if (reusable)
59
+ return { reused: true, record: reusable, read_receipt: read.receipt_id };
60
+ const computed = await request.compute();
61
+ const { content, field_provenance } = typeof computed === "string" ? { content: computed, field_provenance: undefined } : computed;
62
+ if (typeof content !== "string" || content.length === 0)
63
+ throw new Error("readOrCompute: compute must return non-empty content");
64
+ const content_hash = await stateHash(content);
65
+ const computed_at = (request.now ?? (() => new Date().toISOString()))();
66
+ const incumbent = current.find((r) => dependencySet(r.dependencies) !== wanted) ?? null;
67
+ const statement = await stateHash({ scope, subject, transform_version, dependencies: dependencies.map(canonicalJson).sort() });
68
+ const idempotency_key = `derived:${statement.slice(7, 23)}:${content_hash.slice(7, 23)}:${computed_at}`;
69
+ const visibility = request.visibility !== undefined ? request.visibility : incumbent?.visibility;
70
+ const audienceChanges = !!incumbent && canonicalJson(incumbent.visibility ?? null) !== canonicalJson(visibility ?? null);
71
+ const record = {
72
+ ...(visibility ? { visibility } : {}),
73
+ schema: DERIVED_SCHEMA, scope, subject, content, content_hash, dependencies,
74
+ ...(field_provenance ? { field_provenance } : {}),
75
+ transform_version, computed_at, valid_to: null, state: "current", provenance,
76
+ };
77
+ const write = await client.write({
78
+ scope, facet: "derived", record, idempotency_key,
79
+ ...(incumbent ? { supersedes: incumbent.id } : {}),
80
+ ...(audienceChanges ? { expected_version: recordHash.get(incumbent.id) } : {}),
81
+ });
82
+ const stored = (write.record ?? { ...record, id: write.record_id });
83
+ return { reused: false, record: stored, write, superseded: incumbent?.id ?? null, read_receipt: read.receipt_id };
84
+ }
85
+ //# sourceMappingURL=readOrCompute.js.map
@@ -314,3 +314,4 @@ export declare function createStateClient(opts: StateClientOptions): {
314
314
  }>;
315
315
  };
316
316
  export type StateClient = ReturnType<typeof createStateClient>;
317
+ export { readOrCompute, type ReadOrComputeClient, type ReadOrComputeRequest, type ReadOrComputeResult, type ComputedContent } from "./readOrCompute.js";
@@ -62,4 +62,5 @@ export function createStateClient(opts) {
62
62
  health: () => call("GET", "/nuryel/v1/health"),
63
63
  };
64
64
  }
65
+ export { readOrCompute } from "./readOrCompute.js";
65
66
  //# sourceMappingURL=state.js.map
@@ -131,6 +131,7 @@ export interface G2ShadowSweepReport {
131
131
  policy_id: string;
132
132
  error: string;
133
133
  }>;
134
+ retired: string[];
134
135
  skipped_reason: string | null;
135
136
  authority: "none";
136
137
  effects: "shadow_only";
@@ -677,6 +677,7 @@ export class ConstitutionService {
677
677
  const recorded = [];
678
678
  const existing = [];
679
679
  const failures = [];
680
+ const retired = [];
680
681
  if (manifest) {
681
682
  const before = new Set(this.repository.listShadowEvaluations({ privateOnly: true }).map((record) => record.id));
682
683
  for (const policyId of manifest.policy_ids) {
@@ -686,6 +687,12 @@ export class ConstitutionService {
686
687
  if (!policy || publicDuplicate || this.repository.homeOfPolicy(policyId) !== "private" || policy.data_class === "public") {
687
688
  throw new Error("selected policy is not in one exact private-only home");
688
689
  }
690
+ // A retired policy has closed its valid-time window: observing it again is
691
+ // not evidence, only growth. Its recorded history stays untouched.
692
+ if (policy.state === "retired") {
693
+ retired.push(policyId);
694
+ continue;
695
+ }
689
696
  const record = this.recordShadow(policyId, { now: opts.now });
690
697
  if (before.has(record.id))
691
698
  existing.push(record.id);
@@ -705,6 +712,7 @@ export class ConstitutionService {
705
712
  recorded: recorded.sort(),
706
713
  existing: existing.sort(),
707
714
  failures: failures.sort((left, right) => left.policy_id.localeCompare(right.policy_id)),
715
+ retired: retired.sort(),
708
716
  skipped_reason: manifest ? null : "No current private G2 plan; shadow sweep wrote nothing.",
709
717
  authority: "none",
710
718
  effects: "shadow_only",