@davesheffer/hunch 1.16.0 → 1.18.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/README.md CHANGED
@@ -6,9 +6,11 @@
6
6
  [![GitHub stars](https://img.shields.io/github/stars/davesheffer/hunch?color=2742ff&label=%E2%98%85%20star)](https://github.com/davesheffer/hunch)
7
7
  [![license](https://img.shields.io/npm/l/@davesheffer/hunch?color=2742ff)](LICENSE)
8
8
 
9
- Hunch is the engineering-memory and architectural-conformance layer for AI-assisted codebases. It
10
- records the decisions, constraints, rejected approaches, and bug history behind your code, delivers
11
- the relevant evidence before an assistant changes anything, then checks the result deterministically.
9
+ For the precise rules your team explicitly trusts and proves, Hunch gives a concrete guarantee:
10
+ **your agents do not re-make a decided decision or re-introduce a fixed bug without a deterministic
11
+ violation being surfaced.** The mechanism is an engineering-memory and architectural-conformance
12
+ layer — the decisions, constraints, rejected approaches, and bug history behind your code, delivered
13
+ as evidence before an assistant changes anything, with the result checked deterministically after.
12
14
 
13
15
  Memory starts **advisory**. Nothing blocks until you explicitly trust a precise rule and choose
14
16
  strict enforcement.
@@ -16,8 +18,10 @@ strict enforcement.
16
18
  **Memory is the input. The product boundary is the receipt:** relevant evidence before an edit,
17
19
  then a deterministic check of the change against the rules your team has explicitly trusted.
18
20
 
19
- > **New in v1.13.1:** `hunch_context` exposes that provenance-checked, hard-budgeted delivery
20
- > envelope as MCP structured output and records exactly which returned items reached the client.
21
+ > **New in v1.18.0:** YAML and Helm now enter the same dependency graph as application code.
22
+ > Anchors and aliases become reference edges; chart-scoped `define`, `include`, and `template`
23
+ > relationships survive pre-render syntax without weakening fail-closed handling for ordinary
24
+ > invalid YAML.
21
25
 
22
26
  See the public [roadmap](ROADMAP.md) for what is next and what is deliberately out of scope.
23
27
 
@@ -47,8 +51,11 @@ to the same graph. It merges into existing configuration instead of replacing it
47
51
  - **Change receipts** — review a working tree, commit, or branch against recorded intent and get a
48
52
  cited PASS / WARN / BLOCK result.
49
53
  - **Bug lineage** — understand which old incident a line fixed before accidentally undoing it.
50
- - **Code awareness** — TypeScript, JavaScript, and Python structure feed dependency, blast-radius,
51
- and redundancy checks. The reasoning layer works with any language.
54
+ - **Code awareness** — TypeScript, JavaScript, Python, Go, YAML, and chart-scoped Helm templates
55
+ feed dependency, blast-radius, and redundancy checks. The reasoning layer works with any language.
56
+ - **ADR interop** — `hunch import-adr` populates the graph from an existing MADR/Nygard corpus;
57
+ `hunch export-adr` projects it back as standard MADR any ADR reader understands, and the
58
+ projection then tracks the graph automatically and reports its own drift.
52
59
 
53
60
  The source of truth is readable JSON in `.hunch/`. A local SQLite index makes retrieval fast but
54
61
  is always rebuildable.
@@ -82,7 +89,7 @@ Git repo that every teammate can access, install the Matrix release on team mach
82
89
  have one maintainer run:
83
90
 
84
91
  ```bash
85
- npm i -g @davesheffer/hunch@1.13.1
92
+ npm i -g @davesheffer/hunch@1.18.0
86
93
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
87
94
  git add .gitignore .hunch/team.json
88
95
  git commit -m "chore: connect shared Hunch memory"
@@ -97,7 +104,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
97
104
  After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
98
105
 
99
106
  ```bash
100
- npm i -g @davesheffer/hunch@1.13.1
107
+ npm i -g @davesheffer/hunch@1.18.0
101
108
  git pull
102
109
  hunch init
103
110
  hunch doctor
@@ -115,6 +122,9 @@ authentication stays in SSH or the normal Git credential helper. Shared memory r
115
122
  `.hunch/local.json`, and `.hunch-private/` stay out of code history. Use
116
123
  `hunch check --base origin/main --strict --public-only --format markdown` for output that may be
117
124
  posted publicly; omit `--public-only` for an internal check that should enforce team memory.
125
+ `HUNCH_PRIVATE_DIR` remains an explicit process-level override for CI and portability. When it
126
+ redirects a repo away from `.hunch/local.json` or bypasses an advertised team store, CLI and MCP
127
+ startup warn on stderr and `hunch doctor` labels the effective source.
118
128
 
119
129
  For a correction that Hunch can express as a deterministic policy, create and inspect its
120
130
  proof-backed proposal:
@@ -142,7 +152,7 @@ but stops automatic memory commits and pushes. As a team-coordinated rollback, r
142
152
  commit to stop discovery after teammates pull the revert. Existing machines retain their ignored
143
153
  local overlay until they are deliberately disconnected; do not delete the memory repo as part of a
144
154
  rollback. For this rollout, reinstall the previous published package with
145
- `npm i -g @davesheffer/hunch@1.12.1`; the release receipt resolves and records the verified rollback
155
+ `npm i -g @davesheffer/hunch@1.16.0`; the release receipt resolves and records the verified rollback
146
156
  target from the npm registry instead of trusting Git tags. Pause enforcement first as shown above,
147
157
  and keep every team client on the same release before resuming Matrix policy workflows.
148
158
 
@@ -195,6 +205,8 @@ downloaded public VSIX has the same digest as the credential-free release candid
195
205
  - [VS Code extension guide](vscode-extension/README.md)
196
206
  - [Contributing](CONTRIBUTING.md)
197
207
  - [Architecture benchmark](bench/architectural-conformance.md)
208
+ - [Engineering Landscape Graph and ORC boundary](docs/engineering-landscape.md)
209
+ - [ORC outcome/experience protocol](docs/outcome-experience-protocol.md)
198
210
  - [Competitive landscape (dated; re-verify before quoting)](docs/competitive-landscape.md)
199
211
 
200
212
  Apache-2.0
package/dist/cli/index.js CHANGED
@@ -73,6 +73,7 @@ import { adoptProsePrompt } from "../wiki/adopt.js";
73
73
  import { topicCollisions, isInForce, liveForTopic } from "../core/topics.js";
74
74
  import { ADR_DIR_CANDIDATES, ADR_FILE_RE, mapAdrCorpus } from "../extractors/adrImport.js";
75
75
  import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
76
+ import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
76
77
  import { pendingEscalations, policyEscalations } from "../core/escalations.js";
77
78
  import { premiseEscalations } from "../core/premises.js";
78
79
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
@@ -113,6 +114,9 @@ function openTeamStore(root, opts = {}) {
113
114
  const teamWired = ensureTeamOverlay(root);
114
115
  const store = new HunchStore(hunchPaths(root));
115
116
  openStore = store;
117
+ const overlayWarning = store.overlayResolutionWarning(explicitOverlay && existsSync(teamFile));
118
+ if (overlayWarning)
119
+ console.error(`[hunch] ⚠ ${overlayWarning}`);
116
120
  if (teamAdvertised && (store.mode !== "shared"
117
121
  || !store.privateDir
118
122
  || !existsSync(store.privateDir)
@@ -500,6 +504,21 @@ program
500
504
  else if (!opts.quiet) {
501
505
  console.log(`· skipped: ${r.reason}`);
502
506
  }
507
+ // The MADR projection tracks the graph automatically once adopted, the way the
508
+ // SQLite index does — a user who ran `hunch export-adr` once never runs it again.
509
+ // Best-effort and last-write-wins-free: a hand-edited file is skipped, not
510
+ // clobbered, and any failure here must never affect the capture that preceded it.
511
+ try {
512
+ const refreshed = refreshMadrCorpus(store.json.loadAll("decisions"), root, new Date().toISOString());
513
+ if (refreshed && !opts.quiet && (refreshed.written || refreshed.removed || refreshed.skippedEdited.length)) {
514
+ const skipped = refreshed.skippedEdited.length ? `, ${refreshed.skippedEdited.length} hand-edited file(s) left alone` : "";
515
+ console.log(` ↳ ADR corpus refreshed: ${refreshed.written} written, ${refreshed.removed} removed${skipped} (${refreshed.dir}/)`);
516
+ }
517
+ }
518
+ catch (e) {
519
+ if (!opts.quiet)
520
+ console.log(` ↳ ADR corpus refresh skipped safely: ${e.message}`);
521
+ }
503
522
  let graphRefreshed = false;
504
523
  let publicCorrectionQueued = false;
505
524
  let privateCorrectionQueued = false;
@@ -3097,6 +3116,10 @@ program
3097
3116
  // are OURS (marker-verified) and stale — remove so the corpus stays coherent.
3098
3117
  for (const f of stale)
3099
3118
  rmSync(join(outDir, f));
3119
+ // Adopt the corpus: the manifest is what makes `hunch drift` able to notice
3120
+ // this projection going stale, being hand-edited, or outliving its decision.
3121
+ // Written after the files land, so a failed write never claims freshness.
3122
+ writeMadrManifest(root, buildMadrManifest(dir, files, new Date().toISOString()));
3100
3123
  console.log(`✓ exported ${files.length} ADR(s) to ${dir}${stale.length ? `; removed ${stale.length} stale generated file(s)` : ""}`);
3101
3124
  console.log(` ↳ Backstage: add to catalog-info.yaml metadata.annotations → ${backstageAnnotation}`);
3102
3125
  }
@@ -5223,6 +5246,30 @@ program
5223
5246
  console.log(`· ${f.id} — ${f.detail}`);
5224
5247
  console.log(`\nHeal: run \`hunch wiki --heal\` — regenerates only the stale pages (the wiki is a derived view; never edit it by hand).\n`);
5225
5248
  }
5249
+ // The MADR projection: three kinds, three different human actions — which is
5250
+ // why they are separate sections rather than one "run export-adr" line.
5251
+ const madrStale = kind("madr-stale");
5252
+ if (madrStale.length) {
5253
+ console.log(`${madrStale.length} exported ADR(s) drifted from the graph:\n`);
5254
+ for (const f of madrStale)
5255
+ console.log(`· ${f.id} — ${f.detail}`);
5256
+ console.log(`\nHeal: run \`hunch export-adr\` — the corpus is a disposable projection (the graph stays the source of truth). Normally this never appears: the projection refreshes automatically on every capture.\n`);
5257
+ }
5258
+ const madrEdited = kind("madr-edited");
5259
+ if (madrEdited.length) {
5260
+ console.log(`${madrEdited.length} generated ADR(s) were hand-edited — the next export would overwrite them:\n`);
5261
+ for (const f of madrEdited)
5262
+ console.log(`· ${f.id} — ${f.detail}`);
5263
+ console.log(`\nHeal A (the DECISION is what changed): move the edit into the decision via /capture, then let the projection regenerate — the edit survives because it now lives in the graph.`);
5264
+ console.log(`Heal B (you want to own this file): delete the hunch:generated marker. The export refuses it from then on and it becomes a hand-written ADR.\n`);
5265
+ }
5266
+ const madrOrphan = kind("madr-orphan");
5267
+ if (madrOrphan.length) {
5268
+ console.log(`${madrOrphan.length} generated ADR(s) have no decision behind them any more:\n`);
5269
+ for (const f of madrOrphan)
5270
+ console.log(`· ${f.id} — ${f.detail}`);
5271
+ console.log(`\nHeal: delete the file, or run \`hunch export-adr\` to regenerate the corpus without it. If the decision moved to the private overlay, the file is a PUBLIC artifact of a now-private record — delete it.\n`);
5272
+ }
5226
5273
  // Every drift kind heals here — see bug_drift_heal_asymmetry above. premise-stale
5227
5274
  // shipped in the drift report without a section here, so a repo whose ONLY drift
5228
5275
  // was a dead premise got "N findings" from `hunch drift` and a bare closing line
@@ -5387,8 +5434,8 @@ program
5387
5434
  }
5388
5435
  else if (store.privateDir) {
5389
5436
  console.log(store.mode === "shared"
5390
- ? `shared: on → ${store.privateDir} (UNIFIED — every capture routes here; one source of truth across branches, worktrees, teammates, agents)`
5391
- : `private: on → ${store.privateDir} (local overlay — unioned into queries; never committed or posted publicly)`);
5437
+ ? `shared: on → ${store.privateDir} (UNIFIED — every capture routes here; one source of truth across branches, worktrees, teammates, agents; source: ${store.overlaySource})`
5438
+ : `private: on → ${store.privateDir} (local overlay — unioned into queries; never committed or posted publicly; source: ${store.overlaySource})`);
5392
5439
  }
5393
5440
  else {
5394
5441
  const team = readTeamConfig(root);
@@ -27,10 +27,10 @@ function view(file, source) {
27
27
  }
28
28
  function viewOfParsed(parsed) {
29
29
  const symbols = new Map(parsed.symbols.map((s) => [symbolKey(s), s]));
30
- const byStart = new Map(parsed.symbols.map((s) => [s.startByte, s]));
30
+ const byIndex = new Map(parsed.symbols.map((s, i) => [i, s]));
31
31
  const calls = new Map();
32
- for (const [start, callees] of attributeCalls(parsed)) {
33
- const caller = byStart.get(start);
32
+ for (const [index, callees] of attributeCalls(parsed)) {
33
+ const caller = byIndex.get(index);
34
34
  if (!caller)
35
35
  continue;
36
36
  for (const [callee, member] of callees) {
@@ -436,6 +436,7 @@ export function mutateSnapshotForPolicy(policy, snapshot) {
436
436
  }
437
437
  else {
438
438
  edges.push({
439
+ schema: "hunch.edge/1",
439
440
  id: `edge_policy_mutation_${policy.id}`,
440
441
  from: subjectId,
441
442
  to: objectId,
@@ -443,6 +444,8 @@ export function mutateSnapshotForPolicy(policy, snapshot) {
443
444
  reason: "deterministic proof mutation",
444
445
  strength: 1,
445
446
  provenance: { source: "derived", confidence: 1, evidence: [policy.id] },
447
+ environment: null,
448
+ metadata: {},
446
449
  });
447
450
  operator = mutationOperatorForPolicy(policy);
448
451
  }
@@ -14,7 +14,7 @@ export const DataClassSchema = z.enum(["public", "private", "secret"]);
14
14
  export const StructuralSymbolRefSchema = z.object({
15
15
  file: z.string().min(1),
16
16
  name: z.string().min(1),
17
- kind: z.enum(["function", "method", "class", "interface", "type"]),
17
+ kind: z.enum(["function", "method", "class", "interface", "type", "variable", "file"]),
18
18
  });
19
19
  export const StructuralCallRefSchema = z.object({
20
20
  file: z.string().min(1),
@@ -11,6 +11,11 @@
11
11
  * (hash-compared via .hunch/wiki-manifest.json; only when a wiki
12
12
  * was adopted — see src/wiki/wiki.ts). Advisory, healed by
13
13
  * `hunch wiki --heal`, never a gate.
14
+ * - madr-*: the exported MADR corpus drifted from the graph — stale (the
15
+ * decision moved), edited (a human changed a generated file the
16
+ * next export would overwrite), or orphan (the decision left the
17
+ * public graph). Only when a corpus was exported; healed by
18
+ * `hunch export-adr`. See src/integrations/madrManifest.ts.
14
19
  */
15
20
  import { existsSync, readFileSync } from "node:fs";
16
21
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -20,6 +25,7 @@ import { evaluatePremises } from "./premises.js";
20
25
  import { parseDocAnchors } from "./docanchors.js";
21
26
  import { markdownDocs, STALE_MARKER, SRC_REF } from "./docscan.js";
22
27
  import { computeWikiDrift } from "../wiki/wiki.js";
28
+ import { computeMadrDrift } from "../integrations/madrManifest.js";
23
29
  export function computeDrift(store, root) {
24
30
  const findings = [];
25
31
  const decisions = store.recs("decisions");
@@ -142,6 +148,12 @@ export function computeDrift(store, root) {
142
148
  // component vanished). Deterministic hash comparison against the manifest;
143
149
  // fires only when a wiki was adopted. Advisory like every other kind here.
144
150
  findings.push(...computeWikiDrift(store, root));
151
+ // 6b. MADR-* — the exported ADR corpus drifted from the graph. PUBLIC decisions
152
+ // only: the corpus is a committable artifact, so its freshness must be
153
+ // computed from exactly the records allowed to reach it (an overlay record
154
+ // leaking into a public drift report is the same class of bug as one
155
+ // leaking into the export itself). Fires only where a corpus was exported.
156
+ findings.push(...computeMadrDrift(store.json.loadAll("decisions"), root));
145
157
  // 7. FINDING-STALE — a LIVE finding (observation, no diff) whose anchor evaporated:
146
158
  // an affected file that no longer exists, or a violates_constraint pointing at a
147
159
  // retired/missing rule. Deterministic + advisory (never the exit-code class):
package/dist/core/ids.js CHANGED
@@ -22,6 +22,22 @@ export function componentId(name) {
22
22
  export function edgeId(from, to, type) {
23
23
  return "edge_" + shortHash(`${from}->${to}:${type}`);
24
24
  }
25
+ /** Stable Engineering Landscape resource identity. The kind remains visible so
26
+ * fragments stay useful without a lookup table; the natural key is normalized
27
+ * only where spelling cannot carry meaning (outer whitespace, path separators,
28
+ * and a trailing slash). Kind-specific discovery may apply stricter canonical
29
+ * rules before calling this helper. */
30
+ export function resourceId(kind, naturalKey) {
31
+ const normalizedKind = kind.trim().toLowerCase();
32
+ const normalizedKey = naturalKey.trim().replace(/\\/g, "/").replace(/\/+$/, "");
33
+ return `${normalizedKind}:${normalizedKey}`;
34
+ }
35
+ /** Resource relationships ride the existing edge graph and therefore share its
36
+ * endpoint/type identity rule. Keeping one helper prevents a parallel graph from
37
+ * minting incompatible relationship ids. */
38
+ export function resourceRelationshipId(from, to, type) {
39
+ return edgeId(from, to, type);
40
+ }
25
41
  /** Decision id. Seed with the CANONICAL full commit sha (the auto-sync and MCP
26
42
  * commit paths both do this, so a recorded decision upgrades the auto-draft for
27
43
  * the same commit), or with "manual:<title>" for an ad-hoc MCP decision. */
@@ -18,7 +18,7 @@ import { readFileSync, existsSync, mkdirSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
19
  import { writeFileAtomic } from "./io.js";
20
20
  /** The schema generation this build writes and reads. Bump on any breaking change. */
21
- export const SCHEMA_VERSION = 2;
21
+ export const SCHEMA_VERSION = 3;
22
22
  /** A repo whose `.hunch/` predates manifests is treated as v1. Migrations are
23
23
  * numbered from 2 (each `version` is the number it PRODUCES), so a baseline repo
24
24
  * runs every migration with version >= 2 — never author a no-op version:1 one. */
@@ -58,6 +58,25 @@ export const MIGRATIONS = [
58
58
  return raw;
59
59
  },
60
60
  },
61
+ {
62
+ // v3: Engineering Landscape resources reuse the existing graph. Existing
63
+ // edges are explicitly identified as legacy graph edges before the expanded
64
+ // relationship schema is validated; newly written resource relationships use
65
+ // hunch.resource-relationship/1 and keep their currentness/metadata.
66
+ version: 3,
67
+ description: "Version existing graph edges before adding Engineering Landscape resources and relationships",
68
+ up(kind, raw) {
69
+ if (kind === "edges") {
70
+ if (raw.schema === undefined)
71
+ raw.schema = "hunch.edge/1";
72
+ if (raw.environment === undefined)
73
+ raw.environment = null;
74
+ if (raw.metadata === undefined)
75
+ raw.metadata = {};
76
+ }
77
+ return raw;
78
+ },
79
+ },
61
80
  ];
62
81
  /** Read `.hunch/manifest.json`. A missing/corrupt manifest is treated as the
63
82
  * BASELINE version (a pre-manifest `.hunch/`), so future builds still migrate it. */
@@ -6,6 +6,7 @@
6
6
  * tool inputs. Every record carries `provenance` so nothing is a blind assertion.
7
7
  */
8
8
  import { z } from "zod";
9
+ import { resourceId, resourceRelationshipId } from "./ids.js";
9
10
  /** Where a fact came from and how much to trust it. Confidence tiers (DESIGN §4):
10
11
  * inferred < extracted < llm_draft < llm_draft+human_confirmed/derived. */
11
12
  export const ProvenanceSchema = z.object({
@@ -29,6 +30,140 @@ export const ComponentSchema = z.object({
29
30
  created_at: z.string(),
30
31
  updated_at: z.string(),
31
32
  });
33
+ export const RESOURCE_SCHEMA_VERSION = "hunch.resource/1";
34
+ export const RESOURCE_RELATIONSHIP_SCHEMA_VERSION = "hunch.resource-relationship/1";
35
+ /** Resource kinds are deliberately extensible: the initial vocabulary is
36
+ * documented, while repositories may add a stable snake_case kind without a
37
+ * schema release. */
38
+ export const ResourceKindSchema = z.string().regex(/^[a-z][a-z0-9_]{0,63}$/);
39
+ export const ResourceCurrentnessSchema = z.object({
40
+ status: z.enum(["current", "unverified", "stale"]),
41
+ verified_at: z.string().max(64).optional().describe("ISO timestamp at which the declaration was checked"),
42
+ source_revision: z.string().min(1).max(512).optional().describe("immutable source/Git revision backing the declaration"),
43
+ source_content_hash: z.string().min(1).max(512).optional().describe("content hash when revision alone is insufficient"),
44
+ }).strict().superRefine((currentness, ctx) => {
45
+ if (currentness.verified_at !== undefined && !Number.isFinite(Date.parse(currentness.verified_at))) {
46
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["verified_at"], message: "resource currentness timestamp must be ISO-compatible" });
47
+ }
48
+ if (currentness.status !== "unverified"
49
+ && (!currentness.verified_at || (!currentness.source_revision && !currentness.source_content_hash))) {
50
+ ctx.addIssue({
51
+ code: z.ZodIssueCode.custom,
52
+ message: "current or stale resource evidence requires a verification timestamp and source revision or content hash",
53
+ });
54
+ }
55
+ });
56
+ const MetadataValueSchema = z.union([
57
+ z.string().max(1024),
58
+ z.number().finite(),
59
+ z.boolean(),
60
+ z.null(),
61
+ z.array(z.union([z.string().max(1024), z.number().finite(), z.boolean(), z.null()])).max(32),
62
+ ]);
63
+ const SENSITIVE_METADATA_KEY = /(^|[_-])(authorization|bearer|credential|password|passwd|private[_-]?key|secret|token|api[_-]?key)($|[_-])/i;
64
+ const SENSITIVE_ASSIGNMENT = /\b(authorization|password|passwd|private[_-]?key|secret|access[_-]?token|refresh[_-]?token|api[_-]?key)\s*[:=]\s*[^\s,;]{4,}/i;
65
+ const PRIVATE_KEY_BLOCK = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/i;
66
+ const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]{12,}/i;
67
+ /** Reject credential material while allowing ordinary architecture prose such as
68
+ * "authentication service" or "secrets are managed externally". */
69
+ export function isCredentialFreeText(value) {
70
+ if (PRIVATE_KEY_BLOCK.test(value) || BEARER_VALUE.test(value) || SENSITIVE_ASSIGNMENT.test(value))
71
+ return false;
72
+ try {
73
+ const url = new URL(value);
74
+ if (url.username || url.password)
75
+ return false;
76
+ for (const [key] of url.searchParams)
77
+ if (SENSITIVE_METADATA_KEY.test(key))
78
+ return false;
79
+ }
80
+ catch { /* credential-free canonical locators need not be absolute URLs */ }
81
+ return true;
82
+ }
83
+ function isCanonicalResourceIdentity(value) {
84
+ const separator = value.indexOf(":");
85
+ if (separator <= 0 || separator === value.length - 1)
86
+ return false;
87
+ const kind = value.slice(0, separator);
88
+ const naturalKey = value.slice(separator + 1);
89
+ return ResourceKindSchema.safeParse(kind).success && value === resourceId(kind, naturalKey);
90
+ }
91
+ export const ResourceMetadataSchema = z.record(z.string().min(1).max(64), MetadataValueSchema)
92
+ .superRefine((metadata, ctx) => {
93
+ if (Object.keys(metadata).length > 64) {
94
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "resource metadata is limited to 64 fields" });
95
+ }
96
+ for (const [key, raw] of Object.entries(metadata)) {
97
+ if (SENSITIVE_METADATA_KEY.test(key)) {
98
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: "credential-bearing metadata keys are forbidden" });
99
+ continue;
100
+ }
101
+ const values = Array.isArray(raw) ? raw : [raw];
102
+ for (const value of values) {
103
+ if (typeof value === "string" && !isCredentialFreeText(value)) {
104
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: "credential material is forbidden in resource metadata" });
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ });
110
+ /** Durable Engineering Landscape node. Runtime health/readiness intentionally has
111
+ * no field here: those expiring observations belong to ORC. */
112
+ export const ResourceSchema = z.object({
113
+ schema: z.literal(RESOURCE_SCHEMA_VERSION),
114
+ id: z.string().min(3).max(2048).describe("stable kind-qualified resource identity"),
115
+ kind: ResourceKindSchema,
116
+ name: z.string().min(1).max(256),
117
+ scope: z.array(z.string().min(1).max(512)).max(16).default([]),
118
+ locator: z.string().min(1).max(2048).nullable().default(null),
119
+ lifecycle: z.enum(["planned", "active", "deprecated", "retired"]).default("active"),
120
+ criticality: z.enum(["low", "medium", "high", "critical"]).optional(),
121
+ contract_version: z.string().max(256).optional(),
122
+ provenance: ProvenanceSchema,
123
+ currentness: ResourceCurrentnessSchema,
124
+ metadata: ResourceMetadataSchema.default({}),
125
+ created_at: z.string(),
126
+ updated_at: z.string(),
127
+ }).strict().superRefine((resource, ctx) => {
128
+ const prefix = `${resource.kind}:`;
129
+ const naturalKey = resource.id.startsWith(prefix) ? resource.id.slice(prefix.length) : "";
130
+ if (!naturalKey.trim() || resource.id !== resourceId(resource.kind, naturalKey)) {
131
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["id"], message: "resource id must be a canonical kind-qualified identity" });
132
+ }
133
+ const credentialFreeFields = [
134
+ ["id", resource.id],
135
+ ["name", resource.name],
136
+ ...resource.scope.map((scope, index) => [`scope.${index}`, scope]),
137
+ ...(resource.locator === null ? [] : [["locator", resource.locator]]),
138
+ ...(resource.contract_version === undefined ? [] : [["contract_version", resource.contract_version]]),
139
+ ["provenance.source", resource.provenance.source],
140
+ ...resource.provenance.evidence.map((evidence, index) => [`provenance.evidence.${index}`, evidence]),
141
+ ...(resource.currentness.source_revision === undefined
142
+ ? []
143
+ : [["currentness.source_revision", resource.currentness.source_revision]]),
144
+ ...(resource.currentness.source_content_hash === undefined
145
+ ? []
146
+ : [["currentness.source_content_hash", resource.currentness.source_content_hash]]),
147
+ ];
148
+ for (const [field, value] of credentialFreeFields) {
149
+ if (!isCredentialFreeText(value)) {
150
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: String(field).split("."), message: "credential material is forbidden in resource records" });
151
+ }
152
+ }
153
+ if (new Set(resource.scope).size !== resource.scope.length) {
154
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["scope"], message: "resource scope entries must be unique" });
155
+ }
156
+ if (resource.provenance.source.length > 256 || resource.provenance.evidence.length > 64
157
+ || resource.provenance.evidence.some((evidence) => evidence.length > 2048)) {
158
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provenance"], message: "resource provenance must remain bounded" });
159
+ }
160
+ if (resource.created_at.length > 64 || !Number.isFinite(Date.parse(resource.created_at))) {
161
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["created_at"], message: "resource created_at must be ISO-compatible" });
162
+ }
163
+ if (resource.updated_at.length > 64 || !Number.isFinite(Date.parse(resource.updated_at))) {
164
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["updated_at"], message: "resource updated_at must be ISO-compatible" });
165
+ }
166
+ });
32
167
  export const EdgeType = z.enum([
33
168
  "depends_on",
34
169
  "calls",
@@ -37,9 +172,36 @@ export const EdgeType = z.enum([
37
172
  "implements",
38
173
  "supersedes",
39
174
  "related_to",
175
+ "references",
176
+ "provides",
177
+ "belongs_to",
178
+ "implemented_by",
179
+ "invokes",
180
+ "exposes",
181
+ "publishes",
182
+ "consumes",
183
+ "reads_from",
184
+ "writes_to",
185
+ "builds",
186
+ "tests",
187
+ "deploys",
188
+ "deployed_on",
189
+ "owned_by",
190
+ "monitored_by",
191
+ "governed_by",
192
+ "source_of_truth_for",
193
+ "compatible_with",
194
+ "replaces",
195
+ ]);
196
+ export const ResourceRelationshipType = z.enum([
197
+ "provides", "belongs_to", "implemented_by", "contains", "depends_on", "invokes",
198
+ "exposes", "publishes", "consumes", "reads_from", "writes_to", "builds", "tests",
199
+ "deploys", "deployed_on", "owned_by", "monitored_by", "governed_by",
200
+ "source_of_truth_for", "compatible_with", "replaces", "implements",
40
201
  ]);
41
202
  /** Typed relationship between components or symbols. */
42
203
  export const EdgeSchema = z.object({
204
+ schema: z.enum(["hunch.edge/1", RESOURCE_RELATIONSHIP_SCHEMA_VERSION]).default("hunch.edge/1"),
43
205
  id: z.string().describe("edge_*"),
44
206
  from: z.string(),
45
207
  to: z.string(),
@@ -47,6 +209,49 @@ export const EdgeSchema = z.object({
47
209
  reason: z.string().default(""),
48
210
  strength: z.number().min(0).max(1).default(0.5),
49
211
  provenance: ProvenanceSchema,
212
+ currentness: ResourceCurrentnessSchema.optional(),
213
+ environment: z.string().max(256).nullable().default(null),
214
+ criticality: z.enum(["low", "medium", "high", "critical"]).optional(),
215
+ contract_version: z.string().max(256).optional(),
216
+ metadata: ResourceMetadataSchema.default({}),
217
+ }).strict().superRefine((edge, ctx) => {
218
+ if (edge.schema !== RESOURCE_RELATIONSHIP_SCHEMA_VERSION)
219
+ return;
220
+ const credentialFreeFields = [
221
+ ["from", edge.from], ["to", edge.to], ["reason", edge.reason],
222
+ ["provenance.source", edge.provenance.source],
223
+ ...edge.provenance.evidence.map((evidence, index) => [`provenance.evidence.${index}`, evidence]),
224
+ ...(edge.environment === null ? [] : [["environment", edge.environment]]),
225
+ ...(edge.contract_version === undefined ? [] : [["contract_version", edge.contract_version]]),
226
+ ...(edge.currentness?.source_revision === undefined
227
+ ? []
228
+ : [["currentness.source_revision", edge.currentness.source_revision]]),
229
+ ...(edge.currentness?.source_content_hash === undefined
230
+ ? []
231
+ : [["currentness.source_content_hash", edge.currentness.source_content_hash]]),
232
+ ];
233
+ for (const [field, value] of credentialFreeFields) {
234
+ if (!isCredentialFreeText(value)) {
235
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: field.split("."), message: "credential material is forbidden in graph relationships" });
236
+ }
237
+ }
238
+ if (!ResourceRelationshipType.options.includes(edge.type)) {
239
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["type"], message: "unsupported resource relationship type" });
240
+ }
241
+ if (!isCanonicalResourceIdentity(edge.from) || !isCanonicalResourceIdentity(edge.to)) {
242
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["from"], message: "resource relationships require kind-qualified endpoints" });
243
+ }
244
+ if (edge.id !== resourceRelationshipId(edge.from, edge.to, edge.type)) {
245
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["id"], message: "resource relationship id must be deterministic from endpoints and type" });
246
+ }
247
+ if (!edge.currentness) {
248
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["currentness"], message: "resource relationships require currentness evidence" });
249
+ }
250
+ if (edge.from.length > 2048 || edge.to.length > 2048 || edge.reason.length > 2048
251
+ || edge.provenance.source.length > 256 || edge.provenance.evidence.length > 64
252
+ || edge.provenance.evidence.some((evidence) => evidence.length > 2048)) {
253
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provenance"], message: "resource relationship fields must remain bounded" });
254
+ }
50
255
  });
