@davesheffer/hunch 1.20.0-rc.4 → 1.20.0-rc.5

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.
@@ -0,0 +1,223 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { findingId } from "./ids.js";
4
+ import { FindingSchema, isCredentialFreeText } from "./types.js";
5
+ export const USEFULNESS_OBSERVATION_SCHEMA_VERSION = "hunch.usefulness-observation/1";
6
+ export const USEFULNESS_SIGNALS = [
7
+ "used",
8
+ "prevented",
9
+ "near_miss",
10
+ "contradicted",
11
+ "stale",
12
+ "unused",
13
+ "unknown",
14
+ ];
15
+ const SHA256 = /^sha256:[a-f0-9]{64}$/;
16
+ const GIT_OBJECT = /^[a-f0-9]{40,64}$/;
17
+ const RECEIPT_REF = /^hunch-memory:hmctx_[a-f0-9]{32}$/;
18
+ const OBSERVATION_ID = /^huo_[a-f0-9]{24}$/;
19
+ const MAX_RETENTION_MS = 365 * 24 * 60 * 60 * 1_000;
20
+ const EvidenceReferenceSchema = z.object({
21
+ kind: z.enum(["artifact", "event", "verification", "receipt", "observation"]),
22
+ ref: z.string().min(1).max(512),
23
+ hash: z.string().regex(SHA256),
24
+ }).strict();
25
+ export const UsefulnessObservationSchema = z.object({
26
+ schema: z.literal(USEFULNESS_OBSERVATION_SCHEMA_VERSION),
27
+ observationId: z.string().regex(OBSERVATION_ID),
28
+ episode: z.object({
29
+ provider: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/),
30
+ schemaVersion: z.string().min(3).max(128),
31
+ episodeId: z.string().min(3).max(256),
32
+ episodeHash: z.string().regex(SHA256),
33
+ terminalAt: z.string().min(1).max(64),
34
+ result: z.enum(["pass", "fail", "uncertain", "abandoned", "rolled_back"]),
35
+ }).strict(),
36
+ delivery: z.object({
37
+ receiptRef: z.string().regex(RECEIPT_REF),
38
+ receiptHash: z.string().regex(SHA256),
39
+ graphRevision: z.string().regex(GIT_OBJECT),
40
+ sourceRevision: z.string().regex(GIT_OBJECT),
41
+ sourceContentHash: z.string().regex(SHA256),
42
+ }).strict(),
43
+ record: z.object({
44
+ recordId: z.string().min(3).max(512),
45
+ recordKind: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/),
46
+ recordRevision: z.string().regex(SHA256),
47
+ contentHash: z.string().regex(SHA256),
48
+ }).strict(),
49
+ signal: z.enum(USEFULNESS_SIGNALS),
50
+ evidence: z.array(EvidenceReferenceSchema).max(64),
51
+ observedAt: z.string().min(1).max(64),
52
+ retainUntil: z.string().min(1).max(64),
53
+ privacy: z.object({
54
+ payloadMode: z.literal("references_hashes_only"),
55
+ rawTranscriptIncluded: z.literal(false),
56
+ rawProviderOutputIncluded: z.literal(false),
57
+ }).strict(),
58
+ authority: z.object({
59
+ behavioralEffect: z.literal("none"),
60
+ mayChangeRanking: z.literal(false),
61
+ mayPromoteKnowledge: z.literal(false),
62
+ mayGrantAuthority: z.literal(false),
63
+ }).strict(),
64
+ contentHash: z.string().regex(SHA256),
65
+ }).strict().superRefine((observation, ctx) => {
66
+ const timestamps = [
67
+ ["episode", "terminalAt", observation.episode.terminalAt],
68
+ ["observedAt", observation.observedAt],
69
+ ["retainUntil", observation.retainUntil],
70
+ ];
71
+ for (const path of timestamps) {
72
+ const value = path.at(-1);
73
+ if (!Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) {
74
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: path.slice(0, -1), message: "usefulness timestamp is invalid" });
75
+ }
76
+ }
77
+ const observedAt = Date.parse(observation.observedAt);
78
+ const terminalAt = Date.parse(observation.episode.terminalAt);
79
+ const retainUntil = Date.parse(observation.retainUntil);
80
+ if (Number.isFinite(observedAt) && Number.isFinite(terminalAt) && observedAt < terminalAt) {
81
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["observedAt"], message: "usefulness cannot precede the terminal outcome" });
82
+ }
83
+ if (Number.isFinite(observedAt) && Number.isFinite(retainUntil)
84
+ && (retainUntil <= observedAt || retainUntil - observedAt > MAX_RETENTION_MS)) {
85
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["retainUntil"], message: "usefulness retention is invalid" });
86
+ }
87
+ if (observation.delivery.sourceRevision !== observation.delivery.graphRevision) {
88
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["delivery", "sourceRevision"], message: "usefulness source revision is inconsistent" });
89
+ }
90
+ if (observation.signal !== "unknown" && observation.evidence.length === 0) {
91
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["evidence"], message: "supported usefulness requires evidence" });
92
+ }
93
+ const evidenceIdentities = new Set();
94
+ for (const [index, evidence] of observation.evidence.entries()) {
95
+ const identity = `${evidence.kind}:${evidence.ref}:${evidence.hash}`;
96
+ if (evidenceIdentities.has(identity)) {
97
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["evidence", index], message: "usefulness evidence is duplicated" });
98
+ }
99
+ evidenceIdentities.add(identity);
100
+ if (!isCredentialFreeText(evidence.ref)) {
101
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["evidence", index, "ref"], message: "usefulness evidence contains credential material" });
102
+ }
103
+ }
104
+ for (const [path, value] of [
105
+ [["episode", "schemaVersion"], observation.episode.schemaVersion],
106
+ [["episode", "episodeId"], observation.episode.episodeId],
107
+ [["record", "recordId"], observation.record.recordId],
108
+ ]) {
109
+ if (!isCredentialFreeText(value)) {
110
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [...path], message: "usefulness identity contains credential material" });
111
+ }
112
+ }
113
+ const unsigned = usefulnessObservationUnsigned(observation);
114
+ const contentHash = usefulnessHash(unsigned);
115
+ if (observation.contentHash !== contentHash
116
+ || observation.observationId !== usefulnessObservationId(observation)) {
117
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "usefulness observation seal is invalid" });
118
+ }
119
+ });
120
+ function canonicalJson(value) {
121
+ if (Array.isArray(value))
122
+ return `[${value.map(canonicalJson).join(",")}]`;
123
+ if (value && typeof value === "object") {
124
+ return `{${Object.entries(value)
125
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
126
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
127
+ .join(",")}}`;
128
+ }
129
+ return JSON.stringify(value) ?? "null";
130
+ }
131
+ function usefulnessHash(value) {
132
+ return `sha256:${createHash("sha256").update(canonicalJson(value), "utf8").digest("hex")}`;
133
+ }
134
+ function usefulnessObservationId(observation) {
135
+ const identityHash = usefulnessHash({
136
+ episodeId: observation.episode.episodeId,
137
+ receiptRef: observation.delivery.receiptRef,
138
+ receiptHash: observation.delivery.receiptHash,
139
+ recordId: observation.record.recordId,
140
+ });
141
+ return `huo_${identityHash.slice(7, 31)}`;
142
+ }
143
+ function usefulnessObservationUnsigned(observation) {
144
+ return {
145
+ schema: observation.schema,
146
+ episode: { ...observation.episode },
147
+ delivery: { ...observation.delivery },
148
+ record: { ...observation.record },
149
+ signal: observation.signal,
150
+ evidence: observation.evidence.map((item) => ({ ...item })),
151
+ observedAt: observation.observedAt,
152
+ retainUntil: observation.retainUntil,
153
+ privacy: { ...observation.privacy },
154
+ authority: { ...observation.authority },
155
+ };
156
+ }
157
+ export function createUsefulnessObservation(input) {
158
+ const unsigned = usefulnessObservationUnsigned({
159
+ schema: USEFULNESS_OBSERVATION_SCHEMA_VERSION,
160
+ episode: { ...input.episode },
161
+ delivery: { ...input.delivery },
162
+ record: { ...input.record },
163
+ signal: input.signal,
164
+ evidence: input.evidence.map((item) => ({ ...item })),
165
+ observedAt: input.observedAt,
166
+ retainUntil: input.retainUntil,
167
+ privacy: { ...input.privacy },
168
+ authority: {
169
+ behavioralEffect: "none",
170
+ mayChangeRanking: false,
171
+ mayPromoteKnowledge: false,
172
+ mayGrantAuthority: false,
173
+ },
174
+ });
175
+ const observationId = usefulnessObservationId(unsigned);
176
+ return UsefulnessObservationSchema.parse({
177
+ ...unsigned,
178
+ observationId,
179
+ contentHash: usefulnessHash(unsigned),
180
+ });
181
+ }
182
+ export function assertUsefulnessObservation(value) {
183
+ UsefulnessObservationSchema.parse(value);
184
+ }
185
+ /** Contradiction and staleness become open advisory review work, never truth. */
186
+ export function usefulnessObservationFinding(value) {
187
+ const observation = UsefulnessObservationSchema.parse(value);
188
+ if (observation.signal !== "contradicted" && observation.signal !== "stale")
189
+ return null;
190
+ const title = `Outcome ${observation.signal}: ${observation.record.recordId} (${observation.observationId})`;
191
+ const evidence = [
192
+ `usefulness:${observation.observationId}`,
193
+ `usefulness-content:${observation.contentHash}`,
194
+ `episode:${observation.episode.episodeId}@${observation.episode.episodeHash}`,
195
+ `delivery:${observation.delivery.receiptRef}@${observation.delivery.receiptHash}`,
196
+ `record:${observation.record.recordId}@${observation.record.recordRevision}`,
197
+ ...observation.evidence.map((item) => `${item.kind}:${item.ref}@${item.hash}`),
198
+ ];
199
+ return FindingSchema.parse({
200
+ id: findingId(title),
201
+ title,
202
+ observation: observation.signal === "contradicted"
203
+ ? `Outcome evidence may conflict with delivered Hunch record ${observation.record.recordId}; review the record and its applicability before changing trusted knowledge.`
204
+ : `Outcome evidence may show that delivered Hunch record ${observation.record.recordId} was stale; review currentness before changing trusted knowledge.`,
205
+ evidence,
206
+ method: null,
207
+ severity: observation.signal === "contradicted" ? "high" : "medium",
208
+ triage: "open",
209
+ affected_files: [],
210
+ affected_symbols: [observation.record.recordId],
211
+ violates_constraint: null,
212
+ spawned_decision: null,
213
+ observed_at: observation.observedAt,
214
+ resolved_commit: null,
215
+ provenance: {
216
+ source: "outcome_experience+candidate",
217
+ confidence: 0.75,
218
+ evidence,
219
+ last_verified: observation.observedAt,
220
+ },
221
+ });
222
+ }
223
+ //# sourceMappingURL=outcomeExperience.js.map
@@ -35,6 +35,17 @@ export const ADR_DIR_CANDIDATES = [
35
35
  /** An ADR file is NNNN-slug.md (adr-tools / MADR convention). Templates and
36
36
  * indexes (adr-template.md, README.md, index.md) never match. */
37
37
  export const ADR_FILE_RE = /^(\d{1,5})-([a-z0-9@][a-z0-9@._-]*)\.md$/i;
38
+ /** A structurally valid ADR filename can still name corpus scaffolding rather
39
+ * than a decision. Keep this classification separate from parse failure so the
40
+ * CLI can exclude templates quietly instead of falsely calling their filename
41
+ * malformed. */
42
+ export function isAdrCorpusTemplate(relPath) {
43
+ const base = relPath.split("/").pop() ?? relPath;
44
+ const match = ADR_FILE_RE.exec(base);
45
+ return match !== null
46
+ && Number(match[1]) === 0
47
+ && /^(?:adr[-_.]?)?template(?:[-_.]|$)/i.test(match[2]);
48
+ }
38
49
  /** Decision.date is required even when an ADR has no date and no Git history is
39
50
  * available (for example, a copied corpus outside a repository). Use an honest,
40
51
  * deterministic sentinel instead of the import clock: re-importing the same
@@ -180,7 +191,7 @@ export function parseAdrMarkdown(text, relPath) {
180
191
  return null;
181
192
  const number = Number(nameMatch[1]);
182
193
  const slug = nameMatch[2].toLowerCase();
183
- if (number === 0 && /^(?:adr[-_.]?)?template(?:[-_.]|$)/i.test(slug))
194
+ if (isAdrCorpusTemplate(relPath))
184
195
  return null;
185
196
  const fmMatch = FRONTMATTER_RE.exec(text);
186
197
  const fm = fmMatch ? fmMatch[1] : "";
@@ -253,7 +264,9 @@ export function mapAdrCorpus(sources) {
253
264
  for (const src of sources) {
254
265
  const p = parseAdrMarkdown(src.text, src.relPath);
255
266
  if (!p) {
256
- warnings.push(`${src.relPath}: filename does not follow NNNN-slug.md — skipped`);
267
+ if (!isAdrCorpusTemplate(src.relPath)) {
268
+ warnings.push(`${src.relPath}: filename does not follow NNNN-slug.md — skipped`);
269
+ }
257
270
  continue;
258
271
  }
259
272
  parsed.push(p);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.20.0-rc.4",
3
+ "version": "1.20.0-rc.5",
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://hunch-pi.vercel.app",
10
- "version": "1.20.0-rc.4",
10
+ "version": "1.20.0-rc.5",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.20.0-rc.4",
16
+ "version": "1.20.0-rc.5",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {