@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.
- package/dist/core/outcomeExperience.js +223 -0
- package/dist/extractors/adrImport.js +15 -2
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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 (
|
|
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
|
-
|
|
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
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.
|
|
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.
|
|
16
|
+
"version": "1.20.0-rc.5",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|