@alisio/core 0.7.0 → 0.8.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 (64) hide show
  1. package/dist/application.d.ts +15 -0
  2. package/dist/application.js +38 -3
  3. package/dist/changelog/data.js +23 -0
  4. package/dist/commands/builtins.js +7 -0
  5. package/dist/config.d.ts +30 -0
  6. package/dist/config.js +32 -0
  7. package/dist/core/runner.d.ts +4 -0
  8. package/dist/core/runner.js +57 -1
  9. package/dist/eval/engine.d.ts +1 -0
  10. package/dist/eval/engine.js +3 -0
  11. package/dist/eval/secrets.d.ts +2 -0
  12. package/dist/eval/secrets.js +10 -0
  13. package/dist/goal/command.d.ts +7 -2
  14. package/dist/goal/command.js +10 -2
  15. package/dist/goal/format.js +2 -0
  16. package/dist/goal/machine.d.ts +50 -3
  17. package/dist/goal/machine.js +80 -2
  18. package/dist/goal/service.d.ts +22 -2
  19. package/dist/goal/service.js +94 -7
  20. package/dist/goal/store.js +22 -3
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.js +2 -1
  23. package/dist/plan/criteria.d.ts +30 -0
  24. package/dist/plan/criteria.js +256 -0
  25. package/dist/plan/exit-plan.d.ts +8 -0
  26. package/dist/plan/exit-plan.js +89 -8
  27. package/dist/plan/manifest.d.ts +4 -2
  28. package/dist/plan/manifest.js +6 -1
  29. package/dist/plan/state.d.ts +13 -2
  30. package/dist/plan/state.js +7 -1
  31. package/dist/plugins/host.d.ts +7 -0
  32. package/dist/plugins/host.js +15 -0
  33. package/dist/proof/command.d.ts +24 -0
  34. package/dist/proof/command.js +109 -0
  35. package/dist/proof/coverage.d.ts +40 -0
  36. package/dist/proof/coverage.js +102 -0
  37. package/dist/proof/divergence.d.ts +24 -0
  38. package/dist/proof/divergence.js +120 -0
  39. package/dist/proof/export.d.ts +65 -0
  40. package/dist/proof/export.js +144 -0
  41. package/dist/proof/index.d.ts +12 -0
  42. package/dist/proof/index.js +12 -0
  43. package/dist/proof/ledger.d.ts +38 -0
  44. package/dist/proof/ledger.js +57 -0
  45. package/dist/proof/plugin-verifiers.d.ts +23 -0
  46. package/dist/proof/plugin-verifiers.js +39 -0
  47. package/dist/proof/recorder.d.ts +69 -0
  48. package/dist/proof/recorder.js +347 -0
  49. package/dist/proof/scope.d.ts +12 -0
  50. package/dist/proof/scope.js +121 -0
  51. package/dist/proof/store.d.ts +52 -0
  52. package/dist/proof/store.js +192 -0
  53. package/dist/proof/tree.d.ts +6 -0
  54. package/dist/proof/tree.js +78 -0
  55. package/dist/proof/types.d.ts +21 -0
  56. package/dist/proof/types.js +1 -0
  57. package/dist/proof/verifiers.d.ts +46 -0
  58. package/dist/proof/verifiers.js +176 -0
  59. package/dist/proof/verify.d.ts +36 -0
  60. package/dist/proof/verify.js +171 -0
  61. package/dist/runtime/store.js +60 -0
  62. package/dist/tools/goal.d.ts +5 -1
  63. package/dist/tools/goal.js +35 -4
  64. package/package.json +2 -2
