@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,668 @@
1
+ import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ import { promisify } from "node:util";
4
+ import { action, agent, compute, defineWorkflow } from "../workflows/definition.js";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ export const PREPARED_WORKSPACE_SCHEMA = "pi-workflows.prepared-workspace.v1";
9
+ export type WorkspaceMode = "auto" | "branch" | "worktree" | "defaultBranch";
10
+ export type PreparedWorkspaceMode = Exclude<WorkspaceMode, "auto">;
11
+
12
+ export type PreparedWorkspace = {
13
+ schema: typeof PREPARED_WORKSPACE_SCHEMA;
14
+ mode: PreparedWorkspaceMode;
15
+ repository: string;
16
+ worktreePath?: string;
17
+ baseBranch: string;
18
+ baseRevision: string;
19
+ workBranch: string;
20
+ directDefaultBranchAuthorized: boolean;
21
+ preExistingChangedPaths: string[];
22
+ evidence: string[];
23
+ scope: string;
24
+ };
25
+
26
+ export type WorkspacePreparationInput = {
27
+ repository: string;
28
+ baseBranch?: string;
29
+ workspaceMode?: WorkspaceMode;
30
+ directDefaultBranchAuthorized?: boolean;
31
+ scope?: string;
32
+ preparedWorkspace?: PreparedWorkspace;
33
+ };
34
+
35
+ type WorkspaceInspection = {
36
+ route: "ready" | "propose" | "blocked";
37
+ selectedMode?: PreparedWorkspaceMode;
38
+ preparedWorkspace?: PreparedWorkspace;
39
+ repository: string;
40
+ baseBranch: string;
41
+ baseRevision?: string;
42
+ currentBranch?: string;
43
+ preExistingChangedPaths: string[];
44
+ reason: string;
45
+ evidence: string[];
46
+ };
47
+
48
+ type BranchProposal = { branchName: string; reason: string };
49
+
50
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
51
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
52
+ throw new Error(`${label} must be an object`);
53
+ }
54
+ return value as Record<string, unknown>;
55
+ }
56
+
57
+ function requireString(value: unknown, label: string): string {
58
+ if (typeof value !== "string" || value.trim().length === 0) {
59
+ throw new Error(`${label} must be a non-empty string`);
60
+ }
61
+ return value.trim();
62
+ }
63
+
64
+ function requireAbsolutePath(value: unknown, label: string): string {
65
+ const result = requireString(value, label);
66
+ if (!path.isAbsolute(result)) throw new Error(`${label} must be absolute`);
67
+ return path.resolve(result);
68
+ }
69
+
70
+ function parseMode(value: unknown): WorkspaceMode {
71
+ if (value === undefined) return "auto";
72
+ if (value !== "auto" && value !== "branch" && value !== "worktree" && value !== "defaultBranch") {
73
+ throw new Error("workspaceMode must be auto, branch, worktree, or defaultBranch");
74
+ }
75
+ return value;
76
+ }
77
+
78
+ export function parsePreparedWorkspace(value: unknown): PreparedWorkspace {
79
+ const record = requireRecord(value, "prepared workspace");
80
+ if (record.schema !== PREPARED_WORKSPACE_SCHEMA) {
81
+ throw new Error(`prepared workspace schema must be ${PREPARED_WORKSPACE_SCHEMA}`);
82
+ }
83
+ if (record.mode !== "branch" && record.mode !== "worktree" && record.mode !== "defaultBranch") {
84
+ throw new Error("prepared workspace mode is invalid");
85
+ }
86
+ if (typeof record.directDefaultBranchAuthorized !== "boolean") {
87
+ throw new Error("prepared workspace directDefaultBranchAuthorized must be a boolean");
88
+ }
89
+ if (
90
+ !Array.isArray(record.preExistingChangedPaths) ||
91
+ record.preExistingChangedPaths.some((item) => typeof item !== "string")
92
+ ) {
93
+ throw new Error("prepared workspace preExistingChangedPaths must be strings");
94
+ }
95
+ if (!Array.isArray(record.evidence) || record.evidence.some((item) => typeof item !== "string")) {
96
+ throw new Error("prepared workspace evidence must be strings");
97
+ }
98
+ const repository = requireAbsolutePath(record.repository, "prepared workspace repository");
99
+ const worktreePath =
100
+ record.worktreePath === undefined
101
+ ? undefined
102
+ : requireAbsolutePath(record.worktreePath, "prepared workspace worktreePath");
103
+ if (record.mode === "worktree" && worktreePath === undefined) {
104
+ throw new Error("worktree mode requires worktreePath");
105
+ }
106
+ if (record.mode !== "worktree" && worktreePath !== undefined) {
107
+ throw new Error("worktreePath is allowed only in worktree mode");
108
+ }
109
+ return {
110
+ schema: PREPARED_WORKSPACE_SCHEMA,
111
+ mode: record.mode,
112
+ repository,
113
+ ...(worktreePath === undefined ? {} : { worktreePath }),
114
+ baseBranch: requireString(record.baseBranch, "prepared workspace baseBranch"),
115
+ baseRevision: requireString(record.baseRevision, "prepared workspace baseRevision"),
116
+ workBranch: requireString(record.workBranch, "prepared workspace workBranch"),
117
+ directDefaultBranchAuthorized: record.directDefaultBranchAuthorized,
118
+ preExistingChangedPaths: [...record.preExistingChangedPaths] as string[],
119
+ evidence: [...record.evidence] as string[],
120
+ scope: requireString(record.scope, "prepared workspace scope"),
121
+ };
122
+ }
123
+
124
+ export function parseWorkspacePreparationInput(value: unknown): WorkspacePreparationInput {
125
+ const record = requireRecord(value, "workspace preparation input");
126
+ return {
127
+ repository: requireAbsolutePath(record.repository, "workspace repository"),
128
+ ...(record.baseBranch === undefined
129
+ ? {}
130
+ : { baseBranch: requireString(record.baseBranch, "workspace baseBranch") }),
131
+ workspaceMode: parseMode(record.workspaceMode),
132
+ ...(record.directDefaultBranchAuthorized === undefined
133
+ ? {}
134
+ : { directDefaultBranchAuthorized: record.directDefaultBranchAuthorized === true }),
135
+ ...(record.scope === undefined
136
+ ? {}
137
+ : { scope: requireString(record.scope, "workspace scope") }),
138
+ ...(record.preparedWorkspace === undefined
139
+ ? {}
140
+ : { preparedWorkspace: parsePreparedWorkspace(record.preparedWorkspace) }),
141
+ };
142
+ }
143
+
144
+ async function git(cwd: string, args: string[]): Promise<string> {
145
+ const result = await execFileAsync("git", args, {
146
+ cwd,
147
+ encoding: "utf8",
148
+ maxBuffer: 2_000_000,
149
+ timeout: 30_000,
150
+ });
151
+ return result.stdout.trim();
152
+ }
153
+
154
+ async function changedPaths(repository: string): Promise<string[]> {
155
+ const status = await execFileAsync(
156
+ "git",
157
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
158
+ { cwd: repository, encoding: "utf8", maxBuffer: 2_000_000, timeout: 30_000 },
159
+ );
160
+ const entries = status.stdout.split("\0").filter(Boolean);
161
+ const files: string[] = [];
162
+ for (let index = 0; index < entries.length; index += 1) {
163
+ const entry = entries[index]!;
164
+ const statusCode = entry.slice(0, 2);
165
+ files.push(entry.slice(3));
166
+ if (
167
+ (statusCode.includes("R") || statusCode.includes("C")) &&
168
+ entries[index + 1] !== undefined
169
+ ) {
170
+ files.push(entries[index + 1]!);
171
+ index += 1;
172
+ }
173
+ }
174
+ return [...new Set(files)].sort();
175
+ }
176
+
177
+ function workspaceScope(input: WorkspacePreparationInput): string {
178
+ return input.scope ?? `Only ${input.repository}`;
179
+ }
180
+
181
+ function prepared(
182
+ input: WorkspacePreparationInput,
183
+ mode: PreparedWorkspaceMode,
184
+ repository: string,
185
+ baseBranch: string,
186
+ baseRevision: string,
187
+ workBranch: string,
188
+ preExistingChangedPaths: string[],
189
+ evidence: string[],
190
+ worktreePath?: string,
191
+ ): PreparedWorkspace {
192
+ return {
193
+ schema: PREPARED_WORKSPACE_SCHEMA,
194
+ mode,
195
+ repository,
196
+ ...(worktreePath === undefined ? {} : { worktreePath }),
197
+ baseBranch,
198
+ baseRevision,
199
+ workBranch,
200
+ directDefaultBranchAuthorized:
201
+ mode === "defaultBranch" && input.directDefaultBranchAuthorized === true,
202
+ preExistingChangedPaths,
203
+ evidence,
204
+ scope: workspaceScope(input),
205
+ };
206
+ }
207
+
208
+ export async function inspectWorkspace(
209
+ input: WorkspacePreparationInput,
210
+ ): Promise<WorkspaceInspection> {
211
+ if (input.preparedWorkspace !== undefined) {
212
+ const value = parsePreparedWorkspace(input.preparedWorkspace);
213
+ const candidatePath = value.worktreePath ?? value.repository;
214
+ const blocked = (
215
+ reason: string,
216
+ evidence: string[],
217
+ currentBranch?: string,
218
+ ): WorkspaceInspection => ({
219
+ route: "blocked",
220
+ repository: input.repository,
221
+ baseBranch: input.baseBranch ?? value.baseBranch,
222
+ baseRevision: value.baseRevision,
223
+ ...(currentBranch === undefined ? {} : { currentBranch }),
224
+ preExistingChangedPaths: [],
225
+ reason,
226
+ evidence,
227
+ });
228
+ if (value.repository !== input.repository) {
229
+ return blocked(
230
+ "The supplied prepared workspace does not own the requested repository path.",
231
+ [value.repository, value.worktreePath ?? "no worktree path"],
232
+ );
233
+ }
234
+ if (input.baseBranch !== undefined && input.baseBranch !== value.baseBranch) {
235
+ return blocked("The supplied prepared workspace uses a different base branch.", [
236
+ `requested=${input.baseBranch}`,
237
+ `prepared=${value.baseBranch}`,
238
+ ]);
239
+ }
240
+ try {
241
+ const topLevel = path.resolve(await git(candidatePath, ["rev-parse", "--show-toplevel"]));
242
+ const repositoryTopLevel = path.resolve(
243
+ await git(value.repository, ["rev-parse", "--show-toplevel"]),
244
+ );
245
+ const candidateCommonDir = path.resolve(
246
+ candidatePath,
247
+ await git(candidatePath, ["rev-parse", "--git-common-dir"]),
248
+ );
249
+ const repositoryCommonDir = path.resolve(
250
+ value.repository,
251
+ await git(value.repository, ["rev-parse", "--git-common-dir"]),
252
+ );
253
+ const currentBranch = await git(candidatePath, [
254
+ "symbolic-ref",
255
+ "--quiet",
256
+ "--short",
257
+ "HEAD",
258
+ ]);
259
+ const headRevision = await git(candidatePath, ["rev-parse", "HEAD"]);
260
+ await git(candidatePath, ["cat-file", "-e", `${value.baseRevision}^{commit}`]);
261
+ await git(candidatePath, ["show-ref", "--verify", `refs/heads/${value.workBranch}`]);
262
+ const branchRevision = await git(candidatePath, [
263
+ "rev-parse",
264
+ `refs/heads/${value.workBranch}`,
265
+ ]);
266
+ await git(candidatePath, ["merge-base", "--is-ancestor", value.baseRevision, headRevision]);
267
+ if (topLevel !== path.resolve(candidatePath)) {
268
+ return blocked("The prepared workspace path no longer names its Git worktree.", [
269
+ `prepared=${candidatePath}`,
270
+ `actual=${topLevel}`,
271
+ ]);
272
+ }
273
+ if (repositoryTopLevel !== path.resolve(value.repository)) {
274
+ return blocked("The prepared workspace owner no longer names its Git worktree.", [
275
+ `prepared=${value.repository}`,
276
+ `actual=${repositoryTopLevel}`,
277
+ ]);
278
+ }
279
+ if (candidateCommonDir !== repositoryCommonDir) {
280
+ return blocked("The prepared worktree does not belong to the requested repository.", [
281
+ `repositoryGitDir=${repositoryCommonDir}`,
282
+ `worktreeGitDir=${candidateCommonDir}`,
283
+ ]);
284
+ }
285
+ if (currentBranch !== value.workBranch || branchRevision !== headRevision) {
286
+ return blocked(
287
+ "The prepared workspace is no longer on its recorded task branch.",
288
+ [
289
+ `preparedBranch=${value.workBranch}`,
290
+ `currentBranch=${currentBranch}`,
291
+ `branchRevision=${branchRevision}`,
292
+ `headRevision=${headRevision}`,
293
+ ],
294
+ currentBranch,
295
+ );
296
+ }
297
+ if (
298
+ value.mode === "defaultBranch" &&
299
+ (value.workBranch !== value.baseBranch ||
300
+ !value.directDefaultBranchAuthorized ||
301
+ input.directDefaultBranchAuthorized !== true)
302
+ ) {
303
+ return blocked(
304
+ "The prepared default-branch receipt no longer proves direct-work authority.",
305
+ [
306
+ `baseBranch=${value.baseBranch}`,
307
+ `workBranch=${value.workBranch}`,
308
+ `receiptAuthorized=${value.directDefaultBranchAuthorized}`,
309
+ `requestAuthorized=${input.directDefaultBranchAuthorized === true}`,
310
+ ],
311
+ currentBranch,
312
+ );
313
+ }
314
+ if (value.mode === "worktree") {
315
+ const listing = await git(value.repository, ["worktree", "list", "--porcelain"]);
316
+ const registered = listing.split("\n\n").some((entry) => {
317
+ const lines = entry.split("\n");
318
+ return (
319
+ lines.includes(`worktree ${path.resolve(candidatePath)}`) &&
320
+ lines.includes(`branch refs/heads/${value.workBranch}`)
321
+ );
322
+ });
323
+ if (!registered) {
324
+ return blocked(
325
+ "The prepared worktree is no longer registered for its recorded task branch.",
326
+ [`worktree=${candidatePath}`, `branch=${value.workBranch}`],
327
+ currentBranch,
328
+ );
329
+ }
330
+ }
331
+ return {
332
+ route: "ready",
333
+ selectedMode: value.mode,
334
+ preparedWorkspace: value,
335
+ repository: value.repository,
336
+ baseBranch: value.baseBranch,
337
+ baseRevision: value.baseRevision,
338
+ currentBranch,
339
+ preExistingChangedPaths: value.preExistingChangedPaths,
340
+ reason: "The prepared workspace was revalidated and adopted.",
341
+ evidence: [
342
+ ...value.evidence,
343
+ `worktree=${topLevel}`,
344
+ `branch=${currentBranch}`,
345
+ `head=${headRevision}`,
346
+ `baseRevision=${value.baseRevision}`,
347
+ ],
348
+ };
349
+ } catch (error) {
350
+ return blocked("The supplied prepared workspace is stale or unavailable.", [String(error)]);
351
+ }
352
+ }
353
+
354
+ try {
355
+ const repository = input.repository;
356
+ const currentBranch = await git(repository, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
357
+ const localBranches = (
358
+ await git(repository, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
359
+ )
360
+ .split("\n")
361
+ .filter(Boolean);
362
+ let discoveredDefault = localBranches.includes("main")
363
+ ? "main"
364
+ : localBranches.includes("master")
365
+ ? "master"
366
+ : currentBranch;
367
+ try {
368
+ discoveredDefault = (
369
+ await git(repository, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])
370
+ ).replace(/^origin\//, "");
371
+ } catch {
372
+ // A local-only repository has no remote default-branch reference.
373
+ }
374
+ const baseBranch = input.baseBranch ?? discoveredDefault;
375
+ const baseRevision = await git(repository, ["rev-parse", baseBranch]);
376
+ const changes = await changedPaths(repository);
377
+ const requested = input.workspaceMode ?? "auto";
378
+ if (requested === "defaultBranch") {
379
+ if (
380
+ baseBranch !== discoveredDefault ||
381
+ currentBranch !== baseBranch ||
382
+ input.directDefaultBranchAuthorized !== true ||
383
+ changes.length > 0
384
+ ) {
385
+ return {
386
+ route: "blocked",
387
+ repository,
388
+ baseBranch,
389
+ baseRevision,
390
+ currentBranch,
391
+ preExistingChangedPaths: changes,
392
+ reason:
393
+ "Direct default-branch work requires explicit authority, the actual base branch, and no pre-existing changes.",
394
+ evidence: [
395
+ `actualDefault=${discoveredDefault}`,
396
+ `branch=${currentBranch}`,
397
+ `authorized=${input.directDefaultBranchAuthorized === true}`,
398
+ `changes=${changes.join(",") || "none"}`,
399
+ ],
400
+ };
401
+ }
402
+ const value = prepared(
403
+ input,
404
+ "defaultBranch",
405
+ repository,
406
+ baseBranch,
407
+ baseRevision,
408
+ currentBranch,
409
+ changes,
410
+ ["Actual default branch confirmed", "Direct work authority confirmed"],
411
+ );
412
+ return {
413
+ route: "ready",
414
+ selectedMode: "defaultBranch",
415
+ preparedWorkspace: value,
416
+ repository,
417
+ baseBranch,
418
+ baseRevision,
419
+ currentBranch,
420
+ preExistingChangedPaths: changes,
421
+ reason: "Direct default-branch workspace confirmed.",
422
+ evidence: value.evidence,
423
+ };
424
+ }
425
+ if ((requested === "auto" || requested === "branch") && currentBranch !== baseBranch) {
426
+ try {
427
+ await git(repository, ["merge-base", "--is-ancestor", baseRevision, "HEAD"]);
428
+ } catch {
429
+ return {
430
+ route: "blocked",
431
+ repository,
432
+ baseBranch,
433
+ baseRevision,
434
+ currentBranch,
435
+ preExistingChangedPaths: changes,
436
+ reason: "The current task branch is not based on the selected base revision.",
437
+ evidence: [`baseRevision=${baseRevision}`, `branch=${currentBranch}`],
438
+ };
439
+ }
440
+ const value = prepared(
441
+ input,
442
+ "branch",
443
+ repository,
444
+ baseBranch,
445
+ baseRevision,
446
+ currentBranch,
447
+ changes,
448
+ ["Existing non-default task branch adopted"],
449
+ );
450
+ return {
451
+ route: "ready",
452
+ selectedMode: "branch",
453
+ preparedWorkspace: value,
454
+ repository,
455
+ baseBranch,
456
+ baseRevision,
457
+ currentBranch,
458
+ preExistingChangedPaths: changes,
459
+ reason: "Existing task branch adopted.",
460
+ evidence: value.evidence,
461
+ };
462
+ }
463
+ const selectedMode: PreparedWorkspaceMode =
464
+ requested === "worktree" || (requested === "auto" && changes.length > 0)
465
+ ? "worktree"
466
+ : "branch";
467
+ if (selectedMode === "branch" && changes.length > 0) {
468
+ return {
469
+ route: "blocked",
470
+ repository,
471
+ baseBranch,
472
+ baseRevision,
473
+ currentBranch,
474
+ preExistingChangedPaths: changes,
475
+ reason: "A task branch cannot be created in a dirty default-branch checkout.",
476
+ evidence: changes,
477
+ };
478
+ }
479
+ return {
480
+ route: "propose",
481
+ selectedMode,
482
+ repository,
483
+ baseBranch,
484
+ baseRevision,
485
+ currentBranch,
486
+ preExistingChangedPaths: changes,
487
+ reason: `A ${selectedMode} name is required.`,
488
+ evidence: [`branch=${currentBranch}`, `changes=${changes.join(",") || "none"}`],
489
+ };
490
+ } catch (error) {
491
+ return {
492
+ route: "blocked",
493
+ repository: input.repository,
494
+ baseBranch: input.baseBranch ?? "unknown",
495
+ preExistingChangedPaths: [],
496
+ reason: "Workspace inspection failed.",
497
+ evidence: [error instanceof Error ? error.message : String(error)],
498
+ };
499
+ }
500
+ }
501
+
502
+ export async function validateBranchName(
503
+ repository: string,
504
+ branchName: string,
505
+ baseBranch: string,
506
+ ): Promise<string> {
507
+ const branch = requireString(branchName, "branch proposal");
508
+ if (branch === baseBranch || branch === "HEAD" || branch.startsWith("refs/")) {
509
+ throw new Error("branch proposal is reserved or conflicts with the base branch");
510
+ }
511
+ await git(repository, ["check-ref-format", "--branch", branch]);
512
+ for (const ref of [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`]) {
513
+ try {
514
+ await git(repository, ["show-ref", "--verify", ref]);
515
+ throw new Error(`branch already exists: ${branch}`);
516
+ } catch (error) {
517
+ if (error instanceof Error && error.message.startsWith("branch already exists:")) throw error;
518
+ }
519
+ }
520
+ return branch;
521
+ }
522
+
523
+ export async function prepareWorkspace(
524
+ input: WorkspacePreparationInput,
525
+ inspection: WorkspaceInspection,
526
+ proposal: BranchProposal,
527
+ ): Promise<WorkspaceInspection> {
528
+ if (
529
+ inspection.route !== "propose" ||
530
+ inspection.selectedMode === undefined ||
531
+ inspection.baseRevision === undefined
532
+ ) {
533
+ return inspection;
534
+ }
535
+ try {
536
+ const branchName = await validateBranchName(
537
+ inspection.repository,
538
+ proposal.branchName,
539
+ inspection.baseBranch,
540
+ );
541
+ if (inspection.selectedMode === "branch") {
542
+ await git(inspection.repository, ["switch", "-c", branchName, inspection.baseRevision]);
543
+ const value = prepared(
544
+ input,
545
+ "branch",
546
+ inspection.repository,
547
+ inspection.baseBranch,
548
+ inspection.baseRevision,
549
+ branchName,
550
+ inspection.preExistingChangedPaths,
551
+ [...inspection.evidence, `Created branch ${branchName}`],
552
+ );
553
+ return {
554
+ ...inspection,
555
+ route: "ready",
556
+ preparedWorkspace: value,
557
+ currentBranch: branchName,
558
+ reason: proposal.reason,
559
+ evidence: value.evidence,
560
+ };
561
+ }
562
+ const root = path.join(
563
+ path.dirname(inspection.repository),
564
+ `${path.basename(inspection.repository)}-worktrees`,
565
+ );
566
+ const slug = branchName.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
567
+ const worktreePath = path.join(root, slug);
568
+ if (path.dirname(worktreePath) !== root)
569
+ throw new Error("worktree path is outside the standard sibling directory");
570
+ await git(inspection.repository, [
571
+ "worktree",
572
+ "add",
573
+ "-b",
574
+ branchName,
575
+ worktreePath,
576
+ inspection.baseRevision,
577
+ ]);
578
+ const value = prepared(
579
+ input,
580
+ "worktree",
581
+ inspection.repository,
582
+ inspection.baseBranch,
583
+ inspection.baseRevision,
584
+ branchName,
585
+ inspection.preExistingChangedPaths,
586
+ [...inspection.evidence, `Created worktree ${worktreePath}`],
587
+ worktreePath,
588
+ );
589
+ return {
590
+ ...inspection,
591
+ route: "ready",
592
+ preparedWorkspace: value,
593
+ currentBranch: branchName,
594
+ reason: proposal.reason,
595
+ evidence: value.evidence,
596
+ };
597
+ } catch (error) {
598
+ return {
599
+ ...inspection,
600
+ route: "blocked",
601
+ reason: "Workspace preparation failed.",
602
+ evidence: [...inspection.evidence, error instanceof Error ? error.message : String(error)],
603
+ };
604
+ }
605
+ }
606
+
607
+ function parseProposal(value: unknown): BranchProposal {
608
+ const record = requireRecord(value, "branch proposal");
609
+ return {
610
+ branchName: requireString(record.branchName, "branch proposal branchName"),
611
+ reason: requireString(record.reason, "branch proposal reason"),
612
+ };
613
+ }
614
+
615
+ export const workspacePreparationWorkflow = defineWorkflow({
616
+ source: import.meta.url,
617
+ contractId: "pi-workflows.workspace-preparation.v1",
618
+ name: "workspace-preparation",
619
+ input: parseWorkspacePreparationInput,
620
+ startAt: "inspect",
621
+ maxSteps: 5,
622
+ exits: {
623
+ ready: { from: "ready", validate: parsePreparedWorkspace },
624
+ blocked: { from: "blocked", validate: (value: unknown) => value as WorkspaceInspection },
625
+ },
626
+ nodes: {
627
+ inspect: action({
628
+ run: async ({ input }) => await inspectWorkspace(input as WorkspacePreparationInput),
629
+ }),
630
+ propose: agent({
631
+ prompt: ({ outputs }) =>
632
+ [
633
+ "Propose one clear Git task branch name for this work.",
634
+ "Return only the name and a short reason. Do not run Git or create anything.",
635
+ `Workspace facts: ${JSON.stringify(outputs.inspect)}`,
636
+ ].join("\n"),
637
+ expectedOutput: '{ "branchName": "type/short-task-name", "reason": "why this name fits" }',
638
+ validate: parseProposal,
639
+ }),
640
+ apply: action({
641
+ run: async ({ input, outputs }) =>
642
+ await prepareWorkspace(
643
+ input as WorkspacePreparationInput,
644
+ outputs.inspect as WorkspaceInspection,
645
+ outputs.propose as BranchProposal,
646
+ ),
647
+ }),
648
+ ready: compute({
649
+ run: ({ outputs }) => {
650
+ const result = (outputs.apply ?? outputs.inspect) as WorkspaceInspection;
651
+ if (result.preparedWorkspace === undefined)
652
+ throw new Error("workspace preparation has no ready result");
653
+ return result.preparedWorkspace;
654
+ },
655
+ }),
656
+ blocked: compute({ run: ({ outputs }) => outputs.apply ?? outputs.inspect }),
657
+ },
658
+ edges: [
659
+ {
660
+ from: "inspect",
661
+ switch: { on: "$.route", cases: { ready: "ready", propose: "propose", blocked: "blocked" } },
662
+ },
663
+ { from: "propose", to: "apply" },
664
+ { from: "apply", switch: { on: "$.route", cases: { ready: "ready", blocked: "blocked" } } },
665
+ ],
666
+ });
667
+
668
+ export default workspacePreparationWorkflow;