51
256
  export const SymbolKind = z.enum(["function", "method", "class", "interface", "type", "variable", "file"]);
52
257
  export const SymbolMetricsSchema = z.object({
@@ -281,9 +486,10 @@ export const FindingSchema = z.object({
281
486
  provenance: ProvenanceSchema,
282
487
  });
283
488
  /** The entity collections, keyed by their on-disk directory name. */
284
- export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings"];
489
+ export const ENTITY_KINDS = ["components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings"];
285
490
  export const SCHEMAS = {
286
491
  components: ComponentSchema,
492
+ resources: ResourceSchema,
287
493
  edges: EdgeSchema,
288
494
  symbols: SymbolSchema,
289
495
  decisions: DecisionSchema,
@@ -311,6 +311,13 @@ function remoteIdentity(raw, cwd, purpose = "route") {
311
311
  return `literal:${trimRepoSuffix(value)}`;
312
312
  }
313
313
  }
314
+ /** Canonical repository identity for deterministic, credential-free discovery.
315
+ * The returned value is an internal identity: callers exposing it must hash
316
+ * local `file:` identities so host paths never enter graph records. Network
317
+ * credentials, URL query strings and fragments are deliberately excluded. */
318
+ export function canonicalRemoteRepositoryIdentity(raw, cwd) {
319
+ return remoteIdentity(raw, cwd, "publication");
320
+ }
314
321
  function localRemotePath(raw, cwd) {
315
322
  const value = raw.trim();
316
323
  if (!value)