@osolmaz/pi-workflows 0.13.0 → 0.13.2

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 (84) hide show
  1. package/README.md +11 -9
  2. package/dist/builtins/autodoc.workflow.d.ts +191 -4
  3. package/dist/builtins/autodoc.workflow.js +156 -30
  4. package/dist/builtins/autodoc.workflow.js.map +1 -1
  5. package/dist/builtins/autoimplement-command-batches.d.ts +1 -0
  6. package/dist/builtins/autoimplement-command-batches.js +29 -31
  7. package/dist/builtins/autoimplement-command-batches.js.map +1 -1
  8. package/dist/builtins/autoimplement.workflow.d.ts +1259 -29
  9. package/dist/builtins/autoimplement.workflow.js +416 -153
  10. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  11. package/dist/builtins/autoplan.workflow.d.ts +6 -0
  12. package/dist/builtins/autoplan.workflow.js +68 -32
  13. package/dist/builtins/autoplan.workflow.js.map +1 -1
  14. package/dist/builtins/catalog.js +5 -5
  15. package/dist/builtins/catalog.js.map +1 -1
  16. package/dist/builtins/change-verification.workflow.d.ts +110 -0
  17. package/dist/builtins/change-verification.workflow.js +860 -0
  18. package/dist/builtins/change-verification.workflow.js.map +1 -0
  19. package/dist/builtins/monitor.workflow.js +4 -3
  20. package/dist/builtins/monitor.workflow.js.map +1 -1
  21. package/dist/builtins/plain-summary.workflow.js +35 -24
  22. package/dist/builtins/plain-summary.workflow.js.map +1 -1
  23. package/dist/builtins/plan-change.workflow.d.ts +361 -6
  24. package/dist/builtins/plan-change.workflow.js +26 -0
  25. package/dist/builtins/plan-change.workflow.js.map +1 -1
  26. package/dist/builtins/sanity-check.workflow.js +19 -22
  27. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  28. package/dist/builtins/workspace-preparation.workflow.d.ts +75 -0
  29. package/dist/builtins/workspace-preparation.workflow.js +498 -0
  30. package/dist/builtins/workspace-preparation.workflow.js.map +1 -0
  31. package/dist/controllers/sqlite.js +16 -51
  32. package/dist/controllers/sqlite.js.map +1 -1
  33. package/dist/extension/decision-channels.js +45 -7
  34. package/dist/extension/decision-channels.js.map +1 -1
  35. package/dist/extension/executor.js +1 -4
  36. package/dist/extension/executor.js.map +1 -1
  37. package/dist/extension/index.js +2 -15
  38. package/dist/extension/index.js.map +1 -1
  39. package/dist/extension/recorder.d.ts +1 -1
  40. package/dist/extension/recorder.js +8 -8
  41. package/dist/extension/recorder.js.map +1 -1
  42. package/dist/state/schema.js +22 -3
  43. package/dist/state/schema.js.map +1 -1
  44. package/dist/viewer/session-reducer.d.ts +0 -1
  45. package/dist/viewer/session-reducer.js +2 -24
  46. package/dist/viewer/session-reducer.js.map +1 -1
  47. package/dist/workflows/engine.js +4 -3
  48. package/dist/workflows/engine.js.map +1 -1
  49. package/dist/workflows/store.d.ts +6 -1
  50. package/dist/workflows/store.js +286 -33
  51. package/dist/workflows/store.js.map +1 -1
  52. package/dist/workflows/types.d.ts +1 -1
  53. package/docs/SQLITE_STATE.md +20 -14
  54. package/docs/WORKFLOW_COMPOSITION.md +8 -0
  55. package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +1 -1
  56. package/docs/plans/2026-08-24-change-scoped-verification-plan.md +419 -0
  57. package/docs/plans/2026-08-25-autoplan-user-intent-capture-plan.md +107 -0
  58. package/docs/session-event-journal.md +2 -3
  59. package/docs/workflows.md +43 -21
  60. package/herdr-plugin.toml +1 -1
  61. package/package.json +1 -1
  62. package/skills/autodoc/SKILL.md +7 -0
  63. package/skills/autoimplement/SKILL.md +4 -0
  64. package/src/builtins/autodoc.workflow.ts +184 -33
  65. package/src/builtins/autoimplement-command-batches.ts +39 -33
  66. package/src/builtins/autoimplement.workflow.ts +483 -175
  67. package/src/builtins/autoplan.workflow.ts +80 -32
  68. package/src/builtins/catalog.ts +5 -5
  69. package/src/builtins/change-verification.workflow.ts +1143 -0
  70. package/src/builtins/monitor.workflow.ts +6 -3
  71. package/src/builtins/plain-summary.workflow.ts +38 -40
  72. package/src/builtins/plan-change.workflow.ts +35 -0
  73. package/src/builtins/sanity-check.workflow.ts +19 -22
  74. package/src/builtins/workspace-preparation.workflow.ts +668 -0
  75. package/src/controllers/sqlite.ts +19 -53
  76. package/src/extension/decision-channels.ts +47 -7
  77. package/src/extension/executor.ts +1 -4
  78. package/src/extension/index.ts +2 -15
  79. package/src/extension/recorder.ts +10 -8
  80. package/src/state/schema.ts +22 -3
  81. package/src/viewer/session-reducer.ts +2 -29
  82. package/src/workflows/engine.ts +4 -3
  83. package/src/workflows/store.ts +397 -43
  84. package/src/workflows/types.ts +0 -1
@@ -0,0 +1,860 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { runCommandBatch, validateCommandBatchRequest, } from "../workflows/command-batch.js";
8
+ import { action, agent, compute, defineWorkflow } from "../workflows/definition.js";
9
+ import { validateVerificationCommandSafety } from "./autoimplement-command-batches.js";
10
+ import { parsePreparedWorkspace, } from "./workspace-preparation.workflow.js";
11
+ const execFileAsync = promisify(execFile);
12
+ export const CHANGE_VERIFICATION_SCHEMA = "pi-workflows.change-verification.v1";
13
+ const MAX_REPAIR_ATTEMPTS = 2;
14
+ function requireRecord(value, label) {
15
+ if (value === null || typeof value !== "object" || Array.isArray(value))
16
+ throw new Error(`${label} must be an object`);
17
+ return value;
18
+ }
19
+ function requireString(value, label) {
20
+ if (typeof value !== "string" || value.trim().length === 0)
21
+ throw new Error(`${label} must be a non-empty string`);
22
+ return value.trim();
23
+ }
24
+ function stringArray(value, label) {
25
+ if (value === undefined)
26
+ return [];
27
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
28
+ throw new Error(`${label} must be an array of strings`);
29
+ return [...value];
30
+ }
31
+ function positiveInteger(value, label, maximum) {
32
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum)
33
+ throw new Error(`${label} must be an integer from 1 through ${maximum}`);
34
+ return value;
35
+ }
36
+ function validateDirectCommand(command, args, label) {
37
+ validateVerificationCommandSafety(command, args, label);
38
+ if (args.some((arg) => arg.includes("\0")))
39
+ throw new Error(`${label} arguments cannot contain NUL`);
40
+ }
41
+ function parseMechanicalFix(value, repository, label) {
42
+ const record = requireRecord(value, label);
43
+ const command = requireString(record.command, `${label}.command`);
44
+ const args = stringArray(record.args, `${label}.args`);
45
+ validateDirectCommand(command, args, label);
46
+ const files = stringArray(record.files, `${label}.files`).map((file) => {
47
+ if (path.isAbsolute(file) || file.split(path.sep).includes(".."))
48
+ throw new Error(`${label}.files must stay inside ${repository}`);
49
+ return file;
50
+ });
51
+ if (files.length === 0)
52
+ throw new Error(`${label}.files must not be empty`);
53
+ return {
54
+ command,
55
+ args,
56
+ files,
57
+ timeoutMs: positiveInteger(record.timeoutMs, `${label}.timeoutMs`, 60 * 60_000),
58
+ maxOutputChars: positiveInteger(record.maxOutputChars, `${label}.maxOutputChars`, 1_000_000),
59
+ expectedDiff: requireString(record.expectedDiff, `${label}.expectedDiff`),
60
+ };
61
+ }
62
+ function parseCheck(value, workspace, index) {
63
+ const record = requireRecord(value, `verification checks[${index}]`);
64
+ const candidateRoot = workspace.worktreePath ?? workspace.repository;
65
+ const batch = validateCommandBatchRequest({
66
+ items: [
67
+ {
68
+ id: record.id,
69
+ command: record.command,
70
+ args: record.args,
71
+ cwd: record.cwd,
72
+ timeoutMs: record.timeoutMs,
73
+ maxOutputChars: record.maxOutputChars,
74
+ },
75
+ ],
76
+ maxConcurrency: 1,
77
+ }).items[0];
78
+ if (batch === undefined)
79
+ throw new Error(`verification checks[${index}] is missing`);
80
+ if (path.resolve(batch.cwd) !== path.resolve(candidateRoot))
81
+ throw new Error(`verification checks[${index}].cwd must equal the prepared workspace`);
82
+ validateDirectCommand(batch.command, batch.args, `verification checks[${index}]`);
83
+ if (typeof record.readOnly !== "boolean" ||
84
+ typeof record.baseEligible !== "boolean" ||
85
+ typeof record.changedFileScope !== "boolean") {
86
+ throw new Error(`verification checks[${index}] flags must be booleans`);
87
+ }
88
+ if (record.baseEligible === true && record.readOnly !== true)
89
+ throw new Error(`verification checks[${index}] base comparison requires readOnly=true`);
90
+ if (record.baseEligible === true &&
91
+ batch.args.some((arg) => arg.includes(path.resolve(candidateRoot)))) {
92
+ throw new Error(`verification checks[${index}] base comparison arguments must not reference the prepared workspace`);
93
+ }
94
+ if (record.findingFormat !== "text" && record.findingFormat !== "json")
95
+ throw new Error(`verification checks[${index}].findingFormat must be text or json`);
96
+ return {
97
+ ...batch,
98
+ readOnly: record.readOnly,
99
+ baseEligible: record.baseEligible,
100
+ changedFileScope: record.changedFileScope,
101
+ findingFormat: record.findingFormat,
102
+ ...(record.mechanicalFix === undefined
103
+ ? {}
104
+ : {
105
+ mechanicalFix: parseMechanicalFix(record.mechanicalFix, candidateRoot, `verification checks[${index}].mechanicalFix`),
106
+ }),
107
+ };
108
+ }
109
+ export function parseChangeVerificationInput(value) {
110
+ const record = requireRecord(value, "change verification input");
111
+ const workspace = parsePreparedWorkspace(record.workspace);
112
+ if (record.checks !== undefined && !Array.isArray(record.checks))
113
+ throw new Error("change verification checks must be an array");
114
+ const checks = record.checks?.map((check, index) => parseCheck(check, workspace, index));
115
+ const ids = new Set();
116
+ for (const check of checks ?? []) {
117
+ if (ids.has(check.id))
118
+ throw new Error(`verification check id is duplicated: ${check.id}`);
119
+ ids.add(check.id);
120
+ }
121
+ return {
122
+ originatingWorkflow: requireString(record.originatingWorkflow, "change verification originatingWorkflow"),
123
+ qualifiedNode: requireString(record.qualifiedNode, "change verification qualifiedNode"),
124
+ workspace,
125
+ ...(checks === undefined ? {} : { checks }),
126
+ changedFiles: stringArray(record.changedFiles, "change verification changedFiles"),
127
+ untested: stringArray(record.untested, "change verification untested"),
128
+ ...(record.plan === undefined ? {} : { plan: record.plan }),
129
+ maxConcurrency: record.maxConcurrency === undefined
130
+ ? 2
131
+ : positiveInteger(record.maxConcurrency, "change verification maxConcurrency", 8),
132
+ };
133
+ }
134
+ function parsePlannedChecks(value, context) {
135
+ const record = requireRecord(value, "verification command plan");
136
+ if (!Array.isArray(record.checks) || record.checks.length === 0)
137
+ throw new Error("verification command plan checks must be a non-empty array");
138
+ const input = context.input;
139
+ return record.checks.map((check, index) => parseCheck(check, input.workspace, index));
140
+ }
141
+ async function runBatch(context, checks, root) {
142
+ const items = checks.map((check) => ({
143
+ id: check.id,
144
+ command: check.command,
145
+ args: [...check.args],
146
+ cwd: root ?? check.cwd,
147
+ timeoutMs: check.timeoutMs,
148
+ maxOutputChars: check.maxOutputChars,
149
+ }));
150
+ return await runCommandBatch({
151
+ items,
152
+ maxConcurrency: Math.min(context.input.maxConcurrency ?? 2, Math.max(1, items.length)),
153
+ }, { signal: context.signal });
154
+ }
155
+ function emptyBatch() {
156
+ return { schema: "pi-workflows.command-batch-result.v1", items: [], completed: 0, total: 0 };
157
+ }
158
+ async function git(cwd, args) {
159
+ const result = await execFileAsync("git", args, {
160
+ cwd,
161
+ encoding: "utf8",
162
+ maxBuffer: 5_000_000,
163
+ timeout: 60_000,
164
+ });
165
+ return result.stdout.trim();
166
+ }
167
+ function candidateRoot(workspace) {
168
+ return workspace.worktreePath ?? workspace.repository;
169
+ }
170
+ const NODE_DEPENDENCY_INPUT_PATHS = [
171
+ ":(glob)**/package.json",
172
+ ":(glob)**/package-lock.json",
173
+ ":(glob)**/npm-shrinkwrap.json",
174
+ ":(glob)**/yarn.lock",
175
+ ":(glob)**/pnpm-lock.yaml",
176
+ ":(glob)**/pnpm-workspace.yaml",
177
+ ":(glob)**/bun.lock",
178
+ ":(glob)**/bun.lockb",
179
+ ":(glob)**/.npmrc",
180
+ ":(glob)**/.yarnrc",
181
+ ":(glob)**/.yarnrc.yml",
182
+ ":(glob)**/.pnpmfile.cjs",
183
+ ":(glob)**/patches/**",
184
+ ];
185
+ async function nodeDependencyInputsChanged(workspace) {
186
+ const root = candidateRoot(workspace);
187
+ const [tracked, untracked] = await Promise.all([
188
+ git(root, [
189
+ "diff",
190
+ "--name-only",
191
+ workspace.baseRevision,
192
+ "--",
193
+ ...NODE_DEPENDENCY_INPUT_PATHS,
194
+ ]),
195
+ git(root, ["ls-files", "--others", "--exclude-standard", "--", ...NODE_DEPENDENCY_INPUT_PATHS]),
196
+ ]);
197
+ return tracked.length > 0 || untracked.length > 0;
198
+ }
199
+ async function runCandidate(context, checks) {
200
+ return await runBatch(context, checks);
201
+ }
202
+ async function runBase(context, checks) {
203
+ const input = context.input;
204
+ const eligible = checks.filter((check) => check.readOnly && check.baseEligible);
205
+ if (eligible.length === 0)
206
+ return {
207
+ batch: emptyBatch(),
208
+ baseEvidence: ["No checks were eligible for base comparison."],
209
+ cleanupEvidence: [],
210
+ };
211
+ const repository = input.workspace.repository;
212
+ const parent = await fs.mkdtemp(path.join(os.tmpdir(), "pi-workflows-base-"));
213
+ const worktree = path.join(parent, "worktree");
214
+ const baseEvidence = [];
215
+ const cleanupEvidence = [];
216
+ try {
217
+ await git(repository, ["worktree", "add", "--detach", worktree, input.workspace.baseRevision]);
218
+ baseEvidence.push(`Created detached base worktree at ${worktree}`);
219
+ const candidateModules = path.join(candidateRoot(input.workspace), "node_modules");
220
+ let candidateModulesAvailable = false;
221
+ try {
222
+ candidateModulesAvailable = (await fs.stat(candidateModules)).isDirectory();
223
+ if (!candidateModulesAvailable) {
224
+ baseEvidence.push("The candidate node_modules path is not a directory.");
225
+ }
226
+ }
227
+ catch (error) {
228
+ if (error.code === "ENOENT") {
229
+ baseEvidence.push("No candidate dependency installation was available for reuse.");
230
+ }
231
+ else {
232
+ baseEvidence.push(`Candidate dependency inspection failed: ${error instanceof Error ? error.message : String(error)}`);
233
+ return { batch: emptyBatch(), baseEvidence, cleanupEvidence };
234
+ }
235
+ }
236
+ if (candidateModulesAvailable) {
237
+ try {
238
+ if (await nodeDependencyInputsChanged(input.workspace)) {
239
+ baseEvidence.push("Candidate dependencies were not reused because dependency inputs differ from the base revision.");
240
+ return { batch: emptyBatch(), baseEvidence, cleanupEvidence };
241
+ }
242
+ await fs.symlink(candidateModules, path.join(worktree, "node_modules"), "dir");
243
+ baseEvidence.push("Reused the candidate dependency installation after confirming that dependency inputs match the base revision.");
244
+ }
245
+ catch (error) {
246
+ baseEvidence.push(`Equivalent dependency reuse could not be established: ${error instanceof Error ? error.message : String(error)}`);
247
+ return { batch: emptyBatch(), baseEvidence, cleanupEvidence };
248
+ }
249
+ }
250
+ const batch = await runBatch(context, eligible, worktree);
251
+ return { batch, baseEvidence, cleanupEvidence };
252
+ }
253
+ catch (error) {
254
+ baseEvidence.push(`Base setup or execution failed: ${error instanceof Error ? error.message : String(error)}`);
255
+ return { batch: emptyBatch(), baseEvidence, cleanupEvidence };
256
+ }
257
+ finally {
258
+ try {
259
+ await git(repository, ["worktree", "remove", "--force", worktree]);
260
+ cleanupEvidence.push(`Removed detached base worktree ${worktree}`);
261
+ }
262
+ catch (error) {
263
+ cleanupEvidence.push(`Base worktree cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
264
+ await fs.rm(parent, { recursive: true, force: true }).catch(() => undefined);
265
+ }
266
+ await fs.rm(parent, { recursive: true, force: true }).catch(() => undefined);
267
+ }
268
+ }
269
+ function resultComplete(result) {
270
+ return (result.outcome !== "timedOut" &&
271
+ result.outcome !== "cancelled" &&
272
+ !result.stdoutTruncated &&
273
+ !result.stderrTruncated &&
274
+ !(result.outcome === "failed" && result.exitCode === null));
275
+ }
276
+ function resultPassed(result) {
277
+ return (result !== undefined &&
278
+ resultComplete(result) &&
279
+ result.outcome === "succeeded" &&
280
+ result.exitCode === 0);
281
+ }
282
+ function normalizeText(value, basePath, candidatePath) {
283
+ return value.replaceAll("\r\n", "\n").replaceAll(basePath, candidatePath);
284
+ }
285
+ function stableFindings(result, format) {
286
+ if (format !== "json")
287
+ return null;
288
+ try {
289
+ const parsed = JSON.parse(result.stdout);
290
+ if (!Array.isArray(parsed))
291
+ return null;
292
+ const ids = parsed.map((entry) => {
293
+ const record = requireRecord(entry, "JSON finding");
294
+ return requireString(record.id, "JSON finding id");
295
+ });
296
+ return ids.sort();
297
+ }
298
+ catch {
299
+ return null;
300
+ }
301
+ }
302
+ function resultFingerprint(result, check, basePath, candidatePath) {
303
+ if (result === undefined)
304
+ return "missing";
305
+ const ids = stableFindings(result, check.findingFormat);
306
+ const source = ids === null
307
+ ? JSON.stringify({
308
+ outcome: result.outcome,
309
+ exitCode: result.exitCode,
310
+ signal: result.signal,
311
+ stdout: normalizeText(result.stdout, basePath, candidatePath),
312
+ stderr: normalizeText(result.stderr, basePath, candidatePath),
313
+ stdoutTruncated: result.stdoutTruncated,
314
+ stderrTruncated: result.stderrTruncated,
315
+ })
316
+ : JSON.stringify({ outcome: result.outcome, exitCode: result.exitCode, ids });
317
+ return createHash("sha256").update(source).digest("hex");
318
+ }
319
+ function finding(check, kind, summary, fingerprint, candidate, base) {
320
+ return {
321
+ checkId: check.id,
322
+ kind,
323
+ summary,
324
+ fingerprint,
325
+ ...(candidate === undefined ? {} : { candidateOutputRef: `candidate:${candidate.id}` }),
326
+ ...(base === undefined ? {} : { baseOutputRef: `base:${base.id}` }),
327
+ };
328
+ }
329
+ function previousRepairAttempts(context) {
330
+ return context.state.steps.flatMap((step) => {
331
+ if ((step.nodeId !== "mechanicalRepair" &&
332
+ step.nodeId !== "semanticRepair" &&
333
+ step.nodeId !== "judge") ||
334
+ step.outcome !== "ok")
335
+ return [];
336
+ const output = step.output;
337
+ return output?.attempt === undefined ? [] : [output];
338
+ });
339
+ }
340
+ export function classifyVerification(input, state) {
341
+ const root = candidateRoot(input.workspace);
342
+ const baseRoot = state.base.items[0]?.cwd ?? root;
343
+ const candidate = new Map(state.candidate.items.map((item) => [item.id, item]));
344
+ const base = new Map(state.base.items.map((item) => [item.id, item]));
345
+ const related = [];
346
+ const unrelated = [];
347
+ const fixed = [];
348
+ const unknown = [];
349
+ for (const check of state.checks) {
350
+ const candidateResult = candidate.get(check.id);
351
+ const baseResult = base.get(check.id);
352
+ const candidateFingerprint = resultFingerprint(candidateResult, check, root, root);
353
+ const baseFingerprint = resultFingerprint(baseResult, check, baseRoot, root);
354
+ if (candidateResult === undefined || !resultComplete(candidateResult)) {
355
+ unknown.push(finding(check, "unknown", "Candidate result is missing, cancelled, timed out, truncated, or incomplete.", candidateFingerprint, candidateResult, baseResult));
356
+ continue;
357
+ }
358
+ if (!check.baseEligible || !check.readOnly) {
359
+ if (!resultPassed(candidateResult))
360
+ related.push(finding(check, "related", "Candidate-only check failed.", candidateFingerprint, candidateResult));
361
+ continue;
362
+ }
363
+ if (baseResult === undefined || !resultComplete(baseResult)) {
364
+ if (!resultPassed(candidateResult))
365
+ unknown.push(finding(check, "unknown", "Base comparison was unavailable or incomplete.", `${candidateFingerprint}:${baseFingerprint}`, candidateResult, baseResult));
366
+ continue;
367
+ }
368
+ const candidatePass = resultPassed(candidateResult);
369
+ const basePass = resultPassed(baseResult);
370
+ if (candidatePass && !basePass)
371
+ fixed.push(finding(check, "fixedBaseline", "The candidate fixes a failure present on the base.", baseFingerprint, candidateResult, baseResult));
372
+ else if (!candidatePass && basePass)
373
+ related.push(finding(check, "related", "The failure appears only on the candidate.", candidateFingerprint, candidateResult, baseResult));
374
+ else if (!candidatePass && !basePass && candidateFingerprint === baseFingerprint)
375
+ unrelated.push(finding(check, "unrelated", "The same failure is present on the candidate and base.", candidateFingerprint, candidateResult, baseResult));
376
+ else if (!candidatePass && !basePass)
377
+ unknown.push(finding(check, "unknown", "Candidate and base both fail differently.", `${candidateFingerprint}:${baseFingerprint}`, candidateResult, baseResult));
378
+ }
379
+ const untested = (input.untested ?? []).map((summary, index) => ({
380
+ checkId: `untested-${index + 1}`,
381
+ kind: "untested",
382
+ summary,
383
+ fingerprint: createHash("sha256").update(summary).digest("hex"),
384
+ }));
385
+ if (state.cleanupEvidence.some((item) => item.includes("failed"))) {
386
+ unknown.push({
387
+ checkId: "base-cleanup",
388
+ kind: "unknown",
389
+ summary: "Temporary base worktree cleanup failed.",
390
+ fingerprint: createHash("sha256").update(state.cleanupEvidence.join("\n")).digest("hex"),
391
+ });
392
+ }
393
+ const rejectedRepair = state.repairAttempts.find((attempt) => attempt.result.startsWith("Rejected mechanical fixer:"));
394
+ if (rejectedRepair !== undefined) {
395
+ unknown.push({
396
+ checkId: "mechanical-fix-boundary",
397
+ kind: "unknown",
398
+ summary: rejectedRepair.result,
399
+ fingerprint: createHash("sha256").update(rejectedRepair.result).digest("hex"),
400
+ });
401
+ }
402
+ const failureFingerprint = createHash("sha256")
403
+ .update(JSON.stringify({
404
+ related: related.map((item) => item.fingerprint).sort(),
405
+ unknown: unknown.map((item) => item.fingerprint).sort(),
406
+ untested: untested.map((item) => item.fingerprint).sort(),
407
+ }))
408
+ .digest("hex");
409
+ const route = rejectedRepair !== undefined
410
+ ? "blocked"
411
+ : unknown.length > 0 || untested.length > 0
412
+ ? "needsJudgment"
413
+ : related.length === 0
414
+ ? "ready"
415
+ : "repairable";
416
+ return {
417
+ schema: CHANGE_VERIFICATION_SCHEMA,
418
+ route,
419
+ originatingWorkflow: input.originatingWorkflow,
420
+ qualifiedNode: input.qualifiedNode,
421
+ workspace: input.workspace,
422
+ changedFiles: input.changedFiles ?? [],
423
+ candidateCommands: state.candidate,
424
+ baseCommands: state.base,
425
+ relatedFailures: related,
426
+ unrelatedFailures: unrelated,
427
+ fixedBaselineFailures: fixed,
428
+ unknownFailures: unknown,
429
+ untestedChecks: untested,
430
+ repairAttempts: state.repairAttempts,
431
+ failureFingerprint,
432
+ outputReferences: [
433
+ ...state.candidate.items.map((item) => `candidate:${item.id}`),
434
+ ...state.base.items.map((item) => `base:${item.id}`),
435
+ ],
436
+ reason: route === "ready"
437
+ ? "All current-change checks passed; baseline failures remain visible."
438
+ : route === "repairable"
439
+ ? "Current-change failures have a bounded repair path."
440
+ : route === "needsJudgment"
441
+ ? "Complete evidence needs bounded attribution or repair judgment."
442
+ : "Verification could not continue safely.",
443
+ evidence: [...state.baseEvidence, ...state.cleanupEvidence],
444
+ };
445
+ }
446
+ function checksForContext(context) {
447
+ const input = context.input;
448
+ return input.checks ?? context.outputs.planChecks;
449
+ }
450
+ function latestExecution(context) {
451
+ const checks = checksForContext(context);
452
+ const candidate = context.outputs.runCandidate;
453
+ const baseOutput = context.outputs.runBase;
454
+ return {
455
+ checks,
456
+ candidate,
457
+ base: baseOutput.batch,
458
+ baseEvidence: baseOutput.baseEvidence,
459
+ cleanupEvidence: baseOutput.cleanupEvidence,
460
+ repairAttempts: previousRepairAttempts(context),
461
+ };
462
+ }
463
+ function latestClassification(context) {
464
+ return classifyVerification(context.input, latestExecution(context));
465
+ }
466
+ function statusPaths(output) {
467
+ const entries = output.split("\0").filter(Boolean);
468
+ const files = [];
469
+ for (let index = 0; index < entries.length; index += 1) {
470
+ const entry = entries[index];
471
+ const status = entry.slice(0, 2);
472
+ files.push(entry.slice(3));
473
+ if ((status.includes("R") || status.includes("C")) && entries[index + 1] !== undefined) {
474
+ files.push(entries[index + 1]);
475
+ index += 1;
476
+ }
477
+ }
478
+ return [...new Set(files)].sort();
479
+ }
480
+ function workspacePath(root, file) {
481
+ const resolvedRoot = path.resolve(root);
482
+ const resolved = path.resolve(resolvedRoot, file);
483
+ if (resolved === resolvedRoot || !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
484
+ throw new Error(`Workspace path escapes the repository: ${file}`);
485
+ }
486
+ return resolved;
487
+ }
488
+ async function pathFingerprint(root, file) {
489
+ try {
490
+ const target = workspacePath(root, file);
491
+ const stat = await fs.lstat(target);
492
+ const hash = createHash("sha256").update(`${stat.mode}:${stat.isSymbolicLink()}:`);
493
+ if (stat.isSymbolicLink())
494
+ hash.update(await fs.readlink(target));
495
+ else if (stat.isFile())
496
+ hash.update(await fs.readFile(target));
497
+ else
498
+ hash.update("directory");
499
+ return hash.digest("hex");
500
+ }
501
+ catch (error) {
502
+ if (error.code === "ENOENT")
503
+ return "missing";
504
+ throw error;
505
+ }
506
+ }
507
+ async function gitState(root) {
508
+ const status = await execFileAsync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=traditional"], { cwd: root, encoding: "utf8", maxBuffer: 5_000_000, timeout: 60_000 });
509
+ const state = new Map();
510
+ for (const file of statusPaths(status.stdout)) {
511
+ state.set(file, await pathFingerprint(root, file));
512
+ }
513
+ return state;
514
+ }
515
+ async function backupPaths(root, files) {
516
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-workflows-repair-"));
517
+ const snapshots = new Map();
518
+ let index = 0;
519
+ try {
520
+ for (const file of files) {
521
+ const source = workspacePath(root, file);
522
+ const backupPath = path.join(directory, String(index));
523
+ index += 1;
524
+ try {
525
+ await fs.lstat(source);
526
+ await fs.cp(source, backupPath, {
527
+ recursive: true,
528
+ dereference: false,
529
+ preserveTimestamps: true,
530
+ verbatimSymlinks: true,
531
+ });
532
+ snapshots.set(file, { existed: true, backupPath });
533
+ }
534
+ catch (error) {
535
+ if (error.code !== "ENOENT")
536
+ throw error;
537
+ snapshots.set(file, { existed: false });
538
+ }
539
+ }
540
+ return { directory, paths: snapshots };
541
+ }
542
+ catch (error) {
543
+ await fs.rm(directory, { recursive: true, force: true });
544
+ throw error;
545
+ }
546
+ }
547
+ async function gitBlob(root, object) {
548
+ return await new Promise((resolve, reject) => {
549
+ execFile("git", ["cat-file", "blob", object], { cwd: root, encoding: "buffer", maxBuffer: 100_000_000, timeout: 60_000 }, (error, stdout) => {
550
+ if (error !== null)
551
+ reject(error);
552
+ else
553
+ resolve(stdout);
554
+ });
555
+ });
556
+ }
557
+ async function restoreFromRevision(root, file, revision) {
558
+ const target = workspacePath(root, file);
559
+ const tree = await execFileAsync("git", ["ls-tree", "-z", revision, "--", file], {
560
+ cwd: root,
561
+ encoding: "utf8",
562
+ maxBuffer: 5_000_000,
563
+ timeout: 60_000,
564
+ });
565
+ if (tree.stdout.length === 0) {
566
+ await fs.rm(target, { recursive: true, force: true });
567
+ return;
568
+ }
569
+ const metadata = tree.stdout.slice(0, tree.stdout.indexOf("\t")).split(" ");
570
+ const mode = metadata[0];
571
+ const type = metadata[1];
572
+ const object = metadata[2];
573
+ if (mode === undefined || type !== "blob" || object === undefined) {
574
+ throw new Error(`Cannot restore unsupported Git entry ${file}`);
575
+ }
576
+ const content = await gitBlob(root, object);
577
+ await fs.rm(target, { recursive: true, force: true });
578
+ await fs.mkdir(path.dirname(target), { recursive: true });
579
+ if (mode === "120000")
580
+ await fs.symlink(content.toString(), target);
581
+ else {
582
+ const permissions = mode === "100755" ? 0o755 : 0o644;
583
+ await fs.writeFile(target, content, { mode: permissions });
584
+ await fs.chmod(target, permissions);
585
+ }
586
+ }
587
+ async function restorePath(root, file, backup, revision) {
588
+ const target = workspacePath(root, file);
589
+ if (backup === undefined) {
590
+ await restoreFromRevision(root, file, revision);
591
+ return;
592
+ }
593
+ await fs.rm(target, { recursive: true, force: true });
594
+ if (!backup.existed)
595
+ return;
596
+ await fs.mkdir(path.dirname(target), { recursive: true });
597
+ await fs.cp(backup.backupPath, target, {
598
+ recursive: true,
599
+ dereference: false,
600
+ preserveTimestamps: true,
601
+ verbatimSymlinks: true,
602
+ });
603
+ }
604
+ function changedState(before, after) {
605
+ const files = new Set([...before.keys(), ...after.keys()]);
606
+ return [...files].filter((file) => before.get(file) !== after.get(file)).sort();
607
+ }
608
+ async function runMechanicalRepair(context) {
609
+ const classification = latestClassification(context);
610
+ const checks = checksForContext(context);
611
+ const root = candidateRoot(context.input.workspace);
612
+ const attempts = previousRepairAttempts(context);
613
+ const target = classification.relatedFailures.find((item) => checks.find((check) => check.id === item.checkId)?.mechanicalFix !== undefined);
614
+ if (target === undefined)
615
+ throw new Error("No mechanical repair is available");
616
+ const fix = checks.find((check) => check.id === target.checkId)?.mechanicalFix;
617
+ if (fix === undefined)
618
+ throw new Error("Mechanical repair disappeared");
619
+ const beforeRevision = await git(root, ["rev-parse", "HEAD"]);
620
+ const before = await gitState(root);
621
+ const backup = await backupPaths(root, before.keys());
622
+ let retainBackup = false;
623
+ try {
624
+ const result = await runCommandBatch({
625
+ items: [
626
+ {
627
+ id: `fix-${target.checkId}`,
628
+ command: fix.command,
629
+ args: fix.args,
630
+ cwd: root,
631
+ timeoutMs: fix.timeoutMs,
632
+ maxOutputChars: fix.maxOutputChars,
633
+ },
634
+ ],
635
+ maxConcurrency: 1,
636
+ }, { signal: context.signal });
637
+ const after = await gitState(root);
638
+ const changedFiles = changedState(before, after);
639
+ const outside = changedFiles.filter((file) => !fix.files.includes(file));
640
+ let rejection;
641
+ if (outside.length > 0) {
642
+ const restorationFailures = [];
643
+ for (const file of outside) {
644
+ try {
645
+ await restorePath(root, file, backup.paths.get(file), beforeRevision);
646
+ }
647
+ catch (error) {
648
+ restorationFailures.push(`${file}: ${String(error)}`);
649
+ }
650
+ }
651
+ const restored = await gitState(root);
652
+ const unrestored = outside.filter((file) => before.get(file) !== restored.get(file));
653
+ if (restorationFailures.length > 0 || unrestored.length > 0) {
654
+ retainBackup = true;
655
+ rejection = `Rejected mechanical fixer: changed undeclared files ${outside.join(", ")}; restoration failed for ${[...restorationFailures, ...unrestored].join(", ")}; backup retained at ${backup.directory}`;
656
+ }
657
+ else {
658
+ rejection = `Rejected mechanical fixer: changed undeclared files ${outside.join(", ")}; restored all undeclared paths`;
659
+ }
660
+ }
661
+ return {
662
+ attempt: attempts.length + 1,
663
+ kind: "mechanical",
664
+ fingerprint: classification.failureFingerprint,
665
+ changedFiles,
666
+ result: rejection ??
667
+ (result.items[0]?.outcome === "succeeded"
668
+ ? fix.expectedDiff
669
+ : JSON.stringify(result.items[0])),
670
+ };
671
+ }
672
+ finally {
673
+ if (!retainBackup)
674
+ await fs.rm(backup.directory, { recursive: true, force: true });
675
+ }
676
+ }
677
+ function parseSemanticRepair(value, context) {
678
+ const record = requireRecord(value, "semantic repair");
679
+ const attempts = previousRepairAttempts(context);
680
+ return {
681
+ attempt: attempts.length + 1,
682
+ kind: "semantic",
683
+ fingerprint: latestClassification(context).failureFingerprint,
684
+ changedFiles: stringArray(record.changedFiles, "semantic repair changedFiles"),
685
+ result: requireString(record.result, "semantic repair result"),
686
+ };
687
+ }
688
+ function repairGuard(context) {
689
+ const classification = latestClassification(context);
690
+ const attempts = previousRepairAttempts(context);
691
+ const repeated = attempts.some((attempt) => attempt.fingerprint === classification.failureFingerprint);
692
+ if (attempts.length >= MAX_REPAIR_ATTEMPTS || repeated) {
693
+ return {
694
+ route: "blocked",
695
+ result: {
696
+ ...classification,
697
+ route: "blocked",
698
+ repairAttempts: attempts,
699
+ reason: repeated
700
+ ? "The failure fingerprint repeated after repair."
701
+ : "The repair attempt limit was reached.",
702
+ },
703
+ };
704
+ }
705
+ const checks = checksForContext(context);
706
+ const mechanical = classification.relatedFailures.length > 0 &&
707
+ classification.relatedFailures.every((failure) => checks.find((check) => check.id === failure.checkId)?.mechanicalFix !== undefined);
708
+ return {
709
+ route: mechanical ? "mechanical" : "semantic",
710
+ attempt: attempts.length + 1,
711
+ fingerprint: classification.failureFingerprint,
712
+ };
713
+ }
714
+ function parseJudgment(value, context) {
715
+ const record = requireRecord(value, "verification judgment");
716
+ if (record.route !== "ready" && record.route !== "repair" && record.route !== "blocked")
717
+ throw new Error("verification judgment route must be ready, repair, or blocked");
718
+ return {
719
+ route: record.route,
720
+ reason: requireString(record.reason, "verification judgment reason"),
721
+ evidence: stringArray(record.evidence, "verification judgment evidence"),
722
+ result: latestClassification(context),
723
+ };
724
+ }
725
+ export const changeVerificationWorkflow = defineWorkflow({
726
+ source: import.meta.url,
727
+ contractId: "pi-workflows.change-verification.v1",
728
+ name: "change-verification",
729
+ input: parseChangeVerificationInput,
730
+ startAt: "selectChecks",
731
+ maxSteps: 30,
732
+ exits: {
733
+ ready: { from: "ready", validate: (value) => value },
734
+ blocked: { from: "blocked", validate: (value) => value },
735
+ },
736
+ nodes: {
737
+ selectChecks: compute({
738
+ run: ({ input }) => ({
739
+ route: input.checks?.length ? "run" : "plan",
740
+ }),
741
+ }),
742
+ planChecks: agent({
743
+ prompt: ({ input }) => {
744
+ const request = input;
745
+ return [
746
+ "Propose the required direct verification commands because no complete program command list was supplied.",
747
+ "Do not run commands. Use no shell wrapper, stdin, environment override, Git mutation, publication, merge, release, or deployment.",
748
+ "Each check needs id, executable, argument array, exact prepared cwd, timeout, output limit, readOnly, baseEligible, changedFileScope, and findingFormat.",
749
+ `Prepared workspace: ${JSON.stringify(request.workspace)}`,
750
+ `Changed files: ${JSON.stringify(request.changedFiles ?? [])}`,
751
+ ].join("\n");
752
+ },
753
+ expectedOutput: '{ "checks": [{ "id": "stable", "command": "npm", "args": ["run", "check"], "cwd": "/absolute/workspace", "timeoutMs": 2700000, "maxOutputChars": 1000000, "readOnly": true, "baseEligible": true, "changedFileScope": false, "findingFormat": "text" }] }',
754
+ validate: parsePlannedChecks,
755
+ }),
756
+ runCandidate: action({
757
+ run: async (context) => await runCandidate(context, checksForContext(context)),
758
+ }),
759
+ runBase: action({
760
+ run: async (context) => await runBase(context, checksForContext(context)),
761
+ }),
762
+ classify: compute({ run: latestClassification }),
763
+ repairGuard: compute({ run: repairGuard }),
764
+ mechanicalRepair: action({ run: runMechanicalRepair }),
765
+ semanticRepair: agent({
766
+ prompt: (context) => {
767
+ const input = context.input;
768
+ const result = latestClassification(context);
769
+ return [
770
+ "Repair only current-change failures in the prepared workspace.",
771
+ "Do not fix unrelated baseline failures or run broad repository migrations.",
772
+ `Prepared path: ${candidateRoot(input.workspace)}`,
773
+ `Authorized scope: ${input.workspace.scope}`,
774
+ `Approved plan: ${JSON.stringify(input.plan)}`,
775
+ `Related failures: ${JSON.stringify(result.relatedFailures)}`,
776
+ `Unknown failures: ${JSON.stringify(result.unknownFailures)}`,
777
+ ].join("\n");
778
+ },
779
+ expectedOutput: '{ "changedFiles": ["file"], "result": "repair made" }',
780
+ validate: parseSemanticRepair,
781
+ }),
782
+ judge: agent({
783
+ prompt: (context) => [
784
+ "Judge only the complete verification evidence that exact comparison could not attribute.",
785
+ "Choose ready only when evidence proves no current-change failure. Choose repair for an in-scope fix. Choose blocked for a material unresolved problem.",
786
+ `Verification: ${JSON.stringify(latestClassification(context))}`,
787
+ ].join("\n"),
788
+ expectedOutput: '{ "route": "ready" | "repair" | "blocked", "reason": "reason", "evidence": ["evidence"] }',
789
+ validate: parseJudgment,
790
+ }),
791
+ ready: compute({
792
+ run: (context) => {
793
+ const result = latestClassification(context);
794
+ const judgment = context.outputs.judge;
795
+ return {
796
+ ...result,
797
+ route: "ready",
798
+ ...(judgment === undefined
799
+ ? {}
800
+ : {
801
+ reason: judgment.reason ?? result.reason,
802
+ evidence: [...result.evidence, ...(judgment.evidence ?? [])],
803
+ }),
804
+ };
805
+ },
806
+ }),
807
+ blocked: compute({
808
+ run: (context) => {
809
+ const guard = context.outputs.repairGuard;
810
+ const judgment = context.outputs.judge;
811
+ const result = guard?.result ?? latestClassification(context);
812
+ return {
813
+ ...result,
814
+ route: "blocked",
815
+ reason: judgment?.reason ?? result.reason,
816
+ evidence: [...result.evidence, ...(judgment?.evidence ?? [])],
817
+ };
818
+ },
819
+ }),
820
+ },
821
+ edges: [
822
+ {
823
+ from: "selectChecks",
824
+ switch: { on: "$.route", cases: { run: "runCandidate", plan: "planChecks" } },
825
+ },
826
+ { from: "planChecks", to: "runCandidate" },
827
+ { from: "runCandidate", to: "runBase" },
828
+ { from: "runBase", to: "classify" },
829
+ {
830
+ from: "classify",
831
+ switch: {
832
+ on: "$.route",
833
+ cases: {
834
+ ready: "ready",
835
+ repairable: "repairGuard",
836
+ needsJudgment: "judge",
837
+ blocked: "blocked",
838
+ },
839
+ },
840
+ },
841
+ {
842
+ from: "repairGuard",
843
+ switch: {
844
+ on: "$.route",
845
+ cases: { mechanical: "mechanicalRepair", semantic: "semanticRepair", blocked: "blocked" },
846
+ },
847
+ },
848
+ { from: "mechanicalRepair", to: "runCandidate" },
849
+ { from: "semanticRepair", to: "runCandidate" },
850
+ {
851
+ from: "judge",
852
+ switch: {
853
+ on: "$.route",
854
+ cases: { ready: "ready", repair: "repairGuard", blocked: "blocked" },
855
+ },
856
+ },
857
+ ],
858
+ });
859
+ export default changeVerificationWorkflow;
860
+ //# sourceMappingURL=change-verification.workflow.js.map