@@ -0,0 +1,256 @@
1
+ export const CRITERIA_MAX = 20;
2
+ export const CRITERION_TEXT_MAX = 500;
3
+ const COMMAND_MAX = 1000;
4
+ const PATH_MAX = 500;
5
+ const PATTERN_MAX = 200;
6
+ const SCOPE_ENTRIES_MAX = 20;
7
+ const SCOPE_ENTRY_MAX = 256;
8
+ const EXIT_CODES_MAX = 10;
9
+ const DATASET_ID_MAX = 64;
10
+ const IDENTIFIER_MAX = 200;
11
+ const EXPECTED_MAX = 500;
12
+ const PLUGIN_INPUT_MAX = 4096;
13
+ const PLUGIN_VERIFIER_ID = /^[a-z0-9][a-z0-9._-]{0,63}:[a-z0-9][a-z0-9._-]{0,63}$/;
14
+ const DATA_CHECKS = new Set(["row_count", "sum", "equals"]);
15
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
16
+ const text = (value, max) => typeof value === "string" && value.trim() && value.length <= max ? value : undefined;
17
+ function verifierOf(raw) {
18
+ if (!isRecord(raw))
19
+ return "verifier must be an object";
20
+ if (raw.kind === "manual")
21
+ return { kind: "manual" };
22
+ if (raw.kind === "command") {
23
+ const command = text(raw.command, COMMAND_MAX)?.trim();
24
+ if (!command)
25
+ return "a command verifier needs a command";
26
+ const cwd = raw.cwd === undefined ? undefined : text(raw.cwd, PATH_MAX);
27
+ if (raw.cwd !== undefined && !cwd)
28
+ return "cwd must be a short string";
29
+ if (cwd && (isAbsoluteLike(cwd) || hasParentSegment(cwd)))
30
+ return "cwd must stay in the workspace";
31
+ const codes = raw.successExitCodes;
32
+ if (codes !== undefined &&
33
+ !(Array.isArray(codes) &&
34
+ codes.length > 0 &&
35
+ codes.length <= EXIT_CODES_MAX &&
36
+ codes.every((c) => Number.isInteger(c) && c >= 0 && c <= 255)))
37
+ return "successExitCodes must be a short list of exit codes";
38
+ const pattern = raw.outputPattern === undefined ? undefined : text(raw.outputPattern, PATTERN_MAX);
39
+ if (raw.outputPattern !== undefined && !pattern)
40
+ return "outputPattern must be a short string";
41
+ return {
42
+ kind: "command",
43
+ command,
44
+ ...(cwd ? { cwd } : {}),
45
+ ...(codes ? { successExitCodes: codes } : {}),
46
+ ...(pattern ? { outputPattern: pattern } : {}),
47
+ };
48
+ }
49
+ if (raw.kind === "file") {
50
+ const path = text(raw.path, PATH_MAX)?.trim();
51
+ if (!path || isAbsoluteLike(path) || hasParentSegment(path))
52
+ return "a file verifier needs a workspace-relative path";
53
+ const sha = typeof raw.sha256 === "string" && /^[0-9a-f]{64}$/.test(raw.sha256);
54
+ const pattern = raw.pattern === undefined ? undefined : text(raw.pattern, PATTERN_MAX);
55
+ if (raw.pattern !== undefined && !pattern)
56
+ return "pattern must be a short string";
57
+ if (raw.sha256 !== undefined && !sha)
58
+ return "sha256 must be 64 hex characters";
59
+ return {
60
+ kind: "file",
61
+ path,
62
+ ...(sha ? { sha256: raw.sha256 } : {}),
63
+ ...(pattern ? { pattern } : {}),
64
+ };
65
+ }
66
+ if (raw.kind === "data-check")
67
+ return dataCheckOf(raw);
68
+ if (raw.kind === "coverage") {
69
+ const command = text(raw.command, COMMAND_MAX)?.trim();
70
+ if (!command)
71
+ return "a coverage verifier needs a command";
72
+ const min = raw.minPercent;
73
+ if (typeof min !== "number" || !Number.isFinite(min) || min < 0 || min > 100)
74
+ return "a coverage verifier needs minPercent between 0 and 100";
75
+ return { kind: "coverage", command, minPercent: min };
76
+ }
77
+ if (raw.kind === "plugin") {
78
+ const verifierId = typeof raw.verifierId === "string" ? raw.verifierId : "";
79
+ if (!PLUGIN_VERIFIER_ID.test(verifierId))
80
+ return "a plugin verifier needs a verifierId like pluginId:verifier";
81
+ if (raw.input === undefined)
82
+ return { kind: "plugin", verifierId };
83
+ let size = Number.POSITIVE_INFINITY;
84
+ try {
85
+ size = JSON.stringify(raw.input)?.length ?? Number.POSITIVE_INFINITY;
86
+ }
87
+ catch {
88
+ /* Not serializable: refused below. */
89
+ }
90
+ if (size > PLUGIN_INPUT_MAX)
91
+ return "a plugin verifier input must be short JSON";
92
+ return { kind: "plugin", verifierId, input: raw.input };
93
+ }
94
+ return "that verifier kind cannot be proposed (use command, file, data-check, coverage, plugin or manual)";
95
+ }
96
+ function dataCheckOf(raw) {
97
+ const datasetId = text(raw.datasetId, DATASET_ID_MAX)?.trim();
98
+ if (!datasetId)
99
+ return "a data-check verifier needs a datasetId";
100
+ const check = typeof raw.check === "string" ? raw.check : "";
101
+ if (!DATA_CHECKS.has(check))
102
+ return "check must be row_count, sum or equals";
103
+ const expected = raw.expected;
104
+ const expectedOk = (typeof expected === "number" && Number.isFinite(expected)) ||
105
+ (typeof expected === "string" && expected.length > 0 && expected.length <= EXPECTED_MAX);
106
+ if (!expectedOk)
107
+ return "a data-check verifier needs an expected number or short string";
108
+ const column = raw.column === undefined ? undefined : text(raw.column, IDENTIFIER_MAX);
109
+ if (raw.column !== undefined && !column)
110
+ return "column must be a short string";
111
+ if (check !== "row_count" && !column)
112
+ return `a ${check} check needs a column`;
113
+ const table = raw.table === undefined ? undefined : text(raw.table, IDENTIFIER_MAX);
114
+ if (raw.table !== undefined && !table)
115
+ return "table must be a short string";
116
+ return {
117
+ kind: "data-check",
118
+ datasetId,
119
+ check: check,
120
+ ...(column ? { column } : {}),
121
+ expected: expected,
122
+ ...(table ? { table } : {}),
123
+ };
124
+ }
125
+ const isAbsoluteLike = (path) => path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:/.test(path);
126
+ const hasParentSegment = (path) => path.split(/[\\/]/).includes("..");
127
+ function scopeOf(raw) {
128
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > SCOPE_ENTRIES_MAX)
129
+ return "scope must be a short list of globs";
130
+ const out = [];
131
+ for (const entry of raw) {
132
+ const glob = text(entry, SCOPE_ENTRY_MAX)?.trim();
133
+ if (!glob)
134
+ return "every scope entry must be a short string";
135
+ if (isAbsoluteLike(glob) || hasParentSegment(glob))
136
+ return "scope entries must stay inside the workspace";
137
+ out.push(glob);
138
+ }
139
+ return out;
140
+ }
141
+ /** Turns the model's `acceptanceCriteria` input into drafts, reporting what it could not keep. */
142
+ export function validateCriteriaDrafts(raw) {
143
+ const accepted = [];
144
+ const dropped = [];
145
+ if (!Array.isArray(raw)) {
146
+ return { accepted, dropped: [{ index: 0, reason: "acceptanceCriteria must be an array" }] };
147
+ }
148
+ raw.forEach((entry, index) => {
149
+ if (accepted.length >= CRITERIA_MAX) {
150
+ dropped.push({ index, reason: `more than ${CRITERIA_MAX} criteria were proposed` });
151
+ return;
152
+ }
153
+ if (!isRecord(entry)) {
154
+ dropped.push({ index, reason: "a criterion must be an object" });
155
+ return;
156
+ }
157
+ const statement = typeof entry.text === "string" ? entry.text.replace(/\s+/g, " ").trim() : "";
158
+ if (!statement) {
159
+ dropped.push({ index, reason: "a criterion needs text" });
160
+ return;
161
+ }
162
+ let verifier;
163
+ if (entry.verifier !== undefined) {
164
+ const checked = verifierOf(entry.verifier);
165
+ if (typeof checked === "string") {
166
+ dropped.push({ index, reason: checked });
167
+ return;
168
+ }
169
+ verifier = checked;
170
+ }
171
+ let scope;
172
+ if (entry.scope !== undefined) {
173
+ const checked = scopeOf(entry.scope);
174
+ if (typeof checked === "string") {
175
+ dropped.push({ index, reason: checked });
176
+ return;
177
+ }
178
+ scope = checked;
179
+ }
180
+ accepted.push({
181
+ text: statement.length > CRITERION_TEXT_MAX ? statement.slice(0, CRITERION_TEXT_MAX) : statement,
182
+ required: entry.required === true,
183
+ ...(verifier ? { verifier } : {}),
184
+ ...(scope ? { scope } : {}),
185
+ origin: "inferred",
186
+ });
187
+ });
188
+ return { accepted, dropped };
189
+ }
190
+ /**
191
+ * Applies the user's per-draft decisions (`plan:criteria`) to the drafts of the plan they approved.
192
+ * `undefined` selection keeps every draft (the plain "approve"). Anything malformed, any index
193
+ * outside the list or an empty edit returns `undefined` for "nothing is activated": a selection
194
+ * the user did not make cleanly never activates a criterion (fail closed).
195
+ */
196
+ export function applyCriteriaSelection(drafts, raw) {
197
+ if (raw === undefined || raw === "")
198
+ return { kept: [...drafts], rejected: 0 };
199
+ let selection;
200
+ try {
201
+ selection = typeof raw === "string" ? JSON.parse(raw) : undefined;
202
+ }
203
+ catch {
204
+ return undefined;
205
+ }
206
+ if (!isRecord(selection))
207
+ return undefined;
208
+ const index = (key) => {
209
+ const n = typeof key === "string" ? Number(key) : key;
210
+ return Number.isInteger(n) && n >= 0 && n < drafts.length ? n : undefined;
211
+ };
212
+ const rejected = new Set();
213
+ if (selection.reject !== undefined) {
214
+ if (!Array.isArray(selection.reject))
215
+ return undefined;
216
+ for (const entry of selection.reject) {
217
+ const i = index(entry);
218
+ if (i === undefined)
219
+ return undefined;
220
+ rejected.add(i);
221
+ }
222
+ }
223
+ const edits = new Map();
224
+ if (selection.edit !== undefined) {
225
+ if (!isRecord(selection.edit))
226
+ return undefined;
227
+ for (const [key, value] of Object.entries(selection.edit)) {
228
+ const i = index(key);
229
+ const statement = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
230
+ if (i === undefined || !statement)
231
+ return undefined;
232
+ edits.set(i, statement.slice(0, CRITERION_TEXT_MAX));
233
+ }
234
+ }
235
+ const required = new Map();
236
+ if (selection.required !== undefined) {
237
+ if (!isRecord(selection.required))
238
+ return undefined;
239
+ for (const [key, value] of Object.entries(selection.required)) {
240
+ const i = index(key);
241
+ if (i === undefined || typeof value !== "boolean")
242
+ return undefined;
243
+ required.set(i, value);
244
+ }
245
+ }
246
+ const kept = drafts.flatMap((draft, i) => rejected.has(i)
247
+ ? []
248
+ : [
249
+ {
250
+ ...draft,
251
+ text: edits.get(i) ?? draft.text,
252
+ required: required.get(i) ?? draft.required,
253
+ },
254
+ ]);
255
+ return { kept, rejected: rejected.size };
256
+ }
@@ -1,5 +1,6 @@
1
1
  import type { AskQuestionsRequest, AskQuestionsResult } from "@alisio/sdk";
