@alisio/core 0.6.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 (97) hide show
  1. package/dist/application.d.ts +15 -0
  2. package/dist/application.js +38 -3
  3. package/dist/changelog/data.js +39 -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/canonical.d.ts +14 -0
  10. package/dist/eval/canonical.js +40 -0
  11. package/dist/eval/cassette.d.ts +180 -0
  12. package/dist/eval/cassette.js +424 -0
  13. package/dist/eval/compare.d.ts +147 -0
  14. package/dist/eval/compare.js +436 -0
  15. package/dist/eval/engine.d.ts +145 -0
  16. package/dist/eval/engine.js +731 -0
  17. package/dist/eval/export.d.ts +40 -0
  18. package/dist/eval/export.js +90 -0
  19. package/dist/eval/fingerprint.d.ts +88 -0
  20. package/dist/eval/fingerprint.js +186 -0
  21. package/dist/eval/normalize.d.ts +68 -0
  22. package/dist/eval/normalize.js +138 -0
  23. package/dist/eval/paths.d.ts +11 -0
  24. package/dist/eval/paths.js +50 -0
  25. package/dist/eval/plan.d.ts +26 -0
  26. package/dist/eval/plan.js +44 -0
  27. package/dist/eval/report.d.ts +53 -0
  28. package/dist/eval/report.js +258 -0
  29. package/dist/eval/run-suite.d.ts +68 -0
  30. package/dist/eval/run-suite.js +94 -0
  31. package/dist/eval/secrets.d.ts +14 -0
  32. package/dist/eval/secrets.js +55 -0
  33. package/dist/eval/stats.d.ts +17 -0
  34. package/dist/eval/stats.js +50 -0
  35. package/dist/eval/suite.d.ts +20 -0
  36. package/dist/eval/suite.js +165 -0
  37. package/dist/eval/variants.d.ts +35 -0
  38. package/dist/eval/variants.js +137 -0
  39. package/dist/eval/verifiers.d.ts +49 -0
  40. package/dist/eval/verifiers.js +264 -0
  41. package/dist/forge/javascript/hosts/worker-host.d.ts +9 -0
  42. package/dist/forge/javascript/hosts/worker-host.js +16 -1
  43. package/dist/forge/kernel.js +3 -2
  44. package/dist/goal/command.d.ts +7 -2
  45. package/dist/goal/command.js +10 -2
  46. package/dist/goal/format.js +2 -0
  47. package/dist/goal/machine.d.ts +50 -3
  48. package/dist/goal/machine.js +80 -2
  49. package/dist/goal/service.d.ts +22 -2
  50. package/dist/goal/service.js +94 -7
  51. package/dist/goal/store.js +22 -3
  52. package/dist/index.d.ts +18 -2
  53. package/dist/index.js +18 -2
  54. package/dist/plan/criteria.d.ts +30 -0
  55. package/dist/plan/criteria.js +256 -0
  56. package/dist/plan/exit-plan.d.ts +8 -0
  57. package/dist/plan/exit-plan.js +91 -9
  58. package/dist/plan/manifest.d.ts +4 -2
  59. package/dist/plan/manifest.js +6 -1
  60. package/dist/plan/state.d.ts +13 -2
  61. package/dist/plan/state.js +7 -1
  62. package/dist/plugins/host.d.ts +7 -0
  63. package/dist/plugins/host.js +15 -0
  64. package/dist/proof/command.d.ts +24 -0
  65. package/dist/proof/command.js +109 -0
  66. package/dist/proof/coverage.d.ts +40 -0
  67. package/dist/proof/coverage.js +102 -0
  68. package/dist/proof/divergence.d.ts +24 -0
  69. package/dist/proof/divergence.js +120 -0
  70. package/dist/proof/export.d.ts +65 -0
  71. package/dist/proof/export.js +144 -0
  72. package/dist/proof/index.d.ts +12 -0
  73. package/dist/proof/index.js +12 -0
  74. package/dist/proof/ledger.d.ts +38 -0
  75. package/dist/proof/ledger.js +57 -0
  76. package/dist/proof/plugin-verifiers.d.ts +23 -0
  77. package/dist/proof/plugin-verifiers.js +39 -0
  78. package/dist/proof/recorder.d.ts +69 -0
  79. package/dist/proof/recorder.js +347 -0
  80. package/dist/proof/scope.d.ts +12 -0
  81. package/dist/proof/scope.js +121 -0
  82. package/dist/proof/store.d.ts +52 -0
  83. package/dist/proof/store.js +192 -0
  84. package/dist/proof/tree.d.ts +6 -0
  85. package/dist/proof/tree.js +78 -0
  86. package/dist/proof/types.d.ts +21 -0
  87. package/dist/proof/types.js +1 -0
  88. package/dist/proof/verifiers.d.ts +46 -0
  89. package/dist/proof/verifiers.js +176 -0
  90. package/dist/proof/verify.d.ts +36 -0
  91. package/dist/proof/verify.js +171 -0
  92. package/dist/runtime/ids.d.ts +21 -0
  93. package/dist/runtime/ids.js +41 -1
  94. package/dist/runtime/store.js +63 -2
  95. package/dist/tools/goal.d.ts +5 -1
  96. package/dist/tools/goal.js +35 -4
  97. package/package.json +2 -2
