@davesheffer/hunch 1.39.0 → 1.39.2

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 +193 -65
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/client/state.d.ts +2 -1
  4. package/dist/client/state.js +1 -0
  5. package/dist/core/agenthook.d.ts +14 -0
  6. package/dist/core/agenthook.js +55 -8
  7. package/dist/core/capturetoken.d.ts +30 -3
  8. package/dist/core/capturetoken.js +29 -3
  9. package/dist/core/changeProof.js +5 -1
  10. package/dist/core/checkreport.d.ts +7 -0
  11. package/dist/core/checkreport.js +20 -3
  12. package/dist/core/compare.js +3 -2
  13. package/dist/core/correction.d.ts +10 -4
  14. package/dist/core/correction.js +7 -4
  15. package/dist/core/countersign.d.ts +28 -0
  16. package/dist/core/countersign.js +50 -0
  17. package/dist/core/reviewqueue.js +6 -1
  18. package/dist/core/spawnCommand.js +41 -9
  19. package/dist/core/stateHttp.d.ts +1 -1
  20. package/dist/core/stateHttp.js +3 -1
  21. package/dist/core/taskReportEvidence.js +31 -11
  22. package/dist/core/topics.js +1 -1
  23. package/dist/core/types.d.ts +1 -0
  24. package/dist/core/workspace.d.ts +23 -1
  25. package/dist/core/workspace.js +31 -7
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/workspaces.d.ts +10 -0
  31. package/dist/extractors/workspaces.js +92 -15
  32. package/dist/integrations/gitignore.d.ts +27 -2
  33. package/dist/integrations/gitignore.js +103 -17
  34. package/dist/integrations/hooks.d.ts +61 -7
  35. package/dist/integrations/hooks.js +330 -43
  36. package/dist/integrations/scaffold.js +1 -1
  37. package/dist/integrations/workspaceLedger.d.ts +23 -3
  38. package/dist/integrations/workspaceLedger.js +114 -8
  39. package/dist/mcp/server.js +115 -56
  40. package/dist/serve/app.d.ts +4 -0
  41. package/dist/serve/app.js +56 -36
  42. package/dist/store/hunchStore.d.ts +4 -2
  43. package/dist/store/hunchStore.js +23 -6
  44. package/dist/store/stateBinding.js +111 -42
  45. package/dist/wiki/wiki.d.ts +7 -0
  46. package/dist/wiki/wiki.js +19 -8
  47. package/package.json +1 -1
  48. package/server.json +2 -2
package/dist/serve/app.js CHANGED
@@ -93,6 +93,7 @@ function parseScopeParam(value) {
93
93
  }
94
94
  export function createServeApp(config, opts = {}) {
95
95
  const version = opts.version ?? HUNCH_VERSION;
96
+ const writeLockTimeoutMs = opts.writeLockTimeoutMs;
96
97
  const configFile = config.file;
97
98
  const authStateDir = opts.authStateDir ?? (configFile ? resolve(configFile + ".auth") : undefined);
98
99
  const stores = new Map();
@@ -125,19 +126,26 @@ export function createServeApp(config, opts = {}) {
125
126
  };
126
127
  const problemBody = (p) => ({ type: `${PROBLEM_TYPE}${p.code}`, title: p.code, status: p.status, detail: p.message, ...p.extra });
127
128
  const sendProblem = (res, p) => { send(res, p.status, problemBody(p), "application/problem+json"); };
128
- /** Anything thrown → problem. Shared by REST (problem+json) and MCP (tool error results). */
129
+ const log = opts.log ?? ((line) => { process.stderr.write(`${line}\n`); });
130
+ /** Anything thrown → problem. Shared by REST (problem+json) and MCP (tool error results).
131
+ * A contract refusal (4xx) explains itself to the caller. A server-side failure (5xx) does
132
+ * not: its message can name lock paths, PIDs, host names or store paths, so the caller gets
133
+ * a generic detail and the specifics go to the server log only. */
129
134
  const problemOf = (error) => {
130
135
  if (error instanceof HttpProblem)
131
136
  return error;
132
137
  if (error instanceof StateRefusal)
133
138
  return problem(REFUSAL_STATUS[error.code], error.code, error.message, error.conflict ? { conflict: error.conflict } : {});
134
- if (error instanceof WriteLockTimeout)
135
- return problem(503, "write-lock-timeout", error.message, { "retry-after": 1 });
139
+ if (error instanceof WriteLockTimeout) {
140
+ log(`hunch serve: 503 write-lock-timeout: ${error.message}`);
141
+ return problem(503, "write-lock-timeout", "the partition is busy with another write; retry shortly", { "retry-after": 1 });
142
+ }
136
143
  if (error && typeof error === "object" && error.name === "ZodError") {
137
144
  const issues = (error.issues ?? []).map((i) => `${i.path.join(".") || "request"}: ${i.message}`);
138
145
  return problem(400, "malformed", `request is malformed: ${issues.join("; ")}`, { issues });
139
146
  }
140
- return problem(500, "internal", error.message);
147
+ log(`hunch serve: 500 internal: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
148
+ return problem(500, "internal", "internal server error");
141
149
  };
142
150
  /** One authenticated state verb. The REST routes and the MCP tools both call this, so every
143
151
  * rule — grants, the write lock, flushes, refusals — is the same whichever transport carried it. */
@@ -191,20 +199,53 @@ export function createServeApp(config, opts = {}) {
191
199
  if (route === "write" || route === "capture" || route === "capture-batch") {
192
200
  const { hunchDir } = stateHomeFor(store, scope);
193
201
  const opts = { ...accessOptions, flush };
202
+ const lockOptions = { timeoutMs: writeLockTimeoutMs };
194
203
  if (route === "write") {
195
- const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, opts));
204
+ const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, opts), lockOptions);
196
205
  return { status: result.outcome === "created" ? 201 : 200, payload: result };
197
206
  }
198
207
  if (route === "capture") {
199
- const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, opts));
208
+ const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, opts), lockOptions);
200
209
  return { status: result.outcome === "created" ? 201 : 200, payload: result };
201
210
  }
202
- return { status: 200, payload: await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, opts)) };
211
+ return { status: 200, payload: await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, opts), lockOptions) };
203
212
  }
204
213
  if (route === "subscribe")
205
214
  return { status: 200, payload: subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }, accessOptions) };
206
215
  return { status: 200, payload: recordsState(store, { schema: STATE_RECORDS_VERSION, principal, ...body }, accessOptions) };
207
216
  };
217
+ /** The bearer/DPoP credential → the principal. Every authenticated route, and health when a
218
+ * credential is presented, goes through this one check. */
219
+ const authenticate = async (req, res, url, activeConfig) => {
220
+ const authorization = /^(Bearer|DPoP) ([^\s]+)$/i.exec(req.headers.authorization ?? '');
221
+ const credential = resolveCredential(activeConfig, authorization?.[2]);
222
+ if (!credential)
223
+ throw problem(401, 'unauthorized', 'valid credentials are required');
224
+ const countHeader = (name) => req.rawHeaders.filter((header, index) => index % 2 === 0 && header.toLowerCase() === name).length;
225
+ if (countHeader('authorization') !== 1 || countHeader('dpop') > 1)
226
+ throw problem(401, 'invalid_dpop_proof', 'ambiguous authentication headers');
227
+ if (credential.proof_key) {
228
+ if (authorization[1].toLowerCase() !== 'dpop')
229
+ throw problem(401, 'invalid_dpop_proof', 'this credential requires DPoP proof; bearer fallback is disabled');
230
+ if (!activeConfig.public_origin || !authStateDir)
231
+ throw problem(503, 'proof-state-unavailable', 'key-bound authentication requires a public origin and persistent proof state');
232
+ try {
233
+ await verifyStateProof({ proof: typeof req.headers.dpop === 'string' ? req.headers.dpop : undefined, key: credential.proof_key, method: req.method ?? '', url: activeConfig.public_origin + url.pathname, token: authorization[2], stateDir: authStateDir });
234
+ }
235
+ catch (error) {
236
+ if (error instanceof StateProofError) {
237
+ res.setHeader('WWW-Authenticate', `DPoP error="${error.code}", algs="EdDSA"`);
238
+ if (error.nonce)
239
+ res.setHeader('DPoP-Nonce', error.nonce);
240
+ throw problem(401, error.code, error.message);
241
+ }
242
+ throw problem(503, 'proof-state-unavailable', 'proof replay state is unavailable; authentication is refused');
243
+ }
244
+ }
245
+ else if (authorization[1].toLowerCase() !== 'bearer' || req.headers.dpop !== undefined)
246
+ throw problem(401, 'invalid_dpop_proof', 'credential is not bound to a proof key');
247
+ return { id: credential.id, kind: credential.kind, grants: credential.grants, ...(credential.display ? { display: credential.display } : {}) };
248
+ };
208
249
  const server = createServer(async (req, res) => {
209
250
  try {
210
251
  let activeConfig;
@@ -222,36 +263,15 @@ export function createServeApp(config, opts = {}) {
222
263
  return res.end(asset[0]);
223
264
  }
224
265
  if (url.pathname === "/nuryel/v1/health" && req.method === "GET") {
225
- return send(res, 200, { ok: true, version, protocol: "nuryel.state/1", partitions: activeConfig.partitions.map((p) => scopePath(p.scope)) });
226
- }
227
- const authorization = /^(Bearer|DPoP) ([^\s]+)$/i.exec(req.headers.authorization ?? '');
228
- const credential = resolveCredential(activeConfig, authorization?.[2]);
229
- if (!credential)
230
- throw problem(401, 'unauthorized', 'valid credentials are required');
231
- const countHeader = (name) => req.rawHeaders.filter((header, index) => index % 2 === 0 && header.toLowerCase() === name).length;
232
- if (countHeader('authorization') !== 1 || countHeader('dpop') > 1)
233
- throw problem(401, 'invalid_dpop_proof', 'ambiguous authentication headers');
234
- if (credential.proof_key) {
235
- if (authorization[1].toLowerCase() !== 'dpop')
236
- throw problem(401, 'invalid_dpop_proof', 'this credential requires DPoP proof; bearer fallback is disabled');
237
- if (!activeConfig.public_origin || !authStateDir)
238
- throw problem(503, 'proof-state-unavailable', 'key-bound authentication requires a public origin and persistent proof state');
239
- try {
240
- await verifyStateProof({ proof: typeof req.headers.dpop === 'string' ? req.headers.dpop : undefined, key: credential.proof_key, method: req.method ?? '', url: activeConfig.public_origin + url.pathname, token: authorization[2], stateDir: authStateDir });
241
- }
242
- catch (error) {
243
- if (error instanceof StateProofError) {
244
- res.setHeader('WWW-Authenticate', `DPoP error="${error.code}", algs="EdDSA"`);
245
- if (error.nonce)
246
- res.setHeader('DPoP-Nonce', error.nonce);
247
- throw problem(401, error.code, error.message);
248
- }
249
- throw problem(503, 'proof-state-unavailable', 'proof replay state is unavailable; authentication is refused');
250
- }
266
+ // Liveness is public (a load balancer or proxy probe holds no token). Which
267
+ // partitions this server hosts is not: their ids name people and organizations.
268
+ const liveness = { ok: true, version, protocol: "nuryel.state/1" };
269
+ if (req.headers.authorization === undefined && req.headers.dpop === undefined)
270
+ return send(res, 200, liveness);
271
+ await authenticate(req, res, url, activeConfig);
272
+ return send(res, 200, { ...liveness, partitions: activeConfig.partitions.map((p) => scopePath(p.scope)) });
251
273
  }
252
- else if (authorization[1].toLowerCase() !== 'bearer' || req.headers.dpop !== undefined)
253
- throw problem(401, 'invalid_dpop_proof', 'credential is not bound to a proof key');
254
- const principal = { id: credential.id, kind: credential.kind, grants: credential.grants, ...(credential.display ? { display: credential.display } : {}) };
274
+ const principal = await authenticate(req, res, url, activeConfig);
255
275
  if (url.pathname === "/nuryel/v1/capabilities" && req.method === "GET") {
256
276
  const scope = parseScopeParam(url.searchParams.get("scope"));
257
277
  const { status, payload } = await dispatch("capabilities", principal, scope ? { scope } : {}, activeConfig);
@@ -5,7 +5,7 @@ import { type Embedder } from "./embedder.js";
5
5
  import { JsonStore } from "./jsonStore.js";
6
6
  import { type RankingContext, type RankingQuery, type RankingWeights, type SlotOptions, type TaskSelection } from "../core/taskRanking.js";
7
7
  import { type VetoTier } from "../core/strictgate.js";
8
- import { type DiffAnalysis } from "../extractors/diff.js";
8
+ import { type DiffAnalysis, type DiffStatus } from "../extractors/diff.js";
9
9
  import type { CheckReport, CausalWhy, ImpactReport } from "../core/checkreport.js";
10
10
  import { type ReviewedLandscapeSelection } from "../core/landscapeDelivery.js";
11
11
  import { type StateSlice } from "../core/stateDelivery.js";
@@ -384,7 +384,7 @@ export declare class HunchStore {
384
384
  /** PR impact (read-only, ADVISORY — never gates): the dependency + memory surface
385
385
  * of a change. Composes the SAME primitives as buildCheckReport (blast radius,
386
386
  * scope-matched constraints, why) so impact and gating can never disagree. */
387
- prImpact(files: string[], diff: string): ImpactReport;
387
+ prImpact(files: string[], diff: string, diffStatus?: DiffStatus): ImpactReport;
388
388
  /** Structure view (hunch_structure / hunch structure): serve the indexed shape of
389
389
  * the repo so an agent ORIENTS from the graph instead of running grep/glob rounds.
390
390
  * Resolution: no target -> repo map; a directory -> its files+symbols; a file ->
@@ -446,6 +446,8 @@ export declare class HunchStore {
446
446
  strict: boolean;
447
447
  lastChange?: (f: string) => string;
448
448
  publicOnly?: boolean;
449
+ /** Completeness of `diff` as produced for a gate (a GateDiff fits). */
450
+ diffStatus?: DiffStatus;
449
451
  }): CheckReport;
450
452
  /** Sprawl/"this already exists" guard (ADVISORY, never blocks). A symbol the diff
451
453
  * ADDS whose name already exists in the indexed graph in a file NOT touched by the
@@ -28,7 +28,7 @@ import { currentForTopic, isInForce } from "../core/topics.js";
28
28
  import { edgeId } from "../core/ids.js";
29
29
  import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
30
30
  import { effectiveForbids, matchForbids } from "../core/constraintmatch.js";
31
- import { analyzeDiff } from "../extractors/diff.js";
31
+ import { analyzeDiff, diffContentGaps } from "../extractors/diff.js";
32
32
  import { selectReviewedLandscape, } from "../core/landscapeDelivery.js";
33
33
  import { STATE_KINDS, STATE_SLICE_CAPS, compareStateHits, isStateKind, stateLiveness, stateObservedAt, stateSearchDoc, stateSubject, } from "../core/stateDelivery.js";
34
34
  /** Git cannot resolve repository identity from a cwd that does not exist yet.
@@ -1332,7 +1332,7 @@ export class HunchStore {
1332
1332
  /** PR impact (read-only, ADVISORY — never gates): the dependency + memory surface
1333
1333
  * of a change. Composes the SAME primitives as buildCheckReport (blast radius,
1334
1334
  * scope-matched constraints, why) so impact and gating can never disagree. */
1335
- prImpact(files, diff) {
1335
+ prImpact(files, diff, diffStatus) {
1336
1336
  const changed = new Set(files.map(toPosixTarget));
1337
1337
  const blast = new Map();
1338
1338
  for (const f of changed) {
@@ -1344,7 +1344,7 @@ export class HunchStore {
1344
1344
  blast.set(b.file, b);
1345
1345
  }
1346
1346
  }
1347
- const report = this.buildCheckReport([...changed], diff, { strict: false });
1347
+ const report = this.buildCheckReport([...changed], diff, { strict: false, diffStatus });
1348
1348
  const decisions = new Map();
1349
1349
  for (const f of changed) {
1350
1350
  for (const d of this.why(f).decisions)
@@ -1639,6 +1639,7 @@ export class HunchStore {
1639
1639
  });
1640
1640
  const directReport = [];
1641
1641
  const addedDepSet = new Set(an.addedDeps);
1642
+ const missingContent = diffContentGaps(diff, opts.diffStatus);
1642
1643
  for (const { c, files: fs } of direct.values()) {
1643
1644
  const forbids = effectiveForbids(c);
1644
1645
  if (forbids) {
@@ -1646,11 +1647,27 @@ export class HunchStore {
1646
1647
  // dep imported / symbol added / pattern matched in scoped code) — not by bare
1647
1648
  // scope-touch. A commit that touches the scope but doesn't trip it COMPLIES → drop it
1648
1649
  // (no noise). A real hit blocks WITHOUT the staleness gate: content is verified per
1649
- // commit, so file churn can't retract the teeth (dec_e0a36efbf5). Empty diff ⇒ can't
1650
- // prove a violation ⇒ treat as clean.
1650
+ // commit, so file churn can't retract the teeth (dec_e0a36efbf5). A COMPLETE diff with
1651
+ // no added lines for a file proves nothing was added there ⇒ clean.
1651
1652
  const scopedAdded = fs.flatMap((f) => an.addedLinesByFile.get(f) ?? []);
1652
- if (!matchForbids(forbids, addedDepSet, scopedAdded))
1653
+ if (!matchForbids(forbids, addedDepSet, scopedAdded)) {
1654
+ // …but when the diff is truncated, git could not produce it, or a file's content
1655
+ // could not be read, "no added lines" is not evidence of compliance. A blocking
1656
+ // rule over such a file is UNEVALUABLE and fails closed under the same strict gate
1657
+ // a proven hit would face (dec_20db57c576). Advisory/warning rules stay quiet.
1658
+ const unseen = missingContent ? fs.filter((f) => missingContent.missing(f)) : [];
1659
+ if (c.severity !== "blocking" || !unseen.length)
1660
+ continue;
1661
+ const strictBlocks = isStrictBlocker(c, false);
1662
+ directReport.push({
1663
+ id: c.id, severity: c.severity, statement: c.statement, rationale: c.rationale ?? "",
1664
+ files: fs, strictBlocks,
1665
+ downgrade: strictBlocks ? undefined : "low-confidence",
1666
+ unevaluable: { reason: missingContent.reason(unseen), files: unseen },
1667
+ why: this.causalChain(c.id),
1668
+ });
1653
1669
  continue;
1670
+ }
1654
1671
  const strictBlocks = isStrictBlocker(c, false);
1655
1672
  directReport.push({
1656
1673
  id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
@@ -26,6 +26,7 @@ import { writeFileAtomic } from "../core/io.js";
26
26
  import { join } from "node:path";
27
27
  import { z } from "zod";
28
28
  import { appendChanges, latestSeqFor, readLedger } from "./changeLedger.js";
29
+ import { STATE_ONLY_FACETS } from "./replay.js";
29
30
  import { hunchPaths } from "../core/paths.js";
30
31
  import { decisionId } from "../core/ids.js";
31
32
  import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
@@ -274,15 +275,18 @@ export function readState(store, input, options = {}) {
274
275
  }
275
276
  if (facets.has("derived"))
276
277
  for (const d of store.recs("derived")) {
278
+ // Match the subject BEFORE admit, as every other facet does: admit names an ungranted
279
+ // partition in denied_scopes, and only a record that matches may name its partition.
280
+ // A linked observation must sit in the request's (granted) partition, so it never names one.
281
+ const direct = isSubject(d.subject) || d.id === subject;
282
+ const linked = scopePath(recordScope(d, repo)) === scopePath(request.scope) && linkedObservations.get(d.id)?.has(stateHash(d));
283
+ if (!direct && !linked)
284
+ continue;
277
285
  const scope = admit("derived", d);
278
286
  if (!scope)
279
287
  continue;
280
288
  if (request.observed_page && scopePath(scope) !== scopePath(request.scope))
281
289
  continue;
282
- const direct = isSubject(d.subject) || d.id === subject;
283
- const linked = scopePath(scope) === scopePath(request.scope) && linkedObservations.get(d.id)?.has(stateHash(d));
284
- if (!direct && !linked)
285
- continue;
286
290
  if (!direct) {
287
291
  if (d.state === "unknown" && d.valid_to == null)
288
292
  observations.push(d);
@@ -467,6 +471,16 @@ function subjectOf(facet, record) {
467
471
  default: return undefined;
468
472
  }
469
473
  }
474
+ /** The published `ChangeEvent.subject` bound. Record subjects can be longer (a receipt's
475
+ * `object_type:object_key`, an entity id, a relationship endpoint, a decision topic); the record
476
+ * limits are never lowered and the event limit is never raised (dec_a9bfb5dd8d). */
477
+ const EVENT_SUBJECT_MAX = 512;
478
+ /** The subject an EVENT carries: the record's subject when it fits the event limit, otherwise
479
+ * omitted (`subject` is optional). A subscriber still matches the event by `record_id`. */
480
+ function eventSubjectOf(facet, record) {
481
+ const subject = subjectOf(facet, record);
482
+ return subject !== undefined && subject.length <= EVENT_SUBJECT_MAX ? subject : undefined;
483
+ }
470
484
  /** Which facet a record id belongs to, from its prefix; `null` for a kind-qualified entity id
471
485
  * or an unknown shape (those are looked up across every facet). */
472
486
  function facetOfId(id) {
@@ -607,6 +621,29 @@ function closeWindow(store, facet, incumbentId, byId, at, isPrivate) {
607
621
  store.putCapture(facet, closed, isPrivate);
608
622
  return true;
609
623
  }
624
+ /** Validate pending change events against the published event schema without writing anything,
625
+ * so a write whose event could not be appended is refused before its record lands (#283). */
626
+ function assertEventsValid(ledger, scope, at, changes) {
627
+ changes.forEach((change, i) => {
628
+ const parsed = ChangeEventSchema.safeParse({ schema: STATE_SUBSCRIBE_VERSION, seq: ledger.head_seq + i + 1, at, scope, ...change });
629
+ if (!parsed.success)
630
+ throw new StateRefusal("malformed", `change event for ${change.record_id} is malformed: ${parsed.error.issues.map((x) => `${x.path.join(".") || "event"}: ${x.message}`).join("; ")}`);
631
+ });
632
+ }
633
+ /** The event a replayed write owes the ledger (#282): a state-only record on file that the ledger
634
+ * has never seen — no event names it and no idempotency entry references it (compaction drops
635
+ * events but keeps the table, so an entry means "known") — was written without its event, by a
636
+ * crash between put and append or a refusal after the put. The retry appends it with the hash on
637
+ * file. A record the ledger already knows gets no new event. */
638
+ function missingEventFor(ledger, facet, onFile, principal) {
639
+ if (!STATE_ONLY_FACETS.includes(facet))
640
+ return [];
641
+ const id = String(onFile.id);
642
+ if (latestSeqFor(ledger, id) > 0 || Object.values(ledger.idempotency).some((e) => e.record_id === id))
643
+ return [];
644
+ const invalidates = facet === "receipts" && Array.isArray(onFile.invalidates) ? onFile.invalidates : [];
645
+ return [{ ...(onFile.visibility ? { visibility: onFile.visibility } : {}), facet, record_id: id, record_hash: stateHash(onFile), change: "created", subject: eventSubjectOf(facet, onFile), invalidates, cause: { kind: "write", principal } }];
646
+ }
610
647
  /** Normalize + validate the record for its facet; enforce the identity rule (an id, when
611
648
  * given, must be the one the record's facts derive). Returns the canonical record. */
612
649
  function normalizeRecord(facet, scope, raw, principal) {
@@ -691,7 +728,17 @@ function writeStateAuthorized(store, input, opts, access) {
691
728
  catch (e) {
692
729
  throw new StateRefusal(/grants/.test(e.message) ? "outside-grants" : "malformed", e.message);
693
730
  }
731
+ const repo = partitionOf(store);
732
+ // Legacy kinds carry no partition scope: whatever home they land in, every reader (and the
733
+ // repository's edit gate) treats them as the store's OWN partition. Written under any other
734
+ // scope they would silently become that partition's records, so they are refused instead.
735
+ if (LEGACY_FACETS.has(request.facet) && scopePath(request.scope) !== scopePath(repo)) {
736
+ throw new StateRefusal("unsupported", `${request.facet} records belong to this store's own partition ${scopePath(repo)}; they cannot be written under ${scopePath(request.scope)} — use a partition-scoped facet (conventions, receipts, commitments, derived, entities, relationships) there`);
737
+ }
694
738
  const { home, hunchDir, isPrivate } = stateHomeFor(store, request.scope);
739
+ /** Several partitions share one overlay home: a record counts for this write only when it is
740
+ * in the write's own partition. */
741
+ const inScope = (r) => scopePath(recordScope(r, repo)) === scopePath(request.scope);
695
742
  const now = (opts.now ?? (() => new Date()))().toISOString();
696
743
  const facet = request.facet;
697
744
  const getHere = (id) => facet === "derived" || facet === "receipts" || facet === "commitments"
@@ -701,9 +748,40 @@ function writeStateAuthorized(store, input, opts, access) {
701
748
  throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
702
749
  const record = normalizeRecord(facet, request.scope, request.record, request.principal);
703
750
  const deny = () => { throw new StateRefusal('outside-grants', 'record unavailable or operation not permitted'); };
751
+ const id = record.id;
752
+ /** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
753
+ const hash = stateHash(record);
754
+ const ledger = opts.ledgerCache?.ledger ?? readLedger(hunchDir, request.scope);
755
+ if (opts.ledgerCache)
756
+ opts.ledgerCache.ledger = ledger;
757
+ const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
758
+ /** The result reports the record ON FILE and its hash — the store may enrich a record on put
759
+ * (a private-mode decision gains `valid_from`), and a writer that goes on to rest a receipt
760
+ * on this record must hold the hash a reader will verify, never a pre-store one. */
761
+ const result = (outcome, conflict = null, rid = id) => {
762
+ const onFile = getHere(rid) ?? record;
763
+ return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: stateHash(onFile), durability: durability(), outcome, conflict, record: onFile });
764
+ };
765
+ // Idempotency, exact replay FIRST: the same key with the same payload for the same record is
766
+ // the retry of a request that already succeeded, and returns what it wrote — before any check
767
+ // that depends on state changed since (an entity now claiming the subject, a visibility or link
768
+ // change). Otherwise a writer whose response was lost is refused, re-derives, and duplicates
769
+ // the record it already holds (#284). A differing payload under the key falls through to every
770
+ // check and the idempotency refusal below.
771
+ const seen = ledger.idempotency[request.idempotency_key];
772
+ if (seen && seen.record_id === id && (seen.record_hash === hash || seen.payload_hash === hash)) {
773
+ const prior = findRecord(store, seen.record_id)?.record;
774
+ if (prior && !access.canRead(prior))
775
+ deny();
776
+ return result("replayed");
777
+ }
704
778
  const incumbent = getHere(String(record.id));
705
779
  if (incumbent && !access.canWrite(incumbent))
706
780
  deny();
781
+ // An id not derived from its scope (an entity, a relationship) can collide with another
782
+ // partition's record in a shared home: that record is never rewritten from this partition.
783
+ if (incumbent && !inScope(incumbent))
784
+ throw new StateRefusal("conflict", `${id} is on record in ${scopePath(recordScope(incumbent, repo))}, not ${scopePath(request.scope)}: a write never moves or rewrites another partition's record`, { incumbent_id: id, reason: "record id held by another partition" });
707
785
  const previousVisibility = incumbent?.visibility;
708
786
  const nextVisibility = record.visibility;
709
787
  if (nextVisibility && home === 'private')
@@ -713,6 +791,10 @@ function writeStateAuthorized(store, input, opts, access) {
713
791
  if (supersededRecord) {
714
792
  if (!access.canWrite(supersededRecord))
715
793
  deny();
794
+ // Supersession stays inside one partition: closing another partition's record from here would
795
+ // land its `superseded` event in THIS ledger, invisible to that partition's subscribers.
796
+ if (!inScope(supersededRecord))
797
+ throw new StateRefusal("conflict", `supersedes ${request.supersedes} is on record in ${scopePath(recordScope(supersededRecord, repo))}, not ${scopePath(request.scope)}: a write supersedes only a record in its own partition`, { incumbent_id: String(supersededRecord.id), reason: "supersede target in another partition" });
716
798
  if (stateHash(supersededVisibility ?? null) !== stateHash(nextVisibility ?? null)) {
717
799
  if (supersededVisibility ? supersededVisibility.owner !== request.principal.id : request.principal.kind !== 'human')
718
800
  deny();
@@ -761,30 +843,12 @@ function writeStateAuthorized(store, input, opts, access) {
761
843
  replayLink = prior;
762
844
  }
763
845
  }
764
- const id = record.id;
765
846
  assertExternalIdentity(store, request.principal, request.scope, facet, record);
766
- /** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
767
- const hash = stateHash(record);
768
- const ledger = opts.ledgerCache?.ledger ?? readLedger(hunchDir, request.scope);
769
- if (opts.ledgerCache)
770
- opts.ledgerCache.ledger = ledger;
771
- const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
772
- /** The result reports the record ON FILE and its hash — the store may enrich a record on put
773
- * (a private-mode decision gains `valid_from`), and a writer that goes on to rest a receipt
774
- * on this record must hold the hash a reader will verify, never a pre-store one. */
775
- const result = (outcome, conflict = null, rid = id) => {
776
- const onFile = getHere(rid) ?? record;
777
- return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: stateHash(onFile), durability: durability(), outcome, conflict, record: onFile });
778
- };
779
- // Idempotency: the same key replays the original; the same key with a different payload
780
- // is a refusal, never a second record.
781
- const seen = ledger.idempotency[request.idempotency_key];
847
+ // Idempotency: the same key with a different payload is a refusal, never a second record.
782
848
  if (seen) {
783
849
  const prior = findRecord(store, seen.record_id)?.record;
784
850
  if (prior && !access.canRead(prior))
785
851
  deny();
786
- if (seen.record_id === id && (seen.record_hash === hash || seen.payload_hash === hash))
787
- return result("replayed");
788
852
  // Say WHAT differs and what to do: a stable key with a varying payload (a timestamp, new
789
853
  // wording) is the trap every writer falls into once; the refusal must teach the way out.
790
854
  const stored = store.getRec(facet, seen.record_id);
@@ -799,7 +863,7 @@ function writeStateAuthorized(store, input, opts, access) {
799
863
  throw new StateRefusal('malformed', 'reviewer must be the initiating principal');
800
864
  }
801
865
  if (existing && stateHash(existing) === hash) {
802
- appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
866
+ appendChanges(hunchDir, request.scope, missingEventFor(ledger, facet, existing, request.principal.id), { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
803
867
  return result("replayed");
804
868
  }
805
869
  if (existing && request.expected_version !== null) {
@@ -811,7 +875,7 @@ function writeStateAuthorized(store, input, opts, access) {
811
875
  }
812
876
  if (replayLink) {
813
877
  const recordHash = stateHash(replayLink);
814
- appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: replayLink.id, record_hash: recordHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
878
+ appendChanges(hunchDir, request.scope, missingEventFor(ledger, facet, replayLink, request.principal.id), { key: request.idempotency_key, entry: { record_id: replayLink.id, record_hash: recordHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
815
879
  return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: replayLink.id, record_hash: recordHash, record: replayLink, outcome: "replayed", conflict: null, durability: "local" });
816
880
  }
817
881
  // human-correction-outranks-agent-writes: what a human confirmed, an agent does not rewrite.
@@ -841,7 +905,7 @@ function writeStateAuthorized(store, input, opts, access) {
841
905
  };
842
906
  const verdict = guard(existing, "overwrite");
843
907
  if (verdict === "replay") {
844
- appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
908
+ appendChanges(hunchDir, request.scope, missingEventFor(ledger, facet, existing, request.principal.id), { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
845
909
  return result("replayed");
846
910
  }
847
911
  if (verdict === "keep-provenance")
@@ -878,7 +942,7 @@ function writeStateAuthorized(store, input, opts, access) {
878
942
  }
879
943
  }
880
944
  }
881
- if (supersedes && !store.recsInHome(facet, home).some((r) => r.id === supersedes)) {
945
+ if (supersedes && !store.recsInHome(facet, home).some((r) => r.id === supersedes && inScope(r))) {
882
946
  throw new StateRefusal("conflict", `supersedes ${supersedes} is not a ${facet} record in this partition`, { incumbent_id: supersedes, reason: "supersede target absent" });
883
947
  }
884
948
  if (supersedes === id)
@@ -892,7 +956,7 @@ function writeStateAuthorized(store, input, opts, access) {
892
956
  if (incumbent && "valid_to" in incumbent && incumbent.valid_to !== null) {
893
957
  const subject = subjectOf(facet, incumbent);
894
958
  const open = store.recsInHome(facet, home)
895
- .filter((r) => subjectOf(facet, r) === subject && r.valid_to === null)
959
+ .filter((r) => inScope(r) && subjectOf(facet, r) === subject && r.valid_to === null)
896
960
  .map((r) => r.id).sort();
897
961
  if (!open.includes(id)) {
898
962
  const current = open.length ? `the current ${facet} record for ${subject ?? "that subject"} is ${open.join(", ")}` : `no ${facet} record for ${subject ?? "that subject"} is open now`;
@@ -911,7 +975,7 @@ function writeStateAuthorized(store, input, opts, access) {
911
975
  const d = record;
912
976
  const incumbent = store.recsInHome("derived", home).find((r) => {
913
977
  const x = r;
914
- return x.id !== id && x.id !== supersedes && x.subject === d.subject && x.transform_version === d.transform_version && x.state === "current" && x.valid_to === null;
978
+ return x.id !== id && x.id !== supersedes && inScope(x) && x.subject === d.subject && x.transform_version === d.transform_version && x.state === "current" && x.valid_to === null;
915
979
  });
916
980
  if (incumbent) {
917
981
  throw new StateRefusal("conflict", `${d.subject} already has a current ${d.transform_version} statement ${incumbent.id}; pass supersedes: "${incumbent.id}" to replace it, or write that identity to update it`, { incumbent_id: incumbent.id, reason: "one-current-derived-per-subject-transform" });
@@ -922,6 +986,21 @@ function writeStateAuthorized(store, input, opts, access) {
922
986
  if (facet === "receipts")
923
987
  assertRestsOn(store, request.principal, request.scope, record.rests_on ?? []);
924
988
  const closedBy = facet === "commitments" ? assertClosedBy(store, request.principal, record) : null;
989
+ // Build and validate every change event BEFORE anything is written (#283): a refusal must never
990
+ // leave a record on file without its event. Only the hashes are filled in after the write (the
991
+ // store may enrich a record on put), and a sha256 always satisfies the event schema.
992
+ const cause = closedBy ? { kind: "receipt", receipt_id: closedBy } : request.cause ?? { kind: "write", principal: request.principal.id };
993
+ // A current derived statement written back as stale is an INVALIDATION, not an update: the
994
+ // ledger says so, and names the external pointer that moved when the writer gives one.
995
+ const invalidated = facet === "derived" && !!existing && (existing.state === "current" || existing.state === "unknown") && record.state === "stale";
996
+ const invalidates = facet === "receipts" ? record.invalidates : [];
997
+ // An entity leaving service is a `retired` change (a merge names the survivor in the record).
998
+ const retired = (facet === "entities" || facet === "relationships") && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
999
+ const subject = eventSubjectOf(facet, record);
1000
+ const supersededPrior = supersedes ? store.getRec(facet, supersedes) : undefined;
1001
+ const supersededChange = (old) => ({ ...(old.visibility ? { visibility: old.visibility } : {}), facet, record_id: String(old.id), record_hash: stateHash(old), change: "superseded", subject: eventSubjectOf(facet, old), invalidates: [], cause });
1002
+ const ownChange = (recordHash) => ({ ...(nextVisibility ? { visibility: nextVisibility } : {}), facet, record_id: id, record_hash: recordHash, change: invalidated ? "invalidated" : retired ? "retired" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
1003
+ assertEventsValid(ledger, request.scope, now, [...(supersededPrior ? [supersededChange(supersededPrior)] : []), ownChange(hash)]);
925
1004
  if (nextVisibility) {
926
1005
  // Publish the fail-closed old-reader gate BEFORE protected bytes. An interrupted
927
1006
  // write can leave a gate without a record, never a record without the gate.
@@ -932,22 +1011,12 @@ function writeStateAuthorized(store, input, opts, access) {
932
1011
  /** What is on file now — the hash every event, ref and result carries. */
933
1012
  const onFileHash = stateHash(getHere(id) ?? record);
934
1013
  const changes = [];
935
- const cause = closedBy ? { kind: "receipt", receipt_id: closedBy } : request.cause ?? { kind: "write", principal: request.principal.id };
936
- // A current derived statement written back as stale is an INVALIDATION, not an update: the
937
- // ledger says so, and names the external pointer that moved when the writer gives one.
938
- const invalidated = facet === "derived" && !!existing && (existing.state === "current" || existing.state === "unknown") && record.state === "stale";
939
- const invalidates = facet === "receipts" ? record.invalidates : [];
940
- // An entity leaving service is a `retired` change (a merge names the survivor in the record).
941
- const retired = (facet === "entities" || facet === "relationships") && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
942
- const subject = subjectOf(facet, record);
943
1014
  if (supersedes) {
944
1015
  const closed = closeWindow(store, facet, supersedes, id, now, isPrivate);
945
- if (closed) {
946
- const old = store.getRec(facet, supersedes);
947
- changes.push({ ...("visibility" in old && old.visibility ? { visibility: old.visibility } : {}), facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
948
- }
1016
+ if (closed)
1017
+ changes.push(supersededChange(store.getRec(facet, supersedes)));
949
1018
  }
950
- changes.push({ ...(nextVisibility ? { visibility: nextVisibility } : {}), facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : retired ? "retired" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
1019
+ changes.push(ownChange(onFileHash));
951
1020
  appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
952
1021
  if (!opts.deferReindex)
953
1022
  store.reindex();
@@ -146,7 +146,14 @@ export interface NowItem {
146
146
  date: string;
147
147
  /** decision text for recent items; CONTEXT (the why-it's-planned) for roadmap items. */
148
148
  note: string;
149
+ /** Roadmap only: true when the item is agent testimony no human has confirmed yet.
150
+ * Omitted (never false) otherwise, so pages rendered before this field stay fresh. */
151
+ unconfirmed?: true;
149
152
  }
153
+ /** Marker for an unconfirmed roadmap item, naming the human countersign command. */
154
+ export declare function unconfirmedRoadmapMarker(item: Pick<NowItem, "id" | "unconfirmed">, opts?: {
155
+ private?: boolean;
156
+ }): string;
150
157
  /** The hot view's inputs: last `recentLimit` decisions by date (any status — a
151
158
  * supersession IS activity), and every live PROPOSED decision (the roadmap:
152
159
  * record intent as a proposed decision; accepting or superseding it removes it
package/dist/wiki/wiki.js CHANGED
@@ -33,6 +33,7 @@ import { writeFileAtomic } from "../core/io.js";
33
33
  import { compareCodeUnits } from "../core/canonicalOrder.js";
34
34
  import { hunchPaths, toPosixTarget } from "../core/paths.js";
35
35
  import { isLive } from "../core/topics.js";
36
+ import { confirmCommand, isAgentTestimony } from "../core/countersign.js";
36
37
  import { scanRepoDocs } from "../core/docscan.js";
37
38
  import { adoptedSlug, adoptionHash, renderAdoptedDoc } from "./adopt.js";
38
39
  import { assembleGraphData, renderGraphPage } from "./graph.js";
@@ -396,6 +397,10 @@ const NOW_ID = "_now";
396
397
  const GRAPH_ID = "_graph";
397
398
  /** Manifest component-id prefix for adopted (wiki-managed) doc copies. */
398
399
  const ADOPTED_PREFIX = "doc:";
400
+ /** Marker for an unconfirmed roadmap item, naming the human countersign command. */
401
+ export function unconfirmedRoadmapMarker(item, opts = {}) {
402
+ return item.unconfirmed ? `⚠ unconfirmed agent testimony — confirm: ${confirmCommand(item.id, opts)}` : "";
403
+ }
399
404
  /** The hot view's inputs: last `recentLimit` decisions by date (any status — a
400
405
  * supersession IS activity), and every live PROPOSED decision (the roadmap:
401
406
  * record intent as a proposed decision; accepting or superseding it removes it
@@ -405,15 +410,21 @@ export function nowData(decisions, recentLimit = 10) {
405
410
  const byDateDesc = (a, b) => compareCodeUnits(b.valid_from ?? b.date, a.valid_from ?? a.date) || compareCodeUnits(a.id, b.id);
406
411
  const recent = [...decisions].sort(byDateDesc).slice(0, recentLimit)
407
412
  .map((d) => ({ id: d.id, topic: d.topic, title: d.title, status: d.status, date: (d.valid_from ?? d.date).slice(0, 10), note: clip1(d.decision) }));
408
- // Roadmap = INTENT the human vouched for. Auto-synthesized drafts are also
409
- // status "proposed", but they describe work already done and belong to the
410
- // review queue (`hunch review`) — surfacing them here would bury real plans.
413
+ // Roadmap = deliberately recorded INTENT: human-vouched, or recorded by an agent
414
+ // (hunch_record_decision, e.g. at the end of /capture) and not yet confirmed. The latter
415
+ // is shown MARKED with the human confirm command — never hidden, and never routed to
416
+ // `adopt-drafts` (which would accept it). Auto-synthesized drafts are also status
417
+ // "proposed", but they describe work already done and belong to the review queue
418
+ // (`hunch review`) — surfacing them here would bury real plans.
411
419
  const live = decisions.filter((d) => d.status === "proposed" && !d.superseded_by && !d.valid_to);
412
- const vouched = live.filter((d) => d.provenance.source.includes("human_confirmed"));
413
- const roadmap = vouched
420
+ const intent = live.filter((d) => d.provenance.source.includes("human_confirmed") || isAgentTestimony(d.provenance.source));
421
+ const roadmap = intent
414
422
  .sort(byDateDesc)
415
- .map((d) => ({ id: d.id, topic: d.topic, title: d.title, status: d.status, date: (d.valid_from ?? d.date).slice(0, 10), note: clip1(d.context || d.decision) }));
416
- return { recent, roadmap, pendingReview: live.length - vouched.length };
423
+ .map((d) => ({
424
+ id: d.id, topic: d.topic, title: d.title, status: d.status, date: (d.valid_from ?? d.date).slice(0, 10), note: clip1(d.context || d.decision),
425
+ ...(isAgentTestimony(d.provenance.source) ? { unconfirmed: true } : {}),
426
+ }));
427
+ return { recent, roadmap, pendingReview: live.length - intent.length };
417
428
  }
418
429
  /** The hot file — a DERIVED view like every other page: what just happened
419
430
  * (last N decisions) and what's next (live proposed decisions). No topic pins
@@ -436,7 +447,7 @@ export function renderNowPage(recent, roadmap, home, pendingReview = 0) {
436
447
  if (!roadmap.length)
437
448
  L.push("_Empty. Record what's next as a PROPOSED decision (`/capture`, status: proposed) and it appears here._", "");
438
449
  for (const r of roadmap)
439
- L.push(`- **${r.title}** — ${r.note || "(no context)"} _(${r.id}${r.topic ? `, topic \`${r.topic}\`` : ""}, since ${r.date})_`);
450
+ L.push(`- **${r.title}** — ${r.note || "(no context)"} _(${r.id}${r.topic ? `, topic \`${r.topic}\`` : ""}, since ${r.date})_${r.unconfirmed ? ` — ${unconfirmedRoadmapMarker(r, { private: home.kind === "private" })}` : ""}`);
440
451
  if (roadmap.length)
441
452
  L.push("");
442
453
  if (pendingReview > 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.39.0",
3
+ "version": "1.39.2",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.39.0",
10
+ "version": "1.39.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.39.0",
16
+ "version": "1.39.2",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {