@intentius/chant 0.90.0 → 0.91.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 (164) hide show
  1. package/dist/cli/handlers/components.d.ts +8 -0
  2. package/dist/cli/handlers/components.d.ts.map +1 -1
  3. package/dist/cli/handlers/operator.d.ts +14 -0
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/run.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/mcp/workspace-tools.d.ts +6 -4
  8. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +31 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  12. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  13. package/dist/lifecycle/git.d.ts +40 -5
  14. package/dist/lifecycle/git.d.ts.map +1 -1
  15. package/dist/lifecycle/lease.d.ts +72 -16
  16. package/dist/lifecycle/lease.d.ts.map +1 -1
  17. package/dist/lifecycle/member-ledger.d.ts +3 -2
  18. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  19. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  20. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  21. package/dist/lifecycle/work-lease.d.ts +140 -0
  22. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  23. package/dist/op/activities/activity-contracts.d.ts +2 -2
  24. package/dist/op/builders.d.ts.map +1 -1
  25. package/dist/op/discover.d.ts +25 -0
  26. package/dist/op/discover.d.ts.map +1 -1
  27. package/dist/op/index.d.ts +9 -3
  28. package/dist/op/index.d.ts.map +1 -1
  29. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  30. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  31. package/dist/op/local-executor.d.ts +19 -0
  32. package/dist/op/local-executor.d.ts.map +1 -1
  33. package/dist/op/local-output.d.ts.map +1 -1
  34. package/dist/op/op-ir.d.ts +10 -1
  35. package/dist/op/op-ir.d.ts.map +1 -1
  36. package/dist/op/operator.d.ts +29 -0
  37. package/dist/op/operator.d.ts.map +1 -1
  38. package/dist/op/runtime.d.ts +9 -0
  39. package/dist/op/runtime.d.ts.map +1 -1
  40. package/dist/op/runtimes/local.d.ts.map +1 -1
  41. package/dist/op/step-output-ref.d.ts +2 -2
  42. package/dist/op/step-output-ref.d.ts.map +1 -1
  43. package/dist/op/steward.d.ts +140 -0
  44. package/dist/op/steward.d.ts.map +1 -0
  45. package/dist/op/types.d.ts +51 -0
  46. package/dist/op/types.d.ts.map +1 -1
  47. package/dist/op/work-lease-decl.d.ts +18 -0
  48. package/dist/op/work-lease-decl.d.ts.map +1 -0
  49. package/dist/op/work-lease-run.d.ts +173 -0
  50. package/dist/op/work-lease-run.d.ts.map +1 -0
  51. package/dist/workspace/box-isolation.d.ts +99 -0
  52. package/dist/workspace/box-isolation.d.ts.map +1 -0
  53. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  54. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  55. package/dist/workspace/checks/boxes.d.ts +71 -0
  56. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  57. package/dist/workspace/checks/records.d.ts +1 -0
  58. package/dist/workspace/checks/records.d.ts.map +1 -1
  59. package/dist/workspace/checks.d.ts +10 -1
  60. package/dist/workspace/checks.d.ts.map +1 -1
  61. package/dist/workspace/decide.d.ts +184 -0
  62. package/dist/workspace/decide.d.ts.map +1 -0
  63. package/dist/workspace/decision-points.schema.json +137 -0
  64. package/dist/workspace/declaration.d.ts +51 -0
  65. package/dist/workspace/declaration.d.ts.map +1 -1
  66. package/dist/workspace/declaration.schema.json +172 -0
  67. package/dist/workspace/declared-kinds.d.ts +12 -0
  68. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  69. package/dist/workspace/points-cli.d.ts +113 -0
  70. package/dist/workspace/points-cli.d.ts.map +1 -0
  71. package/dist/workspace/points.d.ts +320 -0
  72. package/dist/workspace/points.d.ts.map +1 -0
  73. package/dist/workspace/reason-codes.d.ts +26 -0
  74. package/dist/workspace/reason-codes.d.ts.map +1 -1
  75. package/dist/workspace/record-assets.d.ts.map +1 -1
  76. package/dist/workspace/records-cli.d.ts +12 -0
  77. package/dist/workspace/records-cli.d.ts.map +1 -1
  78. package/dist/workspace/records.d.ts +8 -2
  79. package/dist/workspace/records.d.ts.map +1 -1
  80. package/dist/workspace/status-stewards.d.ts +121 -0
  81. package/dist/workspace/status-stewards.d.ts.map +1 -0
  82. package/dist/workspace/status.d.ts +52 -1
  83. package/dist/workspace/status.d.ts.map +1 -1
  84. package/dist/workspace/work-cli.d.ts +78 -0
  85. package/dist/workspace/work-cli.d.ts.map +1 -0
  86. package/package.json +1 -1
  87. package/src/cli/handlers/components.test.ts +93 -0
  88. package/src/cli/handlers/components.ts +44 -3
  89. package/src/cli/handlers/operator.ts +107 -2
  90. package/src/cli/handlers/run.test.ts +19 -0
  91. package/src/cli/handlers/run.ts +53 -1
  92. package/src/cli/main.test.ts +40 -0
  93. package/src/cli/main.ts +78 -2
  94. package/src/cli/mcp/workspace-tools.test.ts +14 -1
  95. package/src/cli/mcp/workspace-tools.ts +49 -5
  96. package/src/cli/registry.ts +31 -0
  97. package/src/components/verbs/vuln-scan.test.ts +124 -1
  98. package/src/components/verbs/vuln-scan.ts +142 -1
  99. package/src/lifecycle/git.ts +65 -15
  100. package/src/lifecycle/lease.test.ts +22 -0
  101. package/src/lifecycle/lease.ts +133 -29
  102. package/src/lifecycle/member-ledger.ts +3 -2
  103. package/src/lifecycle/plan-ledger.test.ts +148 -0
  104. package/src/lifecycle/plan-ledger.ts +158 -0
  105. package/src/lifecycle/work-lease.test.ts +236 -0
  106. package/src/lifecycle/work-lease.ts +426 -0
  107. package/src/op/builders.ts +5 -0
  108. package/src/op/discover.ts +71 -0
  109. package/src/op/index.ts +16 -3
  110. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  111. package/src/op/lifecycle-receipt-store.ts +61 -0
  112. package/src/op/local-executor.ts +216 -18
  113. package/src/op/local-output.ts +13 -0
  114. package/src/op/op-ir.ts +14 -0
  115. package/src/op/operator.ts +75 -4
  116. package/src/op/runtime.ts +6 -0
  117. package/src/op/runtimes/local.ts +3 -0
  118. package/src/op/step-output-ref.ts +6 -2
  119. package/src/op/steward.test.ts +212 -0
  120. package/src/op/steward.ts +253 -0
  121. package/src/op/types.ts +53 -0
  122. package/src/op/work-lease-decl.ts +80 -0
  123. package/src/op/work-lease-run.test.ts +326 -0
  124. package/src/op/work-lease-run.ts +395 -0
  125. package/src/workspace/box-isolation.test.ts +261 -0
  126. package/src/workspace/box-isolation.ts +205 -0
  127. package/src/workspace/check-contract.test.ts +3 -1
  128. package/src/workspace/check.schema.json +15 -7
  129. package/src/workspace/checks/box-isolation.ts +68 -0
  130. package/src/workspace/checks/boxes.test.ts +197 -0
  131. package/src/workspace/checks/boxes.ts +307 -0
  132. package/src/workspace/checks/records.ts +25 -0
  133. package/src/workspace/checks.test.ts +7 -0
  134. package/src/workspace/checks.ts +19 -2
  135. package/src/workspace/decide.test.ts +224 -0
  136. package/src/workspace/decide.ts +576 -0
  137. package/src/workspace/decision-points.schema.json +137 -0
  138. package/src/workspace/declaration.schema.json +172 -0
  139. package/src/workspace/declaration.ts +137 -0
  140. package/src/workspace/declared-kinds.ts +25 -2
  141. package/src/workspace/intent.schema.json +4 -1
  142. package/src/workspace/point-answer.schema.json +95 -0
  143. package/src/workspace/points-cli.ts +273 -0
  144. package/src/workspace/points-write.schema.json +489 -0
  145. package/src/workspace/points.schema.json +710 -0
  146. package/src/workspace/points.test.ts +264 -0
  147. package/src/workspace/points.ts +564 -0
  148. package/src/workspace/read-contract.test.ts +18 -1
  149. package/src/workspace/reason-codes.test.ts +14 -1
  150. package/src/workspace/reason-codes.ts +33 -0
  151. package/src/workspace/record-assets.test.ts +3 -2
  152. package/src/workspace/record-assets.ts +4 -1
  153. package/src/workspace/records-cli.ts +15 -2
  154. package/src/workspace/records-contract.test.ts +3 -2
  155. package/src/workspace/records.schema.json +17 -0
  156. package/src/workspace/records.ts +33 -3
  157. package/src/workspace/status-contract.test.ts +178 -0
  158. package/src/workspace/status-stewards.ts +225 -0
  159. package/src/workspace/status.schema.json +230 -4
  160. package/src/workspace/status.ts +106 -5
  161. package/src/workspace/work-cli.test.ts +180 -0
  162. package/src/workspace/work-cli.ts +246 -0
  163. package/src/workspace/work-lease.schema.json +233 -0
  164. package/src/workspace/work-readiness-chud.test.ts +145 -0