2
2
  import type { ToolRegistry } from "../core/registry.ts";
3
+ import type { NewCriterion } from "../proof/types.ts";
3
4
  import { type PlanStore } from "./state.ts";
4
5
  export interface ExitPlanDeps {
5
6
  store: PlanStore;
@@ -14,5 +15,12 @@ export interface ExitPlanDeps {
14
15
  enabled: boolean;
15
16
  max: number;
16
17
  };
18
+ /**
19
+ * Where approved acceptance criteria go (`ProofStore` fits). Without it the `acceptanceCriteria`
20
+ * field is not offered and is ignored: a core without Proof plans exactly as before.
21
+ */
22
+ proof?: {
23
+ addCriterion(input: NewCriterion): unknown;
24
+ };
17
25
  }
18
26
  export declare function registerExitPlan(registry: ToolRegistry, deps: ExitPlanDeps): void;
@@ -17,9 +17,28 @@ import { textResult } from "@alisio/sdk";
17
17
  import { EXIT_PLAN_TOOL } from "../core/opt-in.js";
18
18
  import { newUuid } from "../runtime/ids.js";
19
19
  import { objectSchema } from "../tools/standard.js";
20
+ import { applyCriteriaSelection, validateCriteriaDrafts, } from "./criteria.js";
20
21
  import { DIAGRAM_MAX_BYTES, DIAGRAM_MAX_NODES, validateDiagrams, } from "./diagrams.js";
21
22
  import { buildPlanManifest } from "./manifest.js";
22
- import { currentPlan, decidePlan, nextPlanRevision, PLAN_CONTEXT_MAX_CHARS, PLAN_MAX_CHARS, PLAN_OPTIONS, PLAN_QUESTION_ID, PLAN_REVIEW_TITLE, PLAN_TEXT_KEY, planHash, planTitle, proposePlan, withdrawPendingPlan, } from "./state.js";
23
+ import { currentPlan, decidePlan, nextPlanRevision, PLAN_CONTEXT_MAX_CHARS, PLAN_CRITERIA_KEY, PLAN_MAX_CHARS, PLAN_OPTIONS, PLAN_QUESTION_ID, PLAN_REVIEW_TITLE, PLAN_TEXT_KEY, planHash, planTitle, proposePlan, withdrawPendingPlan, } from "./state.js";
24
+ const CRITERIA_SCHEMA = {
25
+ type: "array",
26
+ description: "Optional acceptance criteria you PROPOSE for this plan. They are drafts: they only become " +
27
+ "active if the user approves the plan, and a criterion with no verifier stays unchecked.",
28
+ items: {
29
+ type: "object",
30
+ properties: {
31
+ text: { type: "string", description: "A verifiable statement, e.g. 'Unit tests pass'" },
32
+ required: { type: "boolean", description: "Must hold before the work counts as done" },
33
+ verifier: {
34
+ type: "object",
35
+ description: "How to check it: {kind:'command', command:'pnpm test'}, {kind:'file', path} or {kind:'manual'}",
36
+ },
37
+ scope: { type: "array", items: { type: "string" }, description: "Globs it covers" },
38
+ },
39
+ required: ["text"],
40
+ },
41
+ };
23
42
  const DIAGRAM_SCHEMA = {
24
43
  type: "array",
25
44
  description: `Optional Mermaid diagrams; see the plan agent's instructions`,
@@ -97,7 +116,13 @@ export function registerExitPlan(registry, deps) {
97
116
  description: "The whole plan in Markdown",
98
117
  },
99
118
  }, ["plan"]);