@@ -15,10 +15,30 @@ import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { textResult } from "@alisio/sdk";
17
17
  import { EXIT_PLAN_TOOL } from "../core/opt-in.js";
18
+ import { newUuid } from "../runtime/ids.js";
18
19
  import { objectSchema } from "../tools/standard.js";
20
+ import { applyCriteriaSelection, validateCriteriaDrafts, } from "./criteria.js";
19
21
  import { DIAGRAM_MAX_BYTES, DIAGRAM_MAX_NODES, validateDiagrams, } from "./diagrams.js";
20
22
  import { buildPlanManifest } from "./manifest.js";
21
- 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
+ };
22
42
  const DIAGRAM_SCHEMA = {
23
43
  type: "array",
24
44
  description: `Optional Mermaid diagrams; see the plan agent's instructions`,
@@ -96,7 +116,13 @@ export function registerExitPlan(registry, deps) {
96
116
  description: "The whole plan in Markdown",
97
117
  },
98
118
  }, ["plan"]);
99
- 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"]);
100
126
  // The input is always VALIDATED with `diagrams` allowed (a call made while they were switched
101
127
  // off must not fail), but the model is only OFFERED the field while diagrams are on: the
102
128
  // registry compiles the schema once, while the provider reads it on every request.
@@ -105,12 +131,15 @@ export function registerExitPlan(registry, deps) {
105
131
  name: EXIT_PLAN_TOOL,
106
132
  effect: "read",
107
133
  get description() {
108
- return offered()
109
- ? `${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.`
110
136
  : BASE_DESCRIPTION;
137
+ return offered()
138
+ ? `${base} Optional \`diagrams\`: up to ${settings().max} small Mermaid diagrams (see your instructions).`
139
+ : base;
111
140
  },
112
141
  get inputSchema() {
113
- return registering || offered() ? withDiagrams : plain;
142
+ return registering || offered() ? withDiagrams : plainCriteria;
114
143
  },
115
144
  async execute(input, context) {
116
145
  const plan = String(input.plan ?? "").trim();
@@ -118,7 +147,7 @@ export function registerExitPlan(registry, deps) {
118
147
  throw new Error("exit_plan needs the whole plan in the `plan` field");
119
148
  const sessionId = context.session;
120
149
  const title = planTitle(plan, input.title);
121
- const planId = crypto.randomUUID();
150
+ const planId = newUuid();
122
151
  const reviewable = !!sessionId && deps.ui.interactive() && !deps.readOnly;
123
152
  const revision = sessionId ? nextPlanRevision(deps.store, sessionId) : 1;
124
153
  const previous = sessionId
@@ -144,6 +173,15 @@ export function registerExitPlan(registry, deps) {
144
173
  outcome.dropped = validated.dropped;
145
174
  }
146
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 ?? [];
147
185
  const preManifest = buildPlanManifest({
148
186
  planId,
149
187
  revision,
@@ -152,11 +190,12 @@ export function registerExitPlan(registry, deps) {
152
190
  markdown: plan,
153
191
  diagrams: outcome.accepted,
154
192
  ...(previous ? { previous } : {}),
193
+ ...(drafts.length ? { acceptanceCriteria: drafts } : {}),
155
194
  });
156
195
  outcome.removed = preManifest.removed;
157
196
  if (outcome.removed.length)
158
197
  outcome.involved = true;
159
- 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);
160
199
  const manifest = { ...preManifest, hash };
161
200
  const diagrams = manifest.diagrams.map((entry, index) => ({
162
201
  id: entry.id,
@@ -175,7 +214,7 @@ export function registerExitPlan(registry, deps) {
175
214
  const publisher = context.artifacts;
176
215
  if (publisher) {
177
216
  // A folder (plan.md + plan.json + diagrams) when diagrams are involved, else plan.md alone.
178
- if (outcome.accepted.length || outcome.removed.length)
217
+ if (outcome.accepted.length || outcome.removed.length || drafts.length)
179
218
  try {
180
219
  artifact = await publishPlanFolder(publisher, [
181
220
  { path: "plan.md", text: `${plan}\n` },
@@ -208,12 +247,23 @@ export function registerExitPlan(registry, deps) {
208
247
  title,
209
248
  ...(context.callId ? { callId: context.callId } : {}),
210
249
  ...(artifact ? { artifactId: artifact.id } : {}),
250
+ ...(drafts.length ? { criteria: drafts } : {}),
211
251
  ...(manifest.diagrams.length
212
252
  ? {
213
253
  diagrams: manifest.diagrams.map((d) => ({ id: d.id, title: d.title, hash: d.hash })),
214
254
  }
215
255
  : {}),
216
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
+ : {};
217
267
  const report = diagramReport(outcome);
218
268
  if (!reviewable) {
219
269
  // Nobody to ask: keep the revision count honest and tell the model what to do instead.
@@ -238,6 +288,7 @@ export function registerExitPlan(registry, deps) {
238
288
  markdown: plan,
239
289
  ...(artifact ? { artifact } : {}),
240
290
  ...(diagrams.length ? { diagrams } : {}),
291
+ ...(drafts.length ? { acceptanceCriteria: drafts } : {}),
241
292
  };
242
293
  const result = await deps.ui.askQuestions({
243
294
  questions: [
@@ -270,16 +321,47 @@ export function registerExitPlan(registry, deps) {
270
321
  hash,
271
322
  decision,
272
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
+ }
273
349
  const base = {
274
350
  planId,
275
351
  revision,
276
352
  ...(artifact ? { artifactId: artifact.id } : {}),
277
353
  ...report,
354
+ ...criteriaReport(activated),
278
355
  };
279
356
  const body = decision === "approve"
280
357
  ? answer("approved", "The user approved the plan. The build agent will implement exactly this plan in the " +
281
358
  "next turn. Do not implement anything yourself: reply with one short sentence and " +
282
- "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)
283
365
  : decision === "context"
284
366
  ? answer("feedback", "The user added context instead of deciding. Revise the plan to account for it and " +
285
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[];
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Command fingerprint v1 (specs/011, Fase 0 decision): `sha256(JSON.stringify({v:1, argv, cwd}))`.
3
+ * `run_process` uses `[command, ...args]`; `shell` and `bg_run` use `["sh","-c", normalized]`; the
4
+ * working directory is relative to the workspace with `/` and `.` by default. No environment.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { isAbsolute, relative, resolve } from "node:path";
8
+ /** The canonical workspace-relative directory: forward slashes, no `./` prefix, `.` when empty. */
9
+ export function normalizeCwd(cwd) {
10
+ return (cwd
11
+ .split("\\")
12
+ .join("/")
13
+ .replace(/^(\.\/)+/, "")
14
+ .replace(/\/+$/, "") || ".");
15
+ }
16
+ /** Directory of a call relative to the workspace (`.` when it is the workspace itself). */
17
+ export function relativeCwd(workspace, cwd) {
18
+ if (!cwd)
19
+ return ".";
20
+ const abs = isAbsolute(cwd) ? cwd : resolve(workspace, cwd);
21
+ return normalizeCwd(relative(workspace, abs));
22
+ }
23
+ export function commandFingerprint(argv, cwd) {
24
+ return createHash("sha256")
25
+ .update(JSON.stringify({ v: 1, argv, cwd: normalizeCwd(cwd) }))
26
+ .digest("hex");
27
+ }
28
+ /** Trims and collapses whitespace runs OUTSIDE single/double quotes; quoted text stays verbatim. */
29
+ export function normalizeShell(command) {
30
+ let out = "";
31
+ let quote;
32
+ let pendingSpace = false;
33
+ for (const ch of command.trim()) {
34
+ if (quote) {
35
+ out += ch;
36
+ if (ch === quote)
37
+ quote = undefined;
38
+ }
39
+ else if (/\s/.test(ch)) {
40
+ pendingSpace = true;
41
+ }
42
+ else {
43
+ if (pendingSpace)
44
+ out += " ";
45
+ pendingSpace = false;
46
+ out += ch;
47
+ if (ch === '"' || ch === "'")
48
+ quote = ch;
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+ /** Fingerprint of a call as the tool received it. */
54
+ export function callFingerprint(tool, input, cwd) {
55
+ const argv = tool === "run_process"
56
+ ? [input.command, ...(input.args ?? [])]
57
+ : ["sh", "-c", normalizeShell(input.command)];
58
+ return commandFingerprint(argv, cwd);
59
+ }
60
+ const SHELL_OPERATORS = /[|&;<>()$`\\*?~#{}!\n]/;
61
+ /**
62
+ * Splits a plain command line into an argv (single and double quotes group words). Returns
63
+ * undefined when the line needs a shell (operators, expansions, unbalanced quotes): only the
64
+ * shell form can then match it.
65
+ */
66
+ export function splitCommand(command) {
67
+ const argv = [];
68
+ let word;
69
+ let quote;
70
+ for (const ch of command.trim()) {
71
+ if (quote) {
72
+ if (ch === quote)
73
+ quote = undefined;
74
+ else
75
+ word += ch;
76
+ }
77
+ else if (ch === '"' || ch === "'") {
78
+ quote = ch;
79
+ word ??= "";
80
+ }
81
+ else if (/\s/.test(ch)) {
82
+ if (word !== undefined)
83
+ argv.push(word);
84
+ word = undefined;
85
+ }
86
+ else if (SHELL_OPERATORS.test(ch)) {
87
+ return undefined;
88
+ }
89
+ else {
90
+ word = (word ?? "") + ch;
91
+ }
92
+ }
93
+ if (quote)
94
+ return undefined;
95
+ if (word !== undefined)
96
+ argv.push(word);
97
+ return argv.length ? argv : undefined;
98
+ }
99
+ /**
100
+ * Every fingerprint a call may have to satisfy the criterion command `command` run in `cwd`: the
101
+ * `shell` form always and the `run_process` form when the line is a plain argv.
102
+ */
103
+ export function criterionFingerprints(command, cwd) {
104
+ const out = [callFingerprint("shell", { command }, cwd)];
105
+ const argv = splitCommand(command);
106
+ if (argv)
107
+ out.unshift(commandFingerprint(argv, cwd));
108
+ return out;
109
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pure parser and judge of the `coverage` verifier (specs/011 3.1, Fase 7). They read the text a
3
+ * coverage command printed and say whether the overall percentage reaches `minPercent`; they never
4
+ * run anything and never generate tests. The evidence itself is captured by the recorder from the
5
+ * RAW result of a call the runner executed under its gate (D3, D6).
6
+ *
7
+ * Formats, tried from the most explicit overall figure to the least:
8
+ * 1. `coverage.py` / pytest-cov: the `TOTAL` row (last percentage) or `Total coverage: N%`.
9
+ * 2. A text table (vitest, c8, istanbul): the `All files` row, `% Lines` column.
10
+ * 3. An istanbul `text-summary`: the `Lines : N%` row.
11
+ * 4. `lcov --summary`: the `lines......: N%` row.
12
+ * 5. Raw `lcov` tracefile: sum of `LH` over sum of `LF`.
13
+ * Per-file rows never stand in for the total: a missing overall figure is "not found", so an
14
+ * unparseable output can never verify a criterion. Several matches of one format: the last wins.
15
+ */
16
+ import type { ProofVerifierSpec } from "@alisio/sdk";
17
+ export type CoverageSpec = Extract<ProofVerifierSpec, {
18
+ kind: "coverage";
19
+ }>;
20
+ export type CoverageFormat = "coverage.py" | "table" | "summary" | "lcov-summary" | "lcov";
21
+ export interface ParsedCoverage {
22
+ percent: number;
23
+ format: CoverageFormat;
24
+ }
25
+ /** The overall coverage percentage a command printed, or undefined when there is none to trust. */
26
+ export declare function parseCoverage(output: string): ParsedCoverage | undefined;
27
+ /** What a coverage judgement concluded: the verdict and a short, secret-free sentence about it. */
28
+ export interface CoverageJudgement {
29
+ ok: boolean;
30
+ summary: string;
31
+ }
32
+ /**
33
+ * Judges what the coverage command did. It verifies only when the command exited 0 AND an overall
34
+ * percentage was found AND it reaches `minPercent` (inclusive); every other case is a failure with
35
+ * a reason, so an unparseable output or a crashed run is never silently verified.
36
+ */
37
+ export declare function judgeCoverage(spec: CoverageSpec, run: {
38
+ exitCode: number;
39
+ output: string;
40
+ }): CoverageJudgement;
@@ -0,0 +1,102 @@
1
+ const ANSI = /\u001b\[[0-9;]*[A-Za-z]/g;
2
+ const NUMBER = String.raw `(\d{1,3}(?:\.\d+)?)`;
3
+ const valid = (value) => Number.isFinite(value) && value >= 0 && value <= 100;
4
+ const lastMatch = (text, pattern) => {
5
+ let found;
6
+ for (const match of text.matchAll(pattern))
7
+ found = match;
8
+ return found;
9
+ };
10
+ function pythonTotal(text) {
11
+ // pytest-cov prints the precise figure on its own line; prefer it over the rounded TOTAL row.
12
+ const line = lastMatch(text, new RegExp(String.raw `Total coverage:\s*${NUMBER}%`, "gi"));
13
+ if (line?.[1])
14
+ return Number(line[1]);
15
+ const row = lastMatch(text, new RegExp(String.raw `^\s*TOTAL\b[^\n]*?(?<![\d.])${NUMBER}%[ \t]*$`, "gim"));
16
+ return row?.[1] ? Number(row[1]) : undefined;
17
+ }
18
+ function tableTotal(text) {
19
+ const lines = text.split("\n");
20
+ let column = -1;
21
+ let result;
22
+ for (const line of lines) {
23
+ if (!line.includes("|"))
24
+ continue;
25
+ const cells = line.split("|").map((cell) => cell.trim());
26
+ const header = cells.findIndex((cell) => /^%\s*lines?$/i.test(cell));
27
+ if (header >= 0) {
28
+ column = header;
29
+ continue;
30
+ }
31
+ if (column < 0 || !/^all files$/i.test(cells[0] ?? ""))
32
+ continue;
33
+ const raw = (cells[column] ?? "").replace(/%$/, "");
34
+ result = /^\d{1,3}(?:\.\d+)?$/.test(raw) ? Number(raw) : undefined;
35
+ }
36
+ return result;
37
+ }
38
+ function summaryLines(text) {
39
+ const row = lastMatch(text, new RegExp(String.raw `^\s*Lines\s*:\s*${NUMBER}%`, "gim"));
40
+ return row?.[1] ? Number(row[1]) : undefined;
41
+ }
42
+ function lcovSummary(text) {
43
+ const row = lastMatch(text, new RegExp(String.raw `^\s*lines\.+:\s*${NUMBER}%`, "gim"));
44
+ return row?.[1] ? Number(row[1]) : undefined;
45
+ }
46
+ function lcovTracefile(text) {
47
+ let found = 0;
48
+ let hit = 0;
49
+ for (const match of text.matchAll(/^(LF|LH):(\d+)\s*$/gm)) {
50
+ if (match[1] === "LF")
51
+ found += Number(match[2]);
52
+ else
53
+ hit += Number(match[2]);
54
+ }
55
+ return found > 0 ? (hit / found) * 100 : undefined;
56
+ }
57
+ const PARSERS = [
58
+ ["coverage.py", pythonTotal],
59
+ ["table", tableTotal],
60
+ ["summary", summaryLines],
61
+ ["lcov-summary", lcovSummary],
62
+ ["lcov", lcovTracefile],
63
+ ];
64
+ /** The overall coverage percentage a command printed, or undefined when there is none to trust. */
65
+ export function parseCoverage(output) {
66
+ const text = output.replace(ANSI, "");
67
+ for (const [format, parse] of PARSERS) {
68
+ const percent = parse(text);
69
+ if (percent !== undefined && valid(percent))
70
+ return { percent, format };
71
+ }
72
+ return undefined;
73
+ }
74
+ const shown = (value) => String(Math.round(value * 100) / 100);
75
+ /**
76
+ * Judges what the coverage command did. It verifies only when the command exited 0 AND an overall
77
+ * percentage was found AND it reaches `minPercent` (inclusive); every other case is a failure with
78
+ * a reason, so an unparseable output or a crashed run is never silently verified.
79
+ */
80
+ export function judgeCoverage(spec, run) {
81
+ const parsed = parseCoverage(run.output);
82
+ if (!parsed)
83
+ return {
84
+ ok: false,
85
+ summary: `no coverage percentage found in the output (command exited ${run.exitCode}); expected lcov, a vitest/istanbul table or coverage.py report`,
86
+ };
87
+ const percent = `${shown(parsed.percent)}%`;
88
+ if (run.exitCode !== 0)
89
+ return {
90
+ ok: false,
91
+ summary: `the coverage command exited ${run.exitCode} (reported ${percent}, ${parsed.format})`,
92
+ };
93
+ if (parsed.percent < spec.minPercent)
94
+ return {
95
+ ok: false,
96
+ summary: `coverage ${percent} is below the minimum ${shown(spec.minPercent)}% (${parsed.format})`,
97
+ };
98
+ return {
99
+ ok: true,
100
+ summary: `coverage ${percent} reaches the minimum ${shown(spec.minPercent)}% (${parsed.format})`,
101
+ };
102
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Claim/evidence divergence (specs/011 3.5, D7, D8). `detectDivergence` is pure: it reads closed
3
+ * EN/ES claim patterns in a final answer ("tests pass", "typecheck ok", "lint clean", "los tests
4
+ * pasan"...), cross-references them with the criteria of an equivalent kind and reports the ones
5
+ * the ledger does not back. It never edits text or statuses; it is a heuristic signal, not a
6
+ * guarantee, and claims inside quotes and code are ignored to limit false positives.
7
+ */
8
+ import type { ProofLedger, ProofStatus } from "@alisio/sdk";
9
+ export type ClaimKind = "test" | "typecheck" | "lint";
10
+ export interface Divergence {
11
+ /** The matched wording, whitespace-collapsed and capped. */
12
+ claim: string;
13
+ kind: ClaimKind;
14
+ /** What would have backed the claim. */
15
+ expected: "verified";
16
+ /** The status of the equivalent criterion (`not_checked` when there is none). */
17
+ actualState: ProofStatus;
18
+ criterionId?: string;
19
+ }
20
+ /**
21
+ * The claims of `finalText` that `ledger` does not back, in order of appearance. An empty ledger
22
+ * yields nothing (D7: without criteria Proof stays out of the way).
23
+ */
24
+ export declare function detectDivergence(finalText: string, ledger: ProofLedger): Divergence[];