@davesheffer/hunch 1.38.1 → 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 (37) hide show
  1. package/dist/cli/index.js +240 -6
  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/types.d.ts +66 -1
  15. package/dist/core/types.js +3 -0
  16. package/dist/core/workspace.d.ts +234 -0
  17. package/dist/core/workspace.js +335 -0
  18. package/dist/extractors/helm.d.ts +17 -28
  19. package/dist/extractors/helm.js +12 -12
  20. package/dist/extractors/indexer.js +171 -7
  21. package/dist/extractors/k8sManifest.d.ts +59 -0
  22. package/dist/extractors/k8sManifest.js +507 -0
  23. package/dist/extractors/workspaces.d.ts +18 -0
  24. package/dist/extractors/workspaces.js +350 -0
  25. package/dist/integrations/claudemd.js +1 -0
  26. package/dist/integrations/hooks.d.ts +2 -0
  27. package/dist/integrations/hooks.js +25 -0
  28. package/dist/integrations/scaffold.js +11 -0
  29. package/dist/integrations/workspaceLedger.d.ts +73 -0
  30. package/dist/integrations/workspaceLedger.js +201 -0
  31. package/dist/mcp/server.js +54 -0
  32. package/dist/serve/app.d.ts +2 -0
  33. package/dist/serve/app.js +107 -92
  34. package/dist/serve/mcpHttp.d.ts +27 -0
  35. package/dist/serve/mcpHttp.js +95 -0
  36. package/package.json +1 -1
  37. 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";
@@ -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")
@@ -6502,6 +6721,10 @@ program
6502
6721
  console.log(`\n📊 ${rankingStatusLine(resolveTaskRankingMode(store.publicRoot, store))}`);
6503
6722
  }
6504
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}`);
6505
6728
  }
6506
6729
  finally {
6507
6730
  store.close();
@@ -6770,6 +6993,17 @@ program
6770
6993
  console.log(`hooks: ${missing.length
6771
6994
  ? `⚠ missing ${missing.join(", ")} — ${fix}`
6772
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 */ }
6773
7007
  }
6774
7008
  // In unified mode the public .hunch directory is only a routing shell.
6775
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",
@@ -93,16 +93,21 @@ function parsedSymbolFor(graphSymbol, parsed) {
93
93
  const base = symbolId(graphSymbol.file, graphSymbol.name, graphSymbol.kind);
94
94
  return matches.find((_symbol, index) => (index === 0 ? base : `${base}_${index}`) === graphSymbol.id) ?? null;
95
95
  }
96
- function spliceBytes(source, replacements) {
97
- let bytes = Buffer.from(source, "utf8");
96
+ /** `start`/`end` are JS string (UTF-16 code unit) indices. Despite their
97
+ * names, parse.ts's startByte/endByte/atByte carry the same units — native
98
+ * tree-sitter indexes the JS string it was handed, not its UTF-8 encoding —
99
+ * so every scan and splice against them must be string-based, never Buffer-
100
+ * based. */
101
+ function spliceChars(source, replacements) {
102
+ let result = source;
98
103
  for (const replacement of [...replacements].sort((a, b) => b.start - a.start)) {
99
- bytes = Buffer.concat([
100
- bytes.subarray(0, replacement.start),
101
- Buffer.from(replacement.text, "utf8"),
102
- bytes.subarray(replacement.end),
103
- ]);
104
+ result = result.slice(0, replacement.start) + replacement.text + result.slice(replacement.end);
104
105
  }
105
- return bytes.toString("utf8");
106
+ return result;
107
+ }
108
+ /** Where a new top-level statement (an import) can be inserted without splitting a shebang line. */
109
+ function insertionPoint(source) {
110
+ return source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
106
111
  }
107
112
  function mutateSource(policy, base, sourceFile, source) {
108
113
  const assertion = policy.assertion;
@@ -145,10 +150,10 @@ function mutateSource(policy, base, sourceFile, source) {
145
150
  const specifier = relativeSpecifier(sourceFile, targetFile);
146
151
  if (parsed.imports.some((candidate) => candidate === specifier))
147
152
  return { error: "mutation-component-import-already-present" };
148
- const insertion = source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
153
+ const insertion = insertionPoint(source);
149
154
  return {
150
155
  file: sourceFile,
151
- source: spliceBytes(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(specifier)}; // hunch deterministic component mutation\n` }]),
156
+ source: spliceChars(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(specifier)}; // hunch deterministic component mutation\n` }]),
152
157
  };
153
158
  }
154
159
  const subject = symbolForSelector(base, assertion.subject);
@@ -161,7 +166,7 @@ function mutateSource(policy, base, sourceFile, source) {
161
166
  if (!definition)
162
167
  return { error: "mutation-subject-definition-unresolved" };
163
168
  if (assertion.kind === "exists") {
164
- return { file: subject.file, source: spliceBytes(source, [{ start: definition.startByte, end: definition.endByte, text: "" }]) };
169
+ return { file: subject.file, source: spliceChars(source, [{ start: definition.startByte, end: definition.endByte, text: "" }]) };
165
170
  }
166
171
  if (assertion.kind === "not-reaches"
167
172
  && assertion.relation.edges.length === 1
@@ -173,10 +178,10 @@ function mutateSource(policy, base, sourceFile, source) {
173
178
  if (parsed.imports.some((specifier) => externalPackage(specifier) === dependency)) {
174
179
  return { error: "mutation-forbidden-import-already-present" };
175
180
  }
176
- const insertion = source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
181
+ const insertion = insertionPoint(source);
177
182
  return {
178
183
  file: subject.file,
179
- source: spliceBytes(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(dependency)}; // hunch deterministic source mutation\n` }]),
184
+ source: spliceChars(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(dependency)}; // hunch deterministic source mutation\n` }]),
180
185
  };
181
186
  }
182
187
  const object = symbolForSelector(base, assertion.object);
@@ -193,19 +198,19 @@ function mutateSource(policy, base, sourceFile, source) {
193
198
  .map((call) => ({ start: call.atByte, end: call.endByte, text: "hunchMutationRemovedCall" }));
194
199
  if (!replacements.length)
195
200
  return { error: "mutation-required-call-unresolved" };
196
- return { file: subject.file, source: spliceBytes(source, replacements) };
201
+ return { file: subject.file, source: spliceChars(source, replacements) };
197
202
  }
198
203
  if (!assertion.relation.edges.includes("calls"))
199
204
  return { error: "mutation-call-edge-not-supported" };
200
- const bytes = Buffer.from(source, "utf8");
201
- const open = bytes.indexOf("{".charCodeAt(0), definition.startByte);
205
+ // String search, matching definition.startByte/endByte's actual units -- see spliceChars' doc comment.
206
+ const open = source.indexOf("{", definition.startByte);
202
207
  if (open < 0 || open >= definition.endByte)
203
208
  return { error: "mutation-subject-body-unsupported" };
204
209
  const replacements = [{ start: open + 1, end: open + 1, text: `\n ${object.name}(); // hunch deterministic source mutation\n` }];
205
210
  if (object.file !== subject.file) {
206
211
  const specifier = relativeSpecifier(subject.file, object.file);
207
212
  if (!parsed.imports.includes(specifier)) {
208
- const insertion = source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
213
+ const insertion = insertionPoint(source);
209
214
  replacements.push({
210
215
  start: insertion,
211
216
  end: insertion,
@@ -215,7 +220,7 @@ function mutateSource(policy, base, sourceFile, source) {
215
220
  }
216
221
  return {
217
222
  file: subject.file,
218
- source: spliceBytes(source, replacements),
223
+ source: spliceChars(source, replacements),
219
224
  };
220
225
  }
221
226
  function removeWorktree(root, hooks, env, checkout) {