@davesheffer/hunch 1.32.8 → 1.33.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 (56) hide show
  1. package/README.md +9 -3
  2. package/dist/cli/index.js +2 -0
  3. package/dist/cli/serve.js +28 -2
  4. package/dist/cli/state.d.ts +3 -0
  5. package/dist/cli/state.js +150 -0
  6. package/dist/client/state.d.ts +82 -14
  7. package/dist/client/state.js +16 -2
  8. package/dist/client/stateProof.d.ts +4 -0
  9. package/dist/client/stateProof.js +17 -0
  10. package/dist/constitution/behaviorEvaluator.js +1 -1
  11. package/dist/constitution/schema.d.ts +2 -2
  12. package/dist/core/automaticReviewMemory.d.ts +5 -0
  13. package/dist/core/conventionDelivery.d.ts +8 -0
  14. package/dist/core/conventionDelivery.js +52 -0
  15. package/dist/core/fieldProvenance.d.ts +8 -0
  16. package/dist/core/fieldProvenance.js +72 -0
  17. package/dist/core/recordVisibility.d.ts +9 -0
  18. package/dist/core/recordVisibility.js +25 -0
  19. package/dist/core/stateCanonical.d.ts +3 -0
  20. package/dist/core/stateCanonical.js +34 -0
  21. package/dist/core/stateContract.d.ts +122 -7
  22. package/dist/core/stateContract.js +26 -31
  23. package/dist/core/stateDelivery.d.ts +3 -3
  24. package/dist/core/stateDelivery.js +10 -1
  25. package/dist/core/stateHttp.d.ts +280 -0
  26. package/dist/core/stateHttp.js +17 -0
  27. package/dist/core/stateProof.d.ts +13 -0
  28. package/dist/core/stateProof.js +34 -0
  29. package/dist/core/stateRecords.d.ts +127 -0
  30. package/dist/core/stateRecords.js +48 -0
  31. package/dist/core/types.d.ts +146 -4
  32. package/dist/core/types.js +8 -2
  33. package/dist/extractors/git.js +3 -10
  34. package/dist/mcp/server.js +10 -4
  35. package/dist/serve/app.d.ts +2 -0
  36. package/dist/serve/app.js +71 -30
  37. package/dist/serve/config.d.ts +16 -0
  38. package/dist/serve/config.js +27 -7
  39. package/dist/serve/operator.d.ts +4 -0
  40. package/dist/serve/operator.js +223 -0
  41. package/dist/serve/stateProof.d.ts +15 -0
  42. package/dist/serve/stateProof.js +105 -0
  43. package/dist/store/changeLedger.d.ts +6 -0
  44. package/dist/store/hunchStore.d.ts +4 -2
  45. package/dist/store/hunchStore.js +18 -19
  46. package/dist/store/stateAccess.d.ts +13 -0
  47. package/dist/store/stateAccess.js +85 -0
  48. package/dist/store/stateBinding.d.ts +13 -18
  49. package/dist/store/stateBinding.js +161 -52
  50. package/dist/store/stateCapture.js +10 -2
  51. package/dist/store/stateError.d.ts +12 -0
  52. package/dist/store/stateError.js +12 -0
  53. package/dist/store/statePartition.d.ts +9 -0
  54. package/dist/store/statePartition.js +30 -0
  55. package/package.json +5 -1
  56. package/server.json +2 -2
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { z } from "zod";
9
9
  import { ProvenanceSchema, isCredentialFreeText, type Provenance } from "./provenance.js";
10
- import { type ActionReceipt, type Commitment, type DerivedState, type ExternalEntity, type StateRelationship } from "./stateRecords.js";
10
+ import { type Convention, type ActionReceipt, type Commitment, type DerivedState, type ExternalEntity, type StateRelationship } from "./stateRecords.js";
11
11
  export { ProvenanceSchema, isCredentialFreeText };
12
12
  export type { Provenance };
