@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.
- package/dist/cli/index.js +255 -9
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskRecord.js +6 -3
- package/dist/core/taskReport.d.ts +25 -2
- package/dist/core/taskReport.js +92 -18
- package/dist/core/taskReportEvidence.d.ts +4 -1
- package/dist/core/taskReportEvidence.js +5 -2
- package/dist/core/taskReportHook.d.ts +16 -3
- package/dist/core/taskReportHook.js +65 -13
- package/dist/core/taskTouched.d.ts +4 -0
- package/dist/core/taskTouched.js +30 -10
- package/dist/core/types.d.ts +66 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +234 -0
- package/dist/core/workspace.js +335 -0
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +18 -0
- package/dist/extractors/workspaces.js +350 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/hooks.d.ts +2 -0
- package/dist/integrations/hooks.js +25 -0
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +73 -0
- package/dist/integrations/workspaceLedger.js +201 -0
- package/dist/mcp/server.js +54 -0
- package/dist/mcp/taskReportTools.d.ts +9 -0
- package/dist/mcp/taskReportTools.js +13 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
package/dist/client/state.d.ts
CHANGED
|
@@ -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";
|
package/dist/client/state.js
CHANGED
|
@@ -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",
|