100
- const withDiagrams = objectSchema({ ...plain.properties, diagrams: DIAGRAM_SCHEMA }, ["plan"]);
119
+ const withCriteria = deps.proof ? { acceptanceCriteria: CRITERIA_SCHEMA } : {};
120
+ const plainCriteria = objectSchema({ ...plain.properties, ...withCriteria }, ["plan"]);
121
+ const withDiagrams = objectSchema({
122
+ ...plain.properties,
123
+ ...withCriteria,
124
+ diagrams: DIAGRAM_SCHEMA,
125
+ }, ["plan"]);
101
126
  // The input is always VALIDATED with `diagrams` allowed (a call made while they were switched
102
127
  // off must not fail), but the model is only OFFERED the field while diagrams are on: the
103
128
  // registry compiles the schema once, while the provider reads it on every request.
@@ -106,12 +131,15 @@ export function registerExitPlan(registry, deps) {
106
131
  name: EXIT_PLAN_TOOL,
107
132
  effect: "read",
108
133
  get description() {
109
- return offered()
110
- ? `${BASE_DESCRIPTION} Optional \`diagrams\`: up to ${settings().max} small Mermaid diagrams (see your instructions).`
134
+ const base = deps.proof
135
+ ? `${BASE_DESCRIPTION} Optional \`acceptanceCriteria\`: drafts the user approves together with the plan.`
111
136
  : BASE_DESCRIPTION;
137
+ return offered()
138
+ ? `${base} Optional \`diagrams\`: up to ${settings().max} small Mermaid diagrams (see your instructions).`
139
+ : base;
112
140
  },
113
141
  get inputSchema() {
114
- return registering || offered() ? withDiagrams : plain;
142
+ return registering || offered() ? withDiagrams : plainCriteria;
115
143
  },
116
144
  async execute(input, context) {
117
145
  const plan = String(input.plan ?? "").trim();
@@ -145,6 +173,15 @@ export function registerExitPlan(registry, deps) {
145
173
  outcome.dropped = validated.dropped;
146
174
  }
147
175
  }
176
+ // Acceptance criteria are DRAFTS here (O3): validated, shown in the review, stored in the plan
177
+ // state, and turned into real criteria only by the user's approval below. Nothing in this
178
+ // block writes the ledger.
179
+ const rawCriteria = deps.proof ? input.acceptanceCriteria : undefined;
180
+ const proposed = rawCriteria === undefined || (Array.isArray(rawCriteria) && !rawCriteria.length)
181
+ ? undefined
182
+ : validateCriteriaDrafts(rawCriteria);
183
+ const drafts = proposed?.accepted ?? [];
184
+ const droppedCriteria = proposed?.dropped ?? [];
148
185
  const preManifest = buildPlanManifest({
149
186
  planId,
150
187
  revision,
@@ -153,11 +190,12 @@ export function registerExitPlan(registry, deps) {
153
190
  markdown: plan,
154
191
  diagrams: outcome.accepted,
155
192
  ...(previous ? { previous } : {}),
193
+ ...(drafts.length ? { acceptanceCriteria: drafts } : {}),
156
194
  });
157
195
  outcome.removed = preManifest.removed;
158
196
  if (outcome.removed.length)
159
197
  outcome.involved = true;
160
- const hash = planHash(plan, preManifest.diagrams.map((d) => ({ id: d.id, hash: d.hash })));
198
+ const hash = planHash(plan, preManifest.diagrams.map((d) => ({ id: d.id, hash: d.hash })), drafts);
161
199
  const manifest = { ...preManifest, hash };
162
200
  const diagrams = manifest.diagrams.map((entry, index) => ({
163
201
  id: entry.id,
@@ -176,7 +214,7 @@ export function registerExitPlan(registry, deps) {
176
214
  const publisher = context.artifacts;
177
215
  if (publisher) {
178
216
  // A folder (plan.md + plan.json + diagrams) when diagrams are involved, else plan.md alone.
179
- if (outcome.accepted.length || outcome.removed.length)
217
+ if (outcome.accepted.length || outcome.removed.length || drafts.length)
180
218
  try {
181
219
  artifact = await publishPlanFolder(publisher, [
182
220
  { path: "plan.md", text: `${plan}\n` },
@@ -209,12 +247,23 @@ export function registerExitPlan(registry, deps) {
209
247
  title,
210
248
  ...(context.callId ? { callId: context.callId } : {}),
211
249
  ...(artifact ? { artifactId: artifact.id } : {}),
250
+ ...(drafts.length ? { criteria: drafts } : {}),
212
251
  ...(manifest.diagrams.length
213
252
  ? {
214
253
  diagrams: manifest.diagrams.map((d) => ({ id: d.id, title: d.title, hash: d.hash })),
215
254
  }
216
255
  : {}),
217
256
  };
257
+ const criteriaReport = (activated) => drafts.length || droppedCriteria.length
258
+ ? {
259
+ criteria: {
260
+ proposed: drafts.length,
261
+ ...(droppedCriteria.length ? { dropped: droppedCriteria } : {}),
262
+ ...(activated === undefined ? {} : { activated }),
263
+ ...(rejectedDrafts ? { rejected: rejectedDrafts } : {}),
264
+ },
265
+ }
266
+ : {};
218
267
  const report = diagramReport(outcome);
219
268
  if (!reviewable) {
220
269
  // Nobody to ask: keep the revision count honest and tell the model what to do instead.
@@ -239,6 +288,7 @@ export function registerExitPlan(registry, deps) {
239
288
  markdown: plan,
240
289
  ...(artifact ? { artifact } : {}),
241
290
  ...(diagrams.length ? { diagrams } : {}),
291
+ ...(drafts.length ? { acceptanceCriteria: drafts } : {}),
242
292
  };
243
293
  const result = await deps.ui.askQuestions({
244
294
  questions: [
@@ -271,16 +321,47 @@ export function registerExitPlan(registry, deps) {
271
321
  hash,
272
322
  decision,
273
323
  });
324
+ // The only place a draft becomes a criterion: the user's own approval, applied once by the
325
+ // compare-and-set above, of exactly the drafts stored with that proposal.
326
+ let activated;
327
+ let rejectedDrafts = 0;
328
+ if (decision === "approve" && deps.proof && decided.criteria?.length) {
329
+ // The user may approve, edit or reject each draft (`plan:criteria`); a selection that is
330
+ // not clean activates none of them.
331
+ const picked = applyCriteriaSelection(decided.criteria, result[PLAN_CRITERIA_KEY]);
332
+ rejectedDrafts = picked ? picked.rejected : decided.criteria.length;
333
+ try {
334
+ for (const draft of picked?.kept ?? [])
335
+ deps.proof.addCriterion({
336
+ sessionId,
337
+ text: draft.text,
338
+ origin: draft.origin,
339
+ required: draft.required,
340
+ ...(draft.verifier ? { verifier: draft.verifier } : {}),
341
+ ...(draft.scope ? { scope: draft.scope } : {}),
342
+ });
343
+ activated = picked?.kept.length ?? 0;
344
+ }
345
+ catch {
346
+ activated = "failed";
347
+ }
348
+ }
274
349
  const base = {
275
350
  planId,
276
351
  revision,
277
352
  ...(artifact ? { artifactId: artifact.id } : {}),
278
353
  ...report,
354
+ ...criteriaReport(activated),
279
355
  };
280
356
  const body = decision === "approve"
281
357
  ? answer("approved", "The user approved the plan. The build agent will implement exactly this plan in the " +
282
358
  "next turn. Do not implement anything yourself: reply with one short sentence and " +
283
- "call no more tools.", base)
359
+ "call no more tools." +
360
+ (typeof activated === "number"
361
+ ? ` ${activated} acceptance criteria are now active.`
362
+ : activated === "failed"
363
+ ? " The acceptance criteria could not be saved; they are not active."
364
+ : ""), base)
284
365
  : decision === "context"
285
366
  ? answer("feedback", "The user added context instead of deciding. Revise the plan to account for it and " +
286
367
  "call exit_plan again with the complete updated plan" +
@@ -1,4 +1,4 @@
1
- import type { PlanDiagramStatus, PlanManifest } from "@alisio/sdk";
1
+ import type { PlanCriterionDraft, PlanDiagramStatus, PlanManifest } from "@alisio/sdk";
2
2
  import { type PlanDiagram } from "./diagrams.ts";
3
3
  export interface PlanHeading {
4
4
  id: string;
@@ -31,6 +31,8 @@ export interface ManifestInput {
31
31
  diagrams: PlanDiagram[];
32
32
  /** Diagrams of the previous revision (from the plan state), when there was one. */
33
33
  previous?: DiagramRecord[];
34
+ /** Acceptance-criteria drafts of this revision (specs/011); absent or empty = none. */
35
+ acceptanceCriteria?: PlanCriterionDraft[];
34
36
  }
35
37
  /** The diff of this revision's diagrams against the previous one. */
36
38
  export declare function diffDiagrams(diagrams: Array<{
@@ -50,4 +52,4 @@ export declare function buildPlanManifest(input: ManifestInput): PlanManifest;
50
52
  export declare function planContentHash(markdown: string, diagrams: Array<{
51
53
  id: string;
52
54
  hash: string;
53
- }>): string;
55
+ }>, criteria?: PlanCriterionDraft[]): string;
@@ -308,12 +308,17 @@ export function buildPlanManifest(input) {
308
308
  };
309
309
  }),
310
310
  removed,
311
+ ...(input.acceptanceCriteria?.length ? { acceptanceCriteria: input.acceptanceCriteria } : {}),
311
312
  };
312
313
  }
313
314
  /** The hash of a plan revision: the Markdown alone, or the Markdown plus its diagrams. */
314
- export function planContentHash(markdown, diagrams) {
315
+ export function planContentHash(markdown, diagrams, criteria = []) {
315
316
  const hash = createHash("sha256").update(markdown, "utf8");
316
317
  if (diagrams.length)
317
318
  hash.update(`\n--diagrams--\n${diagrams.map((d) => `${d.id}:${d.hash}`).join("\n")}`, "utf8");
319
+ // The criteria are part of what the user approves, so they are part of the hash (only when
320
+ // present: a plan without criteria keeps the hash it always had).
321
+ if (criteria.length)
322
+ hash.update(`\n--criteria--\n${JSON.stringify(criteria.map((c) => [c.text, c.required, c.verifier ?? null, c.scope ?? null]))}`, "utf8");
318
323
  return hash.digest("hex");
319
324
  }
@@ -8,12 +8,18 @@
8
8
  * ├────skip──────▶ skipped └─discard─▶ cancelled (the run was cancelled)
9
9
  * └────context───▶ feedback (a later `exit_plan` call starts a new revision)
10
10
  */
11
- import type { QuestionOption } from "@alisio/sdk";
11
+ import type { PlanCriterionDraft, QuestionOption } from "@alisio/sdk";
12
12
  import { type DiagramRecord } from "./manifest.ts";
13
13
  /** The decision screen title (web: localized; the TUI prints it as is). */
14
14
  export declare const PLAN_REVIEW_TITLE = "Plan complete. What would you like to do?";
15
15
  export declare const PLAN_QUESTION_ID = "plan";
16
16
  export declare const PLAN_TEXT_KEY = "plan:text";
17
+ /**
18
+ * Per-draft decisions on the acceptance criteria (specs/011 phase 5): a JSON object
19
+ * `{reject?: number[], edit?: {"<index>": "text"}, required?: {"<index>": boolean}}` over the
20
+ * drafts as the review lists them. Only read together with `approve`.
21
+ */
22
+ export declare const PLAN_CRITERIA_KEY = "plan:criteria";
17
23
  /** The agent an approved plan is handed to. */
18
24
  export declare const IMPLEMENTATION_AGENT_ID = "build";
19
25
  export declare const PLAN_MAX_CHARS = 60000;
@@ -33,6 +39,11 @@ export interface PlanState {
33
39
  artifactId?: string;
34
40
  /** The accepted diagrams of this revision (absent without any): what the next one is diffed against. */
35
41
  diagrams?: DiagramRecord[];
42
+ /**
43
+ * Acceptance-criteria DRAFTS of this revision (specs/011, O3). They are not criteria: they only
44
+ * become active when the user approves this plan (`exit-plan.ts`), and a later revision replaces them.
45
+ */
46
+ criteria?: PlanCriterionDraft[];
36
47
  /** The approved snapshot; kept only while `approved` (afterwards it lives in the user message). */
37
48
  plan?: string;
38
49
  updatedAt: number;
@@ -54,7 +65,7 @@ export interface PlanStore {
54
65
  export declare const planHash: (plan: string, diagrams?: Array<{
55
66
  id: string;
56
67
  hash: string;
57
- }>) => string;
68
+ }>, criteria?: PlanCriterionDraft[]) => string;
58
69
  /** The plan state stored in `options`, or undefined when absent or malformed. */
59
70
  export declare function readPlanState(options: Record<string, unknown> | undefined): PlanState | undefined;
60
71
  export declare const currentPlan: (store: PlanStore, sessionId: string) => PlanState | undefined;
@@ -3,6 +3,12 @@ import { planContentHash } from "./manifest.js";
3
3
  export const PLAN_REVIEW_TITLE = "Plan complete. What would you like to do?";
4
4
  export const PLAN_QUESTION_ID = "plan";
5
5
  export const PLAN_TEXT_KEY = `${PLAN_QUESTION_ID}:text`;
6
+ /**
7
+ * Per-draft decisions on the acceptance criteria (specs/011 phase 5): a JSON object
8
+ * `{reject?: number[], edit?: {"<index>": "text"}, required?: {"<index>": boolean}}` over the
9
+ * drafts as the review lists them. Only read together with `approve`.
10
+ */
11
+ export const PLAN_CRITERIA_KEY = `${PLAN_QUESTION_ID}:criteria`;
6
12
  /** The agent an approved plan is handed to. */
7
13
  export const IMPLEMENTATION_AGENT_ID = "build";
8
14
  export const PLAN_MAX_CHARS = 60_000;
@@ -22,7 +28,7 @@ export const PLAN_OPTIONS = [
22
28
  * The hash of a plan revision. Without diagrams it is the SHA-256 of the Markdown (as before);
23
29
  * with diagrams it also covers them, so a change in a diagram alone is a different plan.
24
30
  */
25
- export const planHash = (plan, diagrams = []) => planContentHash(plan, diagrams);
31
+ export const planHash = (plan, diagrams = [], criteria = []) => planContentHash(plan, diagrams, criteria);
26
32
  const STATUSES = new Set([
27
33
  "pending",
28
34
  "approved",
@@ -5,6 +5,7 @@ import type { ToolRegistry } from "../core/registry.ts";
5
5
  import type { DecisionRegistry } from "../decisions/registry.ts";
6
6
  import type { DecisionService, DecisionsConfig } from "../decisions/service.ts";
7
7
  import { ExtensionRegistry } from "../extensions/registry.ts";
8
+ import type { ProofVerifierRegistry } from "../proof/plugin-verifiers.ts";
8
9
  import type { ProviderRegistry } from "../providers/registry.ts";
9
10
  export declare const pluginPrefix: (id: string) => string;
10
11
  export interface PluginHostOptions {
@@ -165,6 +166,12 @@ export declare class PluginHost implements RunnerExtensions {
165
166
  * Binds the directories and options plugins receive (`api.paths`, `api.options`). Plugins
166
167
  * activated before this call, or on a host without it, see neither member.
167
168
  */
169
+ /**
170
+ * Binds the Proof verifier registry. Plugins activated afterwards see `api.proof`; without it
171
+ * the member is absent, exactly like on a core that predates Proof.
172
+ */
173
+ setProofVerifiers(registry: ProofVerifierRegistry): void;
174
+ private proofVerifiers?;
168
175
  setPluginEnvironment(environment: PluginEnvironment): void;
169
176
  private environment?;
170
177
  /** Binds credential-free model discovery/resolution after provider startup is complete. */
@@ -178,6 +178,14 @@ export class PluginHost {
178
178
  * Binds the directories and options plugins receive (`api.paths`, `api.options`). Plugins
179
179
  * activated before this call, or on a host without it, see neither member.
180
180
  */
181
+ /**
182
+ * Binds the Proof verifier registry. Plugins activated afterwards see `api.proof`; without it
183
+ * the member is absent, exactly like on a core that predates Proof.
184
+ */
185
+ setProofVerifiers(registry) {
186
+ this.proofVerifiers = registry;
187
+ }
188
+ proofVerifiers;
181
189
  setPluginEnvironment(environment) {
182
190
  this.environment = environment;
183
191
  }
@@ -428,6 +436,13 @@ export class PluginHost {
428
436
  };
429
437
  })(this.environment)
430
438
  : {}),
439
+ ...(this.proofVerifiers
440
+ ? {
441
+ proof: ((registry) => ({
442
+ registerVerifier: (verifier) => track(registry.register(plugin.id, verifier)),
443
+ }))(this.proofVerifiers),
444
+ }
445
+ : {}),
431
446
  ...(this.decisionsImpl
432
447
  ? {
433
448
  decisions: (({ registry, service, activation }) => ({
@@ -0,0 +1,24 @@
1
+ export type CommandTool = "run_process" | "shell";
2
+ /** The canonical workspace-relative directory: forward slashes, no `./` prefix, `.` when empty. */
3
+ export declare function normalizeCwd(cwd: string): string;
4
+ /** Directory of a call relative to the workspace (`.` when it is the workspace itself). */
5
+ export declare function relativeCwd(workspace: string, cwd: string | undefined): string;
6
+ export declare function commandFingerprint(argv: readonly string[], cwd: string): string;
7
+ /** Trims and collapses whitespace runs OUTSIDE single/double quotes; quoted text stays verbatim. */
8
+ export declare function normalizeShell(command: string): string;
9
+ /** Fingerprint of a call as the tool received it. */
10
+ export declare function callFingerprint(tool: CommandTool, input: {
11
+ command: string;
12
+ args?: readonly string[];
13
+ }, cwd: string): string;
14
+ /**
15
+ * Splits a plain command line into an argv (single and double quotes group words). Returns
16
+ * undefined when the line needs a shell (operators, expansions, unbalanced quotes): only the
17
+ * shell form can then match it.
18
+ */
19
+ export declare function splitCommand(command: string): string[] | undefined;
20
+ /**
21
+ * Every fingerprint a call may have to satisfy the criterion command `command` run in `cwd`: the
22
+ * `shell` form always and the `run_process` form when the line is a plain argv.
23
+ */
24
+ export declare function criterionFingerprints(command: string, cwd: string): string[];