13
13
  export declare const ComponentKind: z.ZodEnum<{
@@ -335,6 +335,11 @@ export declare const PremiseSchema: z.ZodObject<{
335
335
  }, z.core.$strip>;
336
336
  export type Premise = z.infer<typeof PremiseSchema>;
337
337
  export declare const DecisionSchema: z.ZodObject<{
338
+ visibility: z.ZodOptional<z.ZodObject<{
339
+ owner: z.ZodString;
340
+ readers: z.ZodArray<z.ZodString>;
341
+ writers: z.ZodArray<z.ZodString>;
342
+ }, z.core.$strict>>;
338
343
  id: z.ZodString;
339
344
  title: z.ZodString;
340
345
  topic: z.ZodDefault<z.ZodNullable<z.ZodString>>;
@@ -416,6 +421,11 @@ export declare const BugLineageSchema: z.ZodObject<{
416
421
  export type BugLineage = z.infer<typeof BugLineageSchema>;
417
422
  /** A bug with root cause and lineage (introduced → fixed → recurred). */
418
423
  export declare const BugSchema: z.ZodObject<{
424
+ visibility: z.ZodOptional<z.ZodObject<{
425
+ owner: z.ZodString;
426
+ readers: z.ZodArray<z.ZodString>;
427
+ writers: z.ZodArray<z.ZodString>;
428
+ }, z.core.$strict>>;
419
429
  id: z.ZodString;
420
430
  title: z.ZodString;
421
431
  symptom: z.ZodDefault<z.ZodString>;
@@ -452,6 +462,11 @@ export declare const BugSchema: z.ZodObject<{
452
462
  export type Bug = z.infer<typeof BugSchema>;
453
463
  /** An invariant the system must respect. */
454
464
  export declare const ConstraintSchema: z.ZodObject<{
465
+ visibility: z.ZodOptional<z.ZodObject<{
466
+ owner: z.ZodString;
467
+ readers: z.ZodArray<z.ZodString>;
468
+ writers: z.ZodArray<z.ZodString>;
469
+ }, z.core.$strict>>;
455
470
  id: z.ZodString;
456
471
  type: z.ZodDefault<z.ZodEnum<{
457
472
  security: "security";
@@ -526,6 +541,11 @@ export type Runbook = z.infer<typeof RunbookSchema>;
526
541
  * never enters any block path. Lifecycle is `triage`, not valid-time: a finding is
527
542
  * resolved/stale-marked, never superseded. */
528
543
  export declare const FindingSchema: z.ZodObject<{
544
+ visibility: z.ZodOptional<z.ZodObject<{
545
+ owner: z.ZodString;
546
+ readers: z.ZodArray<z.ZodString>;
547
+ writers: z.ZodArray<z.ZodString>;
548
+ }, z.core.$strict>>;
529
549
  id: z.ZodString;
530
550
  title: z.ZodString;
531
551
  observation: z.ZodDefault<z.ZodString>;
@@ -538,8 +558,8 @@ export declare const FindingSchema: z.ZodObject<{
538
558
  critical: "critical";
539
559
  }>>;
540
560
  triage: z.ZodDefault<z.ZodEnum<{
541
- open: "open";
542
561
  stale: "stale";
562
+ open: "open";
543
563
  "accepted-risk": "accepted-risk";
544
564
  scheduled: "scheduled";
545
565
  resolved: "resolved";
@@ -590,7 +610,7 @@ export declare function assertLandscapeDriftCandidate(value: unknown): asserts v
590
610
  /** Convert one valid external observation into advisory Hunch memory, never graph authority. */
591
611
  export declare function landscapeDriftCandidateFinding(value: unknown): Finding;
592
612
  /** The entity collections, keyed by their on-disk directory name. */
593
- export declare const ENTITY_KINDS: readonly ["components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings", "receipts", "commitments", "derived", "entities", "relationships"];
613
+ export declare const ENTITY_KINDS: readonly ["components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings", "receipts", "commitments", "derived", "entities", "relationships", "conventions"];
594
614
  export type EntityKind = (typeof ENTITY_KINDS)[number];
595
615
  export declare const SCHEMAS: {
596
616
  readonly components: z.ZodObject<{
@@ -753,6 +773,11 @@ export declare const SCHEMAS: {
753
773
  last_changed: z.ZodDefault<z.ZodString>;
754
774
  }, z.core.$strip>;
755
775
  readonly decisions: z.ZodObject<{
776
+ visibility: z.ZodOptional<z.ZodObject<{
777
+ owner: z.ZodString;
778
+ readers: z.ZodArray<z.ZodString>;
779
+ writers: z.ZodArray<z.ZodString>;
780
+ }, z.core.$strict>>;
756
781
  id: z.ZodString;
757
782
  title: z.ZodString;
758
783
  topic: z.ZodDefault<z.ZodNullable<z.ZodString>>;
@@ -823,6 +848,11 @@ export declare const SCHEMAS: {
823
848
  date: z.ZodString;
824
849
  }, z.core.$strip>;
825
850
  readonly bugs: z.ZodObject<{
851
+ visibility: z.ZodOptional<z.ZodObject<{
852
+ owner: z.ZodString;
853
+ readers: z.ZodArray<z.ZodString>;
854
+ writers: z.ZodArray<z.ZodString>;
855
+ }, z.core.$strict>>;
826
856
  id: z.ZodString;
827
857
  title: z.ZodString;
828
858
  symptom: z.ZodDefault<z.ZodString>;
@@ -857,6 +887,11 @@ export declare const SCHEMAS: {
857
887
  }, z.core.$strip>;
858
888
  }, z.core.$strip>;
859
889
  readonly constraints: z.ZodObject<{
890
+ visibility: z.ZodOptional<z.ZodObject<{
891
+ owner: z.ZodString;
892
+ readers: z.ZodArray<z.ZodString>;
893
+ writers: z.ZodArray<z.ZodString>;
894
+ }, z.core.$strict>>;
860
895
  id: z.ZodString;
861
896
  type: z.ZodDefault<z.ZodEnum<{
862
897
  security: "security";
@@ -919,6 +954,11 @@ export declare const SCHEMAS: {
919
954
  date: z.ZodString;
920
955
  }, z.core.$strip>;
921
956
  readonly findings: z.ZodObject<{
957
+ visibility: z.ZodOptional<z.ZodObject<{
958
+ owner: z.ZodString;
959
+ readers: z.ZodArray<z.ZodString>;
960
+ writers: z.ZodArray<z.ZodString>;
961
+ }, z.core.$strict>>;
922
962
  id: z.ZodString;
923
963
  title: z.ZodString;
924
964
  observation: z.ZodDefault<z.ZodString>;
@@ -931,8 +971,8 @@ export declare const SCHEMAS: {
931
971
  critical: "critical";
932
972
  }>>;
933
973
  triage: z.ZodDefault<z.ZodEnum<{
934
- open: "open";
935
974
  stale: "stale";
975
+ open: "open";
936
976
  "accepted-risk": "accepted-risk";
937
977
  scheduled: "scheduled";
938
978
  resolved: "resolved";
@@ -950,7 +990,76 @@ export declare const SCHEMAS: {
950
990
  last_verified: z.ZodOptional<z.ZodString>;
951
991
  }, z.core.$strip>;
952
992
  }, z.core.$strip>;
993
+ readonly conventions: z.ZodObject<{
994
+ schema: z.ZodLiteral<"nuryel.convention/1">;
995
+ id: z.ZodString;
996
+ scope: z.ZodObject<{
997
+ kind: z.ZodEnum<{
998
+ repository: "repository";
999
+ organization: "organization";
1000
+ team: "team";
1001
+ user: "user";
1002
+ }>;
1003
+ id: z.ZodString;
1004
+ }, z.core.$strict>;
1005
+ key: z.ZodString;
1006
+ value: z.ZodString;
1007
+ status: z.ZodEnum<{
1008
+ stale: "stale";
1009
+ proposed: "proposed";
1010
+ accepted: "accepted";
1011
+ withdrawn: "withdrawn";
1012
+ }>;
1013
+ sources: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
1014
+ kind: z.ZodLiteral<"record">;
1015
+ id: z.ZodString;
1016
+ record_hash: z.ZodString;
1017
+ scope: z.ZodOptional<z.ZodObject<{
1018
+ kind: z.ZodEnum<{
1019
+ repository: "repository";
1020
+ organization: "organization";
1021
+ team: "team";
1022
+ user: "user";
1023
+ }>;
1024
+ id: z.ZodString;
1025
+ }, z.core.$strict>>;
1026
+ }, z.core.$strict>, z.ZodObject<{
1027
+ kind: z.ZodLiteral<"external">;
1028
+ ref: z.ZodObject<{
1029
+ system: z.ZodString;
1030
+ object_type: z.ZodString;
1031
+ object_key: z.ZodString;
1032
+ version: z.ZodOptional<z.ZodString>;
1033
+ content_hash: z.ZodOptional<z.ZodString>;
1034
+ observed_at: z.ZodString;
1035
+ locator: z.ZodOptional<z.ZodString>;
1036
+ }, z.core.$strict>;
1037
+ }, z.core.$strict>, z.ZodObject<{
1038
+ kind: z.ZodLiteral<"schema">;
1039
+ name: z.ZodString;
1040
+ fingerprint: z.ZodString;
1041
+ }, z.core.$strict>], "kind">>;
1042
+ valid_from: z.ZodString;
1043
+ valid_to: z.ZodDefault<z.ZodNullable<z.ZodString>>;
1044
+ review_by: z.ZodString;
1045
+ provenance: z.ZodObject<{
1046
+ source: z.ZodString;
1047
+ confidence: z.ZodNumber;
1048
+ evidence: z.ZodDefault<z.ZodArray<z.ZodString>>;
1049
+ last_verified: z.ZodOptional<z.ZodString>;
1050
+ }, z.core.$strip>;
1051
+ visibility: z.ZodOptional<z.ZodObject<{
1052
+ owner: z.ZodString;
1053
+ readers: z.ZodArray<z.ZodString>;
1054
+ writers: z.ZodArray<z.ZodString>;
1055
+ }, z.core.$strict>>;
1056
+ }, z.core.$strict>;
953
1057
  readonly receipts: z.ZodObject<{
1058
+ visibility: z.ZodOptional<z.ZodObject<{
1059
+ owner: z.ZodString;
1060
+ readers: z.ZodArray<z.ZodString>;
1061
+ writers: z.ZodArray<z.ZodString>;
1062
+ }, z.core.$strict>>;
954
1063
  schema: z.ZodLiteral<"nuryel.receipt/1">;
955
1064
  id: z.ZodString;
956
1065
  scope: z.ZodObject<{
@@ -1023,6 +1132,11 @@ export declare const SCHEMAS: {
1023
1132
  }, z.core.$strip>;
1024
1133
  }, z.core.$strict>;
1025
1134
  readonly commitments: z.ZodObject<{
1135
+ visibility: z.ZodOptional<z.ZodObject<{
1136
+ owner: z.ZodString;
1137
+ readers: z.ZodArray<z.ZodString>;
1138
+ writers: z.ZodArray<z.ZodString>;
1139
+ }, z.core.$strict>>;
1026
1140
  schema: z.ZodLiteral<"nuryel.commitment/1">;
1027
1141
  id: z.ZodString;
1028
1142
  scope: z.ZodObject<{
@@ -1065,6 +1179,11 @@ export declare const SCHEMAS: {
1065
1179
  }, z.core.$strip>;
1066
1180
  }, z.core.$strict>;
1067
1181
  readonly derived: z.ZodObject<{
1182
+ visibility: z.ZodOptional<z.ZodObject<{
1183
+ owner: z.ZodString;
1184
+ readers: z.ZodArray<z.ZodString>;
1185
+ writers: z.ZodArray<z.ZodString>;
1186
+ }, z.core.$strict>>;
1068
1187
  schema: z.ZodLiteral<"nuryel.derived/1">;
1069
1188
  id: z.ZodString;
1070
1189
  scope: z.ZodObject<{
@@ -1108,6 +1227,18 @@ export declare const SCHEMAS: {
1108
1227
  name: z.ZodString;
1109
1228
  fingerprint: z.ZodString;
1110
1229
  }, z.core.$strict>], "kind">>;
1230
+ field_provenance: z.ZodOptional<z.ZodArray<z.ZodObject<{
1231
+ selector: z.ZodDiscriminatedUnion<[z.ZodObject<{
1232
+ kind: z.ZodLiteral<"json_pointer">;
1233
+ path: z.ZodString;
1234
+ }, z.core.$strict>, z.ZodObject<{
1235
+ kind: z.ZodLiteral<"text">;
1236
+ start: z.ZodNumber;
1237
+ end: z.ZodNumber;
1238
+ }, z.core.$strict>], "kind">;
1239
+ value_hash: z.ZodString;
1240
+ dependency_hashes: z.ZodArray<z.ZodString>;
1241
+ }, z.core.$strict>>>;
1111
1242
  transform_version: z.ZodString;
1112
1243
  computed_at: z.ZodString;
1113
1244
  valid_to: z.ZodDefault<z.ZodNullable<z.ZodString>>;
@@ -1142,6 +1273,11 @@ export declare const SCHEMAS: {
1142
1273
  }, z.core.$strip>;
1143
1274
  }, z.core.$strict>;
1144
1275
  readonly entities: z.ZodObject<{
1276
+ visibility: z.ZodOptional<z.ZodObject<{
1277
+ owner: z.ZodString;
1278
+ readers: z.ZodArray<z.ZodString>;
1279
+ writers: z.ZodArray<z.ZodString>;
1280
+ }, z.core.$strict>>;
1145
1281
  schema: z.ZodLiteral<"nuryel.entity/1">;
1146
1282
  id: z.ZodString;
1147
1283
  kind: z.ZodString;
@@ -1181,6 +1317,11 @@ export declare const SCHEMAS: {
1181
1317
  updated_at: z.ZodString;
1182
1318
  }, z.core.$strict>;
1183
1319
  readonly relationships: z.ZodObject<{
1320
+ visibility: z.ZodOptional<z.ZodObject<{
1321
+ owner: z.ZodString;
1322
+ readers: z.ZodArray<z.ZodString>;
1323
+ writers: z.ZodArray<z.ZodString>;
1324
+ }, z.core.$strict>>;
1184
1325
  schema: z.ZodLiteral<"nuryel.relationship/1">;
1185
1326
  id: z.ZodString;
1186
1327
  from: z.ZodString;
@@ -1228,6 +1369,7 @@ export type EntityFor = {
1228
1369
  constraints: Constraint;
1229
1370
  runbooks: Runbook;
1230
1371
  findings: Finding;
1372
+ conventions: Convention;
1231
1373
  receipts: ActionReceipt;
1232
1374
  commitments: Commitment;
1233
1375
  derived: DerivedState;
@@ -1,3 +1,4 @@
1
+ import { RecordVisibilitySchema } from "./recordVisibility.js";
1
2
  /**
2
3
  * Core entity schema for the Project Hunch (DESIGN.md §3).
3
4
  *
@@ -9,7 +10,7 @@ import { z } from "zod";
9
10
  import { createHash } from "node:crypto";
10
11
  import { findingId, resourceId, resourceRelationshipId } from "./ids.js";
11
12
  import { ProvenanceSchema, SENSITIVE_METADATA_KEY, isCredentialFreeText } from "./provenance.js";
12
- import { ActionReceiptSchema, CommitmentSchema, DerivedStateSchema, ExternalEntitySchema, StateRelationshipSchema, } from "./stateRecords.js";
13
+ import { ConventionSchema, ActionReceiptSchema, CommitmentSchema, DerivedStateSchema, ExternalEntitySchema, StateRelationshipSchema, } from "./stateRecords.js";
13
14
  // Provenance and the credential-free text check live in the leaf module ./provenance.js so
14
15
  // record schemas registered below can import them without a cycle; re-exported unchanged.
15
16
  export { ProvenanceSchema, isCredentialFreeText };
@@ -323,6 +324,7 @@ export const PremiseSchema = z.object({
323
324
  path: ["under"],
324
325
  });
325
326
  export const DecisionSchema = z.object({
327
+ visibility: RecordVisibilitySchema.optional(),
326
328
  id: z.string().describe("dec_*"),
327
329
  title: z.string(),
328
330
  // Decision-grounding anchor: the join key that relates a doc section, a decision,
@@ -372,6 +374,7 @@ export const BugLineageSchema = z.object({
372
374
  });
373
375
  /** A bug with root cause and lineage (introduced → fixed → recurred). */
374
376
  export const BugSchema = z.object({
377
+ visibility: RecordVisibilitySchema.optional(),
375
378
  id: z.string().describe("bug_*"),
376
379
  title: z.string(),
377
380
  symptom: z.string().default(""),
@@ -388,6 +391,7 @@ export const BugSchema = z.object({
388
391
  });
389
392
  /** An invariant the system must respect. */
390
393
  export const ConstraintSchema = z.object({
394
+ visibility: RecordVisibilitySchema.optional(),
391
395
  id: z.string().describe("con_*"),
392
396
  type: z.enum(["security", "performance", "correctness", "architecture", "compliance"]).default("correctness"),
393
397
  statement: z.string(),
@@ -448,6 +452,7 @@ export const RunbookSchema = z.object({
448
452
  * never enters any block path. Lifecycle is `triage`, not valid-time: a finding is
449
453
  * resolved/stale-marked, never superseded. */
450
454
  export const FindingSchema = z.object({
455
+ visibility: RecordVisibilitySchema.optional(),
451
456
  id: z.string().describe("fnd_*"),
452
457
  title: z.string(),
453
458
  observation: z.string().default("").describe("what was observed, in plain words"),
@@ -594,7 +599,7 @@ export function landscapeDriftCandidateFinding(value) {
594
599
  // loads exactly as before, and an older build ignores directories it does not know.
595
600
  export const ENTITY_KINDS = [
596
601
  "components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings",
597
- "receipts", "commitments", "derived", "entities", "relationships",
602
+ "receipts", "commitments", "derived", "entities", "relationships", "conventions",
598
603
  ];
599
604
  export const SCHEMAS = {
600
605
  components: ComponentSchema,
@@ -606,6 +611,7 @@ export const SCHEMAS = {
606
611
  constraints: ConstraintSchema,
607
612
  runbooks: RunbookSchema,
608
613
  findings: FindingSchema,
614
+ conventions: ConventionSchema,
609
615
  receipts: ActionReceiptSchema,
610
616
  commitments: CommitmentSchema,
611
617
  derived: DerivedStateSchema,
@@ -1838,16 +1838,9 @@ export function isLinkedWorktree(cwd) {
1838
1838
  const own = gitSafe(["rev-parse", "--absolute-git-dir"], cwd);
1839
1839
  if (!common || !own)
1840
1840
  return false;
1841
- // realpath BOTH before comparing: `--absolute-git-dir` is symlink-resolved while
1842
- // gitCommonDir is not, so on macOS the main checkout would otherwise mismatch on
1843
- // /var vs /private/var and falsely read as "linked".
1844
- const norm = (p) => { try {
1845
- return realpathSync(p);
1846
- }
1847
- catch {
1848
- return resolve(p);
1849
- } };
1850
- return norm(own) !== norm(common);
1841
+ // Git can spell the same directory differently: /var vs /private/var on
1842
+ // macOS, or long vs 8.3/case variants on Windows. Compare physical identity.
1843
+ return !sameFilesystemEntry(own, common);
1851
1844
  }
1852
1845
  /** Current branch name (e.g. "main", "feat/x"), or "" in detached HEAD / non-repo.
1853
1846
  * Stamped onto auto-captured decisions so branch-scoped work stays filterable. */
@@ -1,3 +1,5 @@
1
+ import { conventionSupplements } from '../core/conventionDelivery.js';
2
+ import { fieldCitationText } from "../core/fieldProvenance.js";
1
3
  /**
2
4
  * MCP server — the structured two-way API into the Hunch (DESIGN.md §7 / App. A).
3
5
  * Exposes read tools (query/why/bug_lineage/check_constraints/get_dependents) and
@@ -1102,7 +1104,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1102
1104
  decisionCorpus: store.recs("decisions"),
1103
1105
  historical: !!asOf,
1104
1106
  profile: profile ?? "builder",
1105
- supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding],
1107
+ supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding, ...(asOf ? [] : conventionSupplements(store.recs("conventions")))],
1106
1108
  };
1107
1109
  // Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
1108
1110
  // used to return an empty brief while the graph held the answer — fall back to
@@ -1822,7 +1824,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1822
1824
  const r = (response.records ?? {})[ref.id] ?? {};
1823
1825
  const g = (k) => { const v = r[k]; return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v); };
1824
1826
  if (ref.facet === "derived")
1825
- return `- ${label} derived ${ref.id} · computed ${g("computed_at")} · ${r.dependencies?.length ?? 0} dependencies\n ${g("content").slice(0, 1200)}`;
1827
+ return `- ${label} derived ${ref.id} · computed ${g("computed_at")} · ${r.dependencies?.length ?? 0} dependencies\n ${g("content").slice(0, 1200)}${fieldCitationText(r)}`;
1826
1828
  if (ref.facet === "commitments")
1827
1829
  return `- ${label} commitment ${ref.id} · ${g("status")} · due ${g("due")} · owner ${g("owner")}: ${g("title")}${r.closed_by ? ` · closed by ${g("closed_by")}` : ""}`;
1828
1830
  if (ref.facet === "receipts") {
@@ -1858,7 +1860,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1858
1860
  : sor.observed_truncated ? ['- More observations exist; read this subject with observed_page:{} in one partition, then follow next_cursor.'] : []),
1859
1861
  ...(sor.invalidated_by.length ? [`- invalidated by: ${sor.invalidated_by.join(", ")}`] : [])].join("\n") || "(nothing on record for this subject)"
1860
1862
  : "";
1861
- return stateResult(`${response.receipt_id} · ${summary}${deniedNote}${stateText ? `\n\nState of record:\n${stateText}` : ""}\n\n${envelope.text}`, response);
1863
+ const conventionText = response.conventions ? '\n\nExplicit conventions (advisory; no scope takes precedence):\n' + response.conventions.items.map(item => {
1864
+ const record = response.records?.[item.ref.id];
1865
+ return `- ${item.ref.scope.kind}/${item.ref.scope.id} · ${item.key} · ${record?.status}/${item.currentness}${item.conflict ? ' · CONFLICT' : ''}: ${String(record?.value ?? '').slice(0, 300)} (${item.ref.id})`;
1866
+ }).join('\n') + (response.conventions.truncated ? '\nMore conventions exist; this view is incomplete.' : '') : '';
1867
+ return stateResult(`${response.receipt_id} · ${summary}${deniedNote}${stateText ? `\n\nState of record:\n${stateText}` : ""}\n\n${envelope.text}${conventionText}`, response);
1862
1868
  }
1863
1869
  catch (e) {
1864
1870
  return stateRefusal(e);
@@ -1866,7 +1872,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1866
1872
  });
1867
1873
  server.registerTool("nuryel_write", {
1868
1874
  title: "nuryel.state/1 write — provenance + idempotency in, durability out",
1869
- description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version.",
1875
+ description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version. With capability nuryel.field-provenance/1, a derived record may carry field_provenance: [{selector:{kind:json_pointer,path:/field} or {kind:text,start:0,end:10}, value_hash:stateHash(selected scalar or text), dependency_hashes:[stateHash(existing dependency)]}]. Text offsets count Unicode code points, end exclusive. Citations are writer-supplied traceability, not verified support; negotiate support in every shared reader before writing them. With nuryel.record-visibility/1, records may carry visibility:{owner:principal-id,readers:[ids],writers:[ids]}; the owner is implicit in both lists, and writers must be readers. Owner-only audience changes require expected_version, including supersession. Restricted records require a dedicated partition; local stdio principal assertions assume trusted callers.",
1870
1876
  inputSchema: { ...WriteRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
1871
1877
  outputSchema: WriteResultSchema.shape,
1872
1878
  }, async ({ cwd: _cwd, ...input }) => {
@@ -27,6 +27,8 @@ export declare class HttpProblem extends Error {
27
27
  constructor(status: number, code: string, message: string, extra?: Record<string, unknown>);
28
28
  }
29
29
  export interface ServeOptions {
30
+ /** Required for key-bound credentials when supplying an in-memory configuration. */
31
+ authStateDir?: string;
30
32
  version?: string;
31
33
  /** Injectable for tests: how a partition's store is opened. */
32
34
  openStore?: (root: string) => HunchStore;
package/dist/serve/app.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { resolve } from 'node:path';
2
+ import { StateProofError, verifyStateProof } from './stateProof.js';
3
+ import { STATE_PROOF_CAPABILITY } from '../core/stateProof.js';
1
4
  /**
2
5
  * `hunch serve` — the HTTP binding of nuryel.state/1, and the served product's partition host.
3
6
  *
@@ -21,11 +24,12 @@ import { hunchPaths } from "../core/paths.js";
21
24
  import { flushCapture } from "../integrations/sync.js";
22
25
  import { StateRefusal, capabilities, mergeReadResponses, readState, recordsState, stateHomeFor, subscribeState, writeState } from "../store/stateBinding.js";
23
26
  import { STATE_READ_VERSION, STATE_RECORDS_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadScopesSchema, ScopeSchema, scopePath } from "../core/stateContract.js";
24
- import { partitionFor, resolvePrincipal } from "./config.js";
27
+ import { partitionFor, resolveCredential, readServeConfig } from "./config.js";
25
28
  import { WriteLockTimeout, withWriteLock } from "./writelock.js";
26
29
  import { HUNCH_VERSION } from "../core/version.js";
27
30
  import { captureState, captureBatchState } from "../store/stateCapture.js";
28
31
  import { STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION } from "../core/stateContract.js";
32
+ import { operatorHtml, operatorCss, operatorJs } from "./operator.js";
29
33
  export const BODY_LIMIT_BYTES = 1024 * 1024;
30
34
  export const PROBLEM_TYPE = "https://www.hunchmemory.com/problems/nuryel.state/1/";
31
35
  export class HttpProblem extends Error {
@@ -46,16 +50,6 @@ const problem = (status, code, message, extra = {}) => new HttpProblem(status, c
46
50
  const REFUSAL_STATUS = {
47
51
  "outside-grants": 403, unsupported: 400, malformed: 400, identity: 422, conflict: 409, idempotency: 409, "no-partition-home": 404,
48
52
  };
49
- function bearerToken(req) {
50
- const header = req.headers.authorization;
51
- if (!header)
52
- return undefined;
53
- const trimmed = header.trim();
54
- if (!/^bearer /i.test(trimmed))
55
- return undefined;
56
- const token = trimmed.slice("bearer ".length).trim();
57
- return token || undefined;
58
- }
59
53
  async function readBody(req) {
60
54
  const declared = req.headers["content-length"];
61
55
  if (declared !== undefined) {
@@ -95,9 +89,11 @@ function parseScopeParam(value) {
95
89
  }
96
90
  export function createServeApp(config, opts = {}) {
97
91
  const version = opts.version ?? HUNCH_VERSION;
92
+ const configFile = config.file;
93
+ const authStateDir = opts.authStateDir ?? (configFile ? resolve(configFile + ".auth") : undefined);
98
94
  const stores = new Map();
99
- const storeFor = (scope) => {
100
- const partition = partitionFor(config, scope);
95
+ const storeFor = (scope, activeConfig) => {
96
+ const partition = partitionFor(activeConfig, scope);
101
97
  if (!partition)
102
98
  throw problem(404, "no-partition", `this server does not serve ${scopePath(scope)}`);
103
99
  let store = stores.get(partition.root);
@@ -128,19 +124,59 @@ export function createServeApp(config, opts = {}) {
128
124
  };
129
125
  const server = createServer(async (req, res) => {
130
126
  try {
127
+ let activeConfig;
128
+ try {
129
+ activeConfig = configFile ? readServeConfig(configFile) : config;
130
+ }
131
+ catch {
132
+ throw problem(503, 'configuration-unavailable', 'server configuration is unavailable; authentication is refused');
133
+ }
134
+ const requestStore = (scope) => storeFor(scope, activeConfig);
131
135
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
136
+ // Public shell only: all workspace data still uses the authenticated state routes below.
137
+ const asset = new Map([["/operator", [operatorHtml, "text/html"]], ["/operator/", [operatorHtml, "text/html"]], ["/operator.css", [operatorCss, "text/css"]], ["/operator.js", [operatorJs, "text/javascript"]]]).get(url.pathname);
138
+ if (asset && req.method === "GET") {
139
+ res.writeHead(200, { "content-type": `${asset[1]}; charset=utf-8`, "content-length": Buffer.byteLength(asset[0]), "cache-control": "no-store", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer", "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" });
140
+ return res.end(asset[0]);
141
+ }
132
142
  if (url.pathname === "/nuryel/v1/health" && req.method === "GET") {
133
- return send(res, 200, { ok: true, version, protocol: "nuryel.state/1", partitions: config.partitions.map((p) => scopePath(p.scope)) });
143
+ return send(res, 200, { ok: true, version, protocol: "nuryel.state/1", partitions: activeConfig.partitions.map((p) => scopePath(p.scope)) });
144
+ }
145
+ const authorization = /^(Bearer|DPoP) ([^\s]+)$/i.exec(req.headers.authorization ?? '');
146
+ const credential = resolveCredential(activeConfig, authorization?.[2]);
147
+ if (!credential)
148
+ throw problem(401, 'unauthorized', 'valid credentials are required');
149
+ const countHeader = (name) => req.rawHeaders.filter((header, index) => index % 2 === 0 && header.toLowerCase() === name).length;
150
+ if (countHeader('authorization') !== 1 || countHeader('dpop') > 1)
151
+ throw problem(401, 'invalid_dpop_proof', 'ambiguous authentication headers');
152
+ if (credential.proof_key) {
153
+ if (authorization[1].toLowerCase() !== 'dpop')
154
+ throw problem(401, 'invalid_dpop_proof', 'this credential requires DPoP proof; bearer fallback is disabled');
155
+ if (!activeConfig.public_origin || !authStateDir)
156
+ throw problem(503, 'proof-state-unavailable', 'key-bound authentication requires a public origin and persistent proof state');
157
+ try {
158
+ 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 });
159
+ }
160
+ catch (error) {
161
+ if (error instanceof StateProofError) {
162
+ res.setHeader('WWW-Authenticate', `DPoP error="${error.code}", algs="EdDSA"`);
163
+ if (error.nonce)
164
+ res.setHeader('DPoP-Nonce', error.nonce);
165
+ throw problem(401, error.code, error.message);
166
+ }
167
+ throw problem(503, 'proof-state-unavailable', 'proof replay state is unavailable; authentication is refused');
168
+ }
134
169
  }
135
- const principal = resolvePrincipal(config, bearerToken(req));
136
- if (!principal)
137
- throw problem(401, "unauthorized", "a valid bearer token is required");
170
+ else if (authorization[1].toLowerCase() !== 'bearer' || req.headers.dpop !== undefined)
171
+ throw problem(401, 'invalid_dpop_proof', 'credential is not bound to a proof key');
172
+ const principal = { id: credential.id, kind: credential.kind, grants: credential.grants, ...(credential.display ? { display: credential.display } : {}) };
138
173
  if (url.pathname === "/nuryel/v1/capabilities" && req.method === "GET") {
139
174
  const scope = parseScopeParam(url.searchParams.get("scope")) ?? principal.grants[0];
140
175
  if (!principal.grants.some((g) => scopePath(g) === scopePath(scope)))
141
176
  throw problem(403, "outside-grants", `scope ${scopePath(scope)} is outside the principal's grants`);
142
- const { store } = storeFor(scope);
143
- return send(res, 200, { ...capabilities(store), principal: { id: principal.id, kind: principal.kind, grants: principal.grants } });
177
+ const { store } = requestStore(scope);
178
+ const offered = capabilities(store);
179
+ return send(res, 200, { ...offered, capabilities: [...offered.capabilities, STATE_PROOF_CAPABILITY], principal: { id: principal.id, kind: principal.kind, grants: principal.grants } });
144
180
  }
145
181
  if (req.method !== "POST")
146
182
  throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
@@ -148,13 +184,15 @@ export function createServeApp(config, opts = {}) {
148
184
  // The body never names the principal: the token did.
149
185
  delete body.principal;
150
186
  delete body.schema;
187
+ // Only authenticated grants select stores for cross-partition source visibility.
188
+ const accessOptions = { additionalStores: principal.grants.map(grant => requestStore(grant).store) };
151
189
  if (url.pathname === "/nuryel/v1/read") {
152
190
  const scope = requireScope(principal, body);
153
191
  if (body.observed_page !== undefined && body.scopes !== undefined)
154
192
  throw problem(400, 'malformed', 'observation pages require a single partition without scopes');
155
- const { store } = storeFor(scope);
193
+ const { store } = requestStore(scope);
156
194
  if (body.scopes === undefined) {
157
- const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body });
195
+ const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body }, accessOptions);
158
196
  return send(res, 200, { ...response, envelope });
159
197
  }
160
198
  // Union read. The primary `scope` was gated above as always; every extra scope is
@@ -170,46 +208,49 @@ export function createServeApp(config, opts = {}) {
170
208
  for (const s of requested.data)
171
209
  if (isGranted(s) && scopePath(s) !== scopePath(scope) && !others.has(scopePath(s)))
172
210
  others.set(scopePath(s), s);
173
- const primary = readState(store, { schema: STATE_READ_VERSION, principal, ...rest, scope });
174
- const merged = mergeReadResponses(primary.response, [...others.values()].map((other) => readState(storeFor(other).store, { schema: STATE_READ_VERSION, principal, ...rest, scope: other }).response), ungranted);
211
+ const primary = readState(store, { schema: STATE_READ_VERSION, principal, ...rest, scope }, accessOptions);
212
+ const merged = mergeReadResponses(primary.response, [...others.values()].map((other) => readState(requestStore(other).store, { schema: STATE_READ_VERSION, principal, ...rest, scope: other }, accessOptions).response), ungranted);
175
213
  return send(res, 200, { ...merged, envelope: primary.envelope });
176
214
  }
177
215
  if (url.pathname === "/nuryel/v1/write") {
178
216
  const scope = requireScope(principal, body);
179
- const { store, root } = storeFor(scope);
217
+ const { store, root } = requestStore(scope);
180
218
  const { hunchDir } = stateHomeFor(store, scope);
181
219
  const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, {
220
+ ...accessOptions,
182
221
  flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
183
222
  }));
184
223
  return send(res, result.outcome === "created" ? 201 : 200, result);
185
224
  }
186
225
  if (url.pathname === "/nuryel/v1/capture") {
187
226
  const scope = requireScope(principal, body);
188
- const { store, root } = storeFor(scope);
227
+ const { store, root } = requestStore(scope);
189
228
  const { hunchDir } = stateHomeFor(store, scope);
190
229
  const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, {
230
+ ...accessOptions,
191
231
  flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
192
232
  }));
193
233
  return send(res, result.outcome === "created" ? 201 : 200, result);
194
234
  }
195
235
  if (url.pathname === "/nuryel/v1/capture-batch") {
196
236
  const scope = requireScope(principal, body);
197
- const { store, root } = storeFor(scope);
237
+ const { store, root } = requestStore(scope);
198
238
  const { hunchDir } = stateHomeFor(store, scope);
199
239
  const result = await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, {
240
+ ...accessOptions,
200
241
  flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
201
242
  }));
202
243
  return send(res, 200, result);
203
244
  }
204
245
  if (url.pathname === "/nuryel/v1/subscribe") {
205
246
  const scope = requireScope(principal, body);
206
- const { store } = storeFor(scope);
207
- return send(res, 200, subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }));
247
+ const { store } = requestStore(scope);
248
+ return send(res, 200, subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }, accessOptions));
208
249
  }
209
250
  if (url.pathname === "/nuryel/v1/records") {
210
251
  const scope = requireScope(principal, body);
211
- const { store } = storeFor(scope);
212
- return send(res, 200, recordsState(store, { schema: STATE_RECORDS_VERSION, principal, ...body }));
252
+ const { store } = requestStore(scope);
253
+ return send(res, 200, recordsState(store, { schema: STATE_RECORDS_VERSION, principal, ...body }, accessOptions));
213
254
  }
214
255
  throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
215
256
  }