@@ -0,0 +1,564 @@
1
+ /**
2
+ * Decision points (ws-058, #2738): the recurring questions a workspace asks of
3
+ * its own graph, declared as data, and the answers they leave (#2739).
4
+ *
5
+ * A points file is JSON, validated against `decision-points.schema.json`, and
6
+ * named by a record kind's `answers.points`: the kind whose records are the
7
+ * answers. Each point is a typed question (`noul`, `choice` or `score`, the
8
+ * question types of the POST /v1/systemone wire format, #2491), the inputs it
9
+ * reads, each named as a read-contract output, and an ordered chain of
10
+ * deciders:
11
+ *
12
+ * table rows of { when, answer }; the first row whose conditions all hold answers.
13
+ * model a backend asked the question with the inputs as its state, at a
14
+ * pinned model id. An answer at or above the threshold is a
15
+ * proposal a person confirms; below it, the next decider is asked.
16
+ * quorum people. Always last: the question escalates to them.
17
+ *
18
+ * chant never calls a model (ws-052). {@link runChain} takes the model call
19
+ * as a function, {@link ModelAsk}, which the decide Op activity (#2740), a
20
+ * runtime's decider or a test's stub supplies. Without one, a model decider is
21
+ * not asked and the chain moves on.
22
+ *
23
+ * Taken from chud's `packages/runtime/src/decide.mjs` at 43afcf1: the chain,
24
+ * the observation rule, the point version and the inputs hash. The records
25
+ * are chant records: `decide.ts` writes them, and {@link applyAnswers} warns
26
+ * about them on read.
27
+ */
28
+
29
+ import { createHash } from "node:crypto";
30
+ import { createRequire } from "node:module";
31
+ import { dirname, posix, relative, resolve, sep } from "node:path";
32
+ import schema from "./decision-points.schema.json";
33
+ import type { ReasonCode } from "./reason-codes";
34
+ import type { RecordSource } from "./record-source";
35
+ import type { LoadedRecordKind, ReadRecordsOptions, RecordEntry } from "./records";
36
+
37
+ export const DECISION_POINTS_SCHEMA_ID = schema.$id;
38
+
39
+ // ── The read-contract outputs an input may name ─────────────────────────────
40
+
41
+ /**
42
+ * The read-contract outputs a point's input may name (#2738), each with the
43
+ * output schema and `$defs` entry that describes it. Closed: an input naming
44
+ * anything else is refused. `record`, `decision` and `work-item` are records
45
+ * as `records --json` lists them, of any kind, a decision kind and a work kind.
46
+ */
47
+ export const POINT_INPUT_OUTPUTS = {
48
+ record: { schema: "records", def: "record", description: "a record of any kind, as records --json lists it" },
49
+ decision: { schema: "records", def: "record", description: "a decision record, as records --json lists it" },
50
+ "work-item": { schema: "records", def: "record", description: "a work item record, as records --json lists it (#2683)" },
51
+ finding: { schema: "intent", def: "finding", description: "a finding of graph --intent" },
52
+ region: { schema: "intent", def: "region", description: "the region an intent graph covers" },
53
+ commit: { schema: "intent", def: "commit", description: "a commit in an intent graph's window" },
54
+ member: { schema: "ls", def: "member", description: "a member, as ls lists it" },
55
+ gate: { schema: "status", def: "gate", description: "a gate, as status lists it" },
56
+ release: { schema: "status", def: "release", description: "a release, as status lists it; a release plan's fields until the lifecycle ledger lists plans (#2717)" },
57
+ environment: { schema: "status", def: "environment", description: "an environment, as status lists it" },
58
+ component: { schema: "composites", def: "component", description: "a component, as graph --composites lists it" },
59
+ } as const;
60
+ export type PointInputOutput = keyof typeof POINT_INPUT_OUTPUTS;
61
+ export const POINT_INPUT_OUTPUT_NAMES = Object.keys(POINT_INPUT_OUTPUTS) as PointInputOutput[];
62
+
63
+ /** The output an input name reads: the part before its first dot. */
64
+ export function inputOutput(name: string): string {
65
+ const dot = name.indexOf(".");
66
+ return dot < 0 ? name : name.slice(0, dot);
67
+ }
68
+
69
+ // ── The declaration ──────────────────────────────────────────────────────────
70
+
71
+ export type QuestionType = "noul" | "choice" | "score";
72
+
73
+ export interface Question {
74
+ /** `boolean` in a points file is read as `noul`. */
75
+ type: QuestionType;
76
+ instructions: string;
77
+ criteria: Record<string, string> | string[];
78
+ }
79
+
80
+ export type Scalar = string | number | boolean | null;
81
+ export type Condition = Scalar | { eq?: Scalar; ne?: Scalar; lt?: number; lte?: number; gt?: number; gte?: number; in?: Scalar[] };
82
+
83
+ export interface TableRow {
84
+ when: Record<string, Condition>;
85
+ answer: string | boolean;
86
+ note?: string;
87
+ }
88
+
89
+ export type Decider =
90
+ | { kind: "table"; rows: TableRow[]; note?: string }
91
+ | { kind: "model"; backend: string; model: string; threshold: number; unreachable?: "escalate" | "fail"; note?: string }
92
+ | { kind: "quorum"; count: number; roles?: string[]; note?: string };
93
+
94
+ export interface Point {
95
+ title: string;
96
+ question: Question;
97
+ inputs: Record<string, string>;
98
+ deciders: Decider[];
99
+ }
100
+
101
+ /** One problem with a points file: the JSON path of the field, or null for the file, and what is wrong. */
102
+ export interface PointProblem {
103
+ field: string | null;
104
+ message: string;
105
+ }
106
+
107
+ export class PointsError extends Error {
108
+ constructor(
109
+ readonly file: string,
110
+ readonly problems: PointProblem[],
111
+ ) {
112
+ super(`${file}: ${problems.map((p) => (p.field ? `${p.field} ${p.message}` : p.message)).join("; ")}`);
113
+ this.name = "PointsError";
114
+ }
115
+ }
116
+
117
+ /** A question's candidate answers: true and false, a choice's options, or a score's levels. */
118
+ export function candidates(question: Question): (string | boolean)[] {
119
+ if (question.type === "noul") return [true, false];
120
+ if (question.type === "choice") return Object.keys(question.criteria);
121
+ return [...(question.criteria as string[])];
122
+ }
123
+
124
+ const ONLY: Record<Decider["kind"], string[]> = { table: ["rows"], model: ["backend", "model", "threshold", "unreachable"], quorum: ["count", "roles"] };
125
+
126
+ /** A model id that names an alias rather than a release, such as jev-latest or jev-preview (#2491). */
127
+ const ALIAS = /(^|[-_./])(latest|preview|stable|current)$/i;
128
+
129
+ /** What the schema cannot say about the points (chud's pointProblems, and the input and chain rules of #2738). */
130
+ function pointProblems(points: Record<string, Point>): PointProblem[] {
131
+ const problems: PointProblem[] = [];
132
+ for (const [name, point] of Object.entries(points)) {
133
+ const at = (...path: (string | number)[]) => ["points", name, ...path].join(".");
134
+ const inputs = Object.keys(point.inputs);
135
+ for (const input of inputs) {
136
+ const output = inputOutput(input);
137
+ if (!(POINT_INPUT_OUTPUT_NAMES as string[]).includes(output)) {
138
+ problems.push({
139
+ field: at("inputs", input),
140
+ message: `names ${JSON.stringify(output)}, which is not a read-contract output; an input is one of ${POINT_INPUT_OUTPUT_NAMES.join(", ")}, optionally with dotted field names`,
141
+ });
142
+ }
143
+ }
144
+ const allowed = candidates(point.question);
145
+ const last = point.deciders.length - 1;
146
+ point.deciders.forEach((d, i) => {
147
+ const raw = d as unknown as Record<string, unknown>;
148
+ for (const [kind, fields] of Object.entries(ONLY)) {
149
+ if (kind === d.kind) continue;
150
+ for (const field of fields) {
151
+ if (raw[field] !== undefined && !ONLY[d.kind].includes(field)) problems.push({ field: at("deciders", i, field), message: `is only for a ${kind} decider, and this one is a ${d.kind}` });
152
+ }
153
+ }
154
+ if (d.kind === "quorum" && i !== last) problems.push({ field: at("deciders", i), message: "is a quorum, and a quorum is the last decider: nobody is asked after people" });
155
+ if (d.kind === "model" && ALIAS.test(d.model)) {
156
+ problems.push({ field: at("deciders", i, "model"), message: `${JSON.stringify(d.model)} is an alias, and an alias moves when a new release ships: pin a versioned model id` });
157
+ }
158
+ if (d.kind === "table") {
159
+ d.rows.forEach((row, r) => {
160
+ if (!allowed.includes(row.answer)) {
161
+ problems.push({ field: at("deciders", i, "rows", r, "answer"), message: `must be one of ${allowed.map((a) => JSON.stringify(a)).join(", ")}, not ${JSON.stringify(row.answer)}` });
162
+ }
163
+ for (const key of Object.keys(row.when)) {
164
+ if (!inputs.includes(key)) problems.push({ field: at("deciders", i, "rows", r, "when", key), message: `is not one of this point's inputs (${inputs.join(", ")})` });
165
+ }
166
+ });
167
+ }
168
+ });
169
+ if (point.deciders[last]?.kind !== "quorum") {
170
+ problems.push({ field: at("deciders"), message: "must end in a quorum: when no table row or model answers, people do" });
171
+ }
172
+ }
173
+ return problems;
174
+ }
175
+
176
+ interface AjvError {
177
+ instancePath: string;
178
+ keyword: string;
179
+ message?: string;
180
+ params: Record<string, unknown>;
181
+ }
182
+ type Validate = ((data: unknown) => boolean) & { errors?: AjvError[] | null };
183
+
184
+ let compiled: Validate | undefined;
185
+ function validator(): Validate {
186
+ if (compiled) return compiled;
187
+ const require = createRequire(import.meta.url);
188
+ // ajv is CommonJS; its class is the default export, or that export's own default.
189
+ const mod = require("ajv/dist/2020") as { default?: unknown };
190
+ const Ajv = (mod.default ?? mod) as new (opts: object) => { compile(s: object): Validate };
191
+ compiled = new Ajv({ allErrors: true, strict: false }).compile(schema);
192
+ return compiled;
193
+ }
194
+
195
+ const SUMMARY = new Set(["if", "then", "else", "oneOf", "anyOf", "allOf"]);
196
+
197
+ /** The schema's problems with `data`, as JSON paths. */
198
+ export function schemaProblems(data: unknown): PointProblem[] {
199
+ const validate = validator();
200
+ if (validate(data)) return [];
201
+ const seen = new Set<string>();
202
+ const out: PointProblem[] = [];
203
+ for (const e of validate.errors ?? []) {
204
+ if (SUMMARY.has(e.keyword)) continue;
205
+ const path = e.instancePath.split("/").slice(1).map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
206
+ const field = path.length ? path.join(".") : null;
207
+ const message =
208
+ e.keyword === "additionalProperties"
209
+ ? `has an unknown field ${JSON.stringify(e.params.additionalProperty)}; only fields named x-... may be added`
210
+ : e.keyword === "required"
211
+ ? `is missing ${JSON.stringify(e.params.missingProperty)}`
212
+ : e.keyword === "propertyNames"
213
+ ? `has a name that is not allowed: ${JSON.stringify(e.params.propertyName)}`
214
+ : (e.message ?? "is invalid");
215
+ const key = `${field}\0${message}`;
216
+ if (seen.has(key)) continue;
217
+ seen.add(key);
218
+ out.push({ field, message });
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /** `boolean` read as `noul`. The declaration is otherwise kept as written. */
224
+ function normalise(points: Record<string, Point>): Record<string, Point> {
225
+ const out: Record<string, Point> = {};
226
+ for (const [name, p] of Object.entries(points)) {
227
+ const type = (p.question.type as string) === "boolean" ? "noul" : p.question.type;
228
+ out[name] = { ...p, question: { ...p.question, type } };
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /**
234
+ * Parse and validate a points file's text. Returns its points, with
235
+ * `boolean` read as `noul`. Throws a {@link PointsError} naming the file and
236
+ * each field.
237
+ */
238
+ export function parsePoints(text: string, file: string): Record<string, Point> {
239
+ let data: unknown;
240
+ try {
241
+ data = JSON.parse(text);
242
+ } catch (err) {
243
+ throw new PointsError(file, [{ field: null, message: `is not JSON: ${err instanceof Error ? err.message : String(err)}` }]);
244
+ }
245
+ const problems = schemaProblems(data);
246
+ if (problems.length > 0) throw new PointsError(file, problems);
247
+ const points = normalise((data as { points: Record<string, Point> }).points);
248
+ const more = pointProblems(points);
249
+ if (more.length > 0) throw new PointsError(file, more);
250
+ return points;
251
+ }
252
+
253
+ /** One point by name, or undefined. */
254
+ export function pointOf(points: Record<string, Point>, name: string): Point | undefined {
255
+ return Object.prototype.hasOwnProperty.call(points, name) ? points[name] : undefined;
256
+ }
257
+
258
+ /** JSON with keys sorted and undefined members left out, the text every hash here is taken over. */
259
+ export function canonical(value: unknown): string {
260
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
261
+ if (value !== null && typeof value === "object") {
262
+ const o = value as Record<string, unknown>;
263
+ return `{${Object.keys(o)
264
+ .sort()
265
+ .filter((k) => o[k] !== undefined)
266
+ .map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`)
267
+ .join(",")}}`;
268
+ }
269
+ return JSON.stringify(value);
270
+ }
271
+
272
+ const sha256 = (text: string): string => createHash("sha256").update(text, "utf8").digest("hex");
273
+
274
+ /** The version of a point: the sha256 of its declaration. Editing the question or any decider changes it. */
275
+ export const pointVersion = (point: Point): string => sha256(canonical(point));
276
+
277
+ /** What is answered once: the point, its version and the inputs. */
278
+ export const inputsHash = (name: string, version: string, inputs: Record<string, unknown>): string => sha256(canonical({ point: name, version, inputs }));
279
+
280
+ /** The id of the answer to `name` for inputs hashing to `hash`: the name and the hash's first 12 hex digits. */
281
+ export const answerId = (name: string, hash: string): string => `${name}-${hash.slice(0, 12)}`;
282
+
283
+ /** The quorum a point ends in. */
284
+ export function quorumOf(point: Point): { count: number; roles?: string[] } {
285
+ const last = point.deciders[point.deciders.length - 1];
286
+ return last?.kind === "quorum" ? { count: last.count, ...(last.roles ? { roles: last.roles } : {}) } : { count: 1 };
287
+ }
288
+
289
+ // ── The chain ────────────────────────────────────────────────────────────────
290
+
291
+ function holds(condition: Condition, value: unknown): boolean {
292
+ if (condition === null || typeof condition !== "object") return value === condition;
293
+ const [[op, x]] = Object.entries(condition) as [string, unknown][];
294
+ const number = typeof value === "number";
295
+ switch (op) {
296
+ case "eq":
297
+ return value === x;
298
+ case "ne":
299
+ return value !== x;
300
+ case "lt":
301
+ return number && value < (x as number);
302
+ case "lte":
303
+ return number && value <= (x as number);
304
+ case "gt":
305
+ return number && value > (x as number);
306
+ case "gte":
307
+ return number && value >= (x as number);
308
+ case "in":
309
+ return (x as unknown[]).includes(value);
310
+ default:
311
+ return false;
312
+ }
313
+ }
314
+
315
+ /** Whether every condition in a row's `when` holds for these inputs. An input the state lacks never matches. */
316
+ export const matches = (when: Record<string, Condition>, inputs: Record<string, unknown>): boolean =>
317
+ Object.entries(when).every(([key, condition]) => Object.prototype.hasOwnProperty.call(inputs, key) && holds(condition, inputs[key]));
318
+
319
+ /** A question as the POST /v1/systemone wire format asks it (#2491). */
320
+ export interface WireQuestion {
321
+ type: QuestionType;
322
+ instructions: string;
323
+ criteria: Record<string, string> | string[];
324
+ }
325
+
326
+ /** One answer in the wire format's shape. */
327
+ export type WireAnswer =
328
+ | { type: "noul"; noul: number }
329
+ | { type: "choice"; choice: string; probabilities?: Record<string, number>; confidence?: number }
330
+ | { type: "score"; score?: number; legend?: unknown; probabilities?: Record<string, number>; confidence?: number }
331
+ | { type: "unsupported" };
332
+
333
+ export function wireQuestion(question: Question): WireQuestion {
334
+ return { type: question.type, instructions: question.instructions, criteria: question.criteria };
335
+ }
336
+
337
+ /** What a model decider is asked. */
338
+ export interface ModelRequest {
339
+ point: string;
340
+ backend: string;
341
+ /** The pinned model id. */
342
+ model: string;
343
+ question: WireQuestion;
344
+ /** The inputs, as the state. */
345
+ state: Record<string, unknown>;
346
+ }
347
+
348
+ /**
349
+ * The model call, supplied by the caller: the decide Op activity (#2740), a
350
+ * runtime's decider, or a test's stub. It resolves with the model id that
351
+ * answered and one answer in the wire format's shape. Throwing means the
352
+ * backend could not be reached or gave nothing usable.
353
+ */
354
+ export type ModelAsk = (request: ModelRequest) => Promise<{ model: string; answer: WireAnswer }>;
355
+
356
+ const round = (x: number): number => Math.round(x * 1e6) / 1e6;
357
+
358
+ export type Observation =
359
+ | { observed: true; answer: string | boolean; probabilities: Record<string, number>; confidence: number }
360
+ | { observed: false; answer?: string | boolean | null; probabilities?: Record<string, number>; confidence?: number; reason: string };
361
+
362
+ /**
363
+ * A model's wire answer against the question and the decider's threshold
364
+ * (chud's rule):
365
+ *
366
+ * - noul p: true when p is at least the threshold, false when 1 - p is,
367
+ * otherwise not observed. Its confidence is max(p, 1 - p).
368
+ * - choice and score: the choice (a score's most probable level) when the
369
+ * reported confidence is at least the threshold. A backend that reports no
370
+ * confidence is read by its largest probability.
371
+ *
372
+ * An answer outside the candidates, or an unsupported question, is not observed.
373
+ */
374
+ export function observe(question: Question, answer: WireAnswer | undefined, threshold: number): Observation {
375
+ const allowed = candidates(question);
376
+ if (!answer || answer.type === "unsupported") return { observed: false, reason: "the backend does not support this question type" };
377
+ if (question.type === "noul") {
378
+ const p = Number((answer as { noul?: unknown }).noul);
379
+ if (!(p >= 0 && p <= 1)) return { observed: false, reason: "the answer carries no probability" };
380
+ const probabilities = { true: round(p), false: round(1 - p) };
381
+ const confidence = round(Math.max(p, 1 - p));
382
+ if (p >= threshold) return { observed: true, answer: true, probabilities, confidence };
383
+ if (1 - p >= threshold) return { observed: true, answer: false, probabilities, confidence };
384
+ return { observed: false, answer: p >= 0.5, probabilities, confidence, reason: `confidence ${confidence} is below the threshold ${threshold}` };
385
+ }
386
+ const a = answer as { choice?: unknown; probabilities?: Record<string, unknown>; confidence?: unknown };
387
+ const probabilities = Object.fromEntries(
388
+ Object.entries(a.probabilities ?? {})
389
+ .map(([k, v]) => [k, Number(v)] as const)
390
+ .filter(([, v]) => Number.isFinite(v) && v >= 0 && v <= 1)
391
+ .map(([k, v]) => [k, round(v)]),
392
+ );
393
+ const choice = question.type === "choice" ? a.choice : Object.entries(probabilities).sort((x, y) => y[1] - x[1])[0]?.[0];
394
+ const reported = Number(a.confidence);
395
+ const confidence = round(a.confidence !== undefined && Number.isFinite(reported) ? reported : Math.max(0, ...Object.values(probabilities)));
396
+ if (typeof choice !== "string" || !allowed.includes(choice)) {
397
+ return { observed: false, answer: typeof choice === "string" ? choice : null, probabilities, confidence, reason: `the answer ${JSON.stringify(choice ?? null)} is not a candidate` };
398
+ }
399
+ if (confidence >= threshold) return { observed: true, answer: choice, probabilities, confidence };
400
+ return { observed: false, answer: choice, probabilities, confidence, reason: `confidence ${confidence} is below the threshold ${threshold}` };
401
+ }
402
+
403
+ /** A decider that was asked and did not answer, and why. */
404
+ export interface Escalation {
405
+ kind: "table" | "model";
406
+ reason: string;
407
+ backend?: string;
408
+ model?: string;
409
+ answer?: string | boolean | null;
410
+ probabilities?: Record<string, number>;
411
+ confidence?: number;
412
+ threshold?: number;
413
+ }
414
+
415
+ export type ChainResult =
416
+ | { status: "answered"; answer: string | boolean; decider: { kind: "table"; row: number }; note?: string; escalations: Escalation[] }
417
+ | {
418
+ status: "proposed";
419
+ answer: string | boolean;
420
+ decider: { kind: "model"; backend: string; model: string };
421
+ probabilities: Record<string, number>;
422
+ confidence: number;
423
+ threshold: number;
424
+ escalations: Escalation[];
425
+ }
426
+ | { status: "escalated"; decider: { kind: "quorum"; count: number; roles?: string[] }; escalations: Escalation[] };
427
+
428
+ /** A model decider declared `unreachable: "fail"` could not answer, so the ask fails and nothing is written. */
429
+ export class DeciderFailed extends Error {
430
+ constructor(message: string) {
431
+ super(message);
432
+ this.name = "DeciderFailed";
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Ask a point's deciders in order. A table row answers; a model at or above
438
+ * its threshold proposes; otherwise the question escalates to the quorum.
439
+ * Each decider that did not answer is in `escalations`, with why. A model
440
+ * decider is asked through `ask`; without it, it is not asked.
441
+ */
442
+ export async function runChain(name: string, point: Point, inputs: Record<string, unknown>, ask?: ModelAsk): Promise<ChainResult> {
443
+ const escalations: Escalation[] = [];
444
+ for (const d of point.deciders) {
445
+ if (d.kind === "table") {
446
+ const row = d.rows.findIndex((r) => matches(r.when, inputs));
447
+ if (row >= 0) {
448
+ return { status: "answered", answer: d.rows[row].answer, decider: { kind: "table", row }, ...(d.rows[row].note ? { note: d.rows[row].note } : {}), escalations };
449
+ }
450
+ escalations.push({ kind: "table", reason: "no row matches these inputs" });
451
+ } else if (d.kind === "model") {
452
+ if (!ask) {
453
+ escalations.push({ kind: "model", backend: d.backend, model: d.model, threshold: d.threshold, reason: "not asked: no model backend was given to this ask" });
454
+ continue;
455
+ }
456
+ let reply: { model: string; answer: WireAnswer };
457
+ try {
458
+ reply = await ask({ point: name, backend: d.backend, model: d.model, question: wireQuestion(point.question), state: inputs });
459
+ } catch (err) {
460
+ const why = `${d.backend} could not answer: ${err instanceof Error ? err.message : String(err)}`;
461
+ if (d.unreachable === "fail") throw new DeciderFailed(`${name}: ${why}, and the point fails closed (unreachable: "fail")`);
462
+ escalations.push({ kind: "model", backend: d.backend, model: d.model, threshold: d.threshold, reason: `not observed: ${why}` });
463
+ continue;
464
+ }
465
+ if (reply.model !== d.model) {
466
+ escalations.push({ kind: "model", backend: d.backend, model: reply.model, threshold: d.threshold, reason: `not observed: ${reply.model} answered, and the point pins ${d.model}` });
467
+ continue;
468
+ }
469
+ const seen = observe(point.question, reply.answer, d.threshold);
470
+ if (seen.observed) {
471
+ return {
472
+ status: "proposed",
473
+ answer: seen.answer,
474
+ decider: { kind: "model", backend: d.backend, model: reply.model },
475
+ probabilities: seen.probabilities,
476
+ confidence: seen.confidence,
477
+ threshold: d.threshold,
478
+ escalations,
479
+ };
480
+ }
481
+ escalations.push({
482
+ kind: "model",
483
+ backend: d.backend,
484
+ model: reply.model,
485
+ answer: seen.answer ?? null,
486
+ ...(seen.probabilities ? { probabilities: seen.probabilities } : {}),
487
+ ...(seen.confidence !== undefined ? { confidence: seen.confidence } : {}),
488
+ threshold: d.threshold,
489
+ reason: `not observed: ${seen.reason}`,
490
+ });
491
+ } else {
492
+ return { status: "escalated", decider: { kind: "quorum", count: d.count, ...(d.roles ? { roles: d.roles } : {}) }, escalations };
493
+ }
494
+ }
495
+ // Unreachable for a valid point, which ends in a quorum.
496
+ return { status: "escalated", decider: { kind: "quorum", count: 1 }, escalations };
497
+ }
498
+
499
+ // ── Answers on read ──────────────────────────────────────────────────────────
500
+
501
+ /** Why an answer record carries a warning. Closed, like the record warning codes. */
502
+ export const ANSWER_WARNING_CODES = [
503
+ /** The points file the answer kind names can't be read, or is not valid (#2738). */
504
+ "answer-points-unreadable",
505
+ /** The answer's point is not in the points file. */
506
+ "answer-point-unknown",
507
+ /** The point's declaration changed since the answer: it answers an older version of the question. */
508
+ "answer-point-changed",
509
+ ] as const satisfies readonly ReasonCode[];
510
+ export type AnswerWarningCode = (typeof ANSWER_WARNING_CODES)[number];
511
+
512
+ /** The points file an answer kind names, from `root` with / separators. */
513
+ export function pointsFileOf(loaded: LoadedRecordKind, root: string): string {
514
+ const abs = resolve(dirname(loaded.file), loaded.kind.answers!.points);
515
+ return relative(root, abs).split(sep).join(posix.sep);
516
+ }
517
+
518
+ /** The points an answer kind names, read from `source` (the tree read), or the problems reading them. */
519
+ export function readPointsThrough(source: RecordSource, file: string): { points: Record<string, Point> } | { error: string; problems: PointProblem[] } {
520
+ // A revision's source reads only files in a directory it has listed.
521
+ const names = source.list(posix.dirname(file));
522
+ if (!names?.includes(posix.basename(file))) {
523
+ return { error: `the points file ${file} does not exist${source.label}`, problems: [{ field: null, message: `does not exist${source.label}` }] };
524
+ }
525
+ let text: string;
526
+ try {
527
+ text = source.read(file);
528
+ } catch (err) {
529
+ return { error: `the points file ${file} can't be read: ${err instanceof Error ? err.message : String(err)}`, problems: [{ field: null, message: "can't be read" }] };
530
+ }
531
+ try {
532
+ return { points: parsePoints(text, file) };
533
+ } catch (err) {
534
+ if (err instanceof PointsError) return { error: `the points file is not valid: ${err.message}`, problems: err.problems };
535
+ throw err;
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Give an answer kind's records their warnings, in place: the points file
541
+ * can't be read, the answer's point is gone, or the point changed since. They
542
+ * never make a record invalid.
543
+ */
544
+ export function applyAnswers(loaded: LoadedRecordKind, entries: RecordEntry[], options: ReadRecordsOptions): void {
545
+ const file = pointsFileOf(loaded, options.root);
546
+ const read = readPointsThrough(options.source, file);
547
+ for (const e of entries) {
548
+ if (e.data === null) continue;
549
+ if ("error" in read) {
550
+ e.warnings.push({ code: "answer-points-unreadable", message: read.error });
551
+ continue;
552
+ }
553
+ const name = e.data.point;
554
+ if (typeof name !== "string") continue;
555
+ const point = pointOf(read.points, name);
556
+ if (!point) {
557
+ e.warnings.push({ code: "answer-point-unknown", message: `answers ${name}, which ${file} does not declare` });
558
+ continue;
559
+ }
560
+ if (typeof e.data.point_version === "string" && e.data.point_version !== pointVersion(point)) {
561
+ e.warnings.push({ code: "answer-point-changed", message: `answers ${name} as it was declared at ${e.data.point_version.slice(0, 12)}, and ${file} declares it at ${pointVersion(point).slice(0, 12)} now: ask again for the question as it stands` });
562
+ }
563
+ }
564
+ }
@@ -36,13 +36,15 @@ import { queryRecords } from "./records-cli";
36
36
  import recordsSchema from "./records.schema.json";
37
37
  import { queryRecordsSince } from "./records-since";
38
38
  import recordsSinceSchema from "./records-since.schema.json";
39
+ import { workspacePoints } from "./points-cli";
40
+ import pointsSchema from "./points.schema.json";
39
41
  import { workspaceStatus } from "./status";
40
42
  import statusSchema from "./status.schema.json";
41
43
 
42
44
  const FIXTURE = join(REPO, "reference-workspace");
43
45
  const TIMEOUT = 240_000;
44
46
 
45
- const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema, "records-since": recordsSinceSchema, intent: intentSchema, composites: compositesSchema };
47
+ const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema, "records-since": recordsSinceSchema, intent: intentSchema, composites: compositesSchema, points: pointsSchema };
46
48
 
47
49
  /** This checkout's chant, started the way the CLI starts it, for members with no toolchain of their own. */
48
50
  const reader: Toolchain = {
@@ -184,6 +186,21 @@ describe("every schema against the reference workspace (#2543)", () => {
184
186
  expect(sessions.summary.invalid).toBe(0);
185
187
  });
186
188
 
189
+ test("points, in the working tree and at HEAD (ws-058)", async () => {
190
+ const { expectValid } = contract(pointsSchema);
191
+ for (const at of [undefined, "HEAD"]) {
192
+ const doc = await workspacePoints({ cwd: FIXTURE, at });
193
+ expectValid(doc);
194
+ if ("error" in doc) throw new Error(doc.error.message);
195
+ expect(doc.points.map((p) => p.name)).toEqual(["slice-tier", "ship-skip"]);
196
+ }
197
+ const open = await workspacePoints({ cwd: FIXTURE, open: true });
198
+ expectValid(open);
199
+ if ("error" in open) throw new Error(open.error.message);
200
+ expect(open.sources).toEqual([{ kind: "answers/answer.kind.mjs", points: "reference-workspace/decisions/points.json", reason: null }]);
201
+ expect(open.questions.map((q) => [q.subject, q.state, q.model?.answer, q.model?.confidence])).toEqual([["W-002", "proposed", "medium", 0.865]]);
202
+ });
203
+
187
204
  test("records --since HEAD, for decisions and sessions (#2673)", async () => {
188
205
  const { expectValid } = contract(recordsSinceSchema);
189
206
  for (const kind of ["decisions/decision.kind.mjs", "design/sessions/session.kind.mjs"]) {
@@ -23,8 +23,13 @@ import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, REVIEW_REA
23
23
  import { AMEND_ERROR_CODES, NEW_ERROR_CODES, REVIEW_ERROR_CODES } from "./records-write";
24
24
  import { CLOSE_ERROR_CODES } from "./records-close";
25
25
  import { RECORDS_SINCE_ERROR_CODES, RECORDS_SINCE_REASON_CODES } from "./records-since";
26
- import { STATUS_ERROR_CODES, STATUS_GATE_REASON_CODES, STATUS_REASON_CODES } from "./status";
26
+ import { STATUS_ERROR_CODES, STATUS_GATE_REASON_CODES, STATUS_REASON_CODES, STATUS_STEWARD_REASON_CODES } from "./status";
27
27
  import { WORK_WARNING_CODES } from "./work";
28
+ import { BOX_FINDING_CODES } from "./checks/boxes";
29
+ import { WORK_ERROR_CODES, WORK_LEASE_REFUSALS } from "./work-cli";
30
+ import { ANSWER_WARNING_CODES } from "./points";
31
+ import { POINTS_ERROR_CODES, POINTS_SOURCE_REASON_CODES } from "./points-cli";
32
+ import { POINTS_WRITE_ERROR_CODES } from "./decide";
28
33
 
29
34
  const HERE = import.meta.dirname;
30
35
 
@@ -36,9 +41,11 @@ const PER_COMMAND: Record<string, readonly string[]> = {
36
41
  GRAPH_ERROR_CODES,
37
42
  CHECK_CODES,
38
43
  CHECK_ERROR_CODES,
44
+ BOX_FINDING_CODES,
39
45
  STATUS_REASON_CODES,
40
46
  STATUS_ERROR_CODES,
41
47
  STATUS_GATE_REASON_CODES,
48
+ STATUS_STEWARD_REASON_CODES,
42
49
  RECORD_REASON_CODES,
43
50
  RECORD_WARNING_CODES,
44
51
  REVIEW_REASON_CODES,
@@ -59,6 +66,12 @@ const PER_COMMAND: Record<string, readonly string[]> = {
59
66
  COMPOSITES_REASON_CODES,
60
67
  COMPOSITES_RUNTIME_REASON_CODES,
61
68
  COMPOSITES_ENVIRONMENT_REASON_CODES,
69
+ WORK_ERROR_CODES,
70
+ WORK_LEASE_REFUSALS,
71
+ ANSWER_WARNING_CODES,
72
+ POINTS_ERROR_CODES,
73
+ POINTS_SOURCE_REASON_CODES,
74
+ POINTS_WRITE_ERROR_CODES,
62
75
  };
63
76
 
64
77
  /** Every string in an `enum` under a property named `code`, anywhere in a schema. */