@wichayutdew/pi-workflows 1.0.1 → 2.0.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 (131) hide show
  1. package/README.md +312 -223
  2. package/agents/step.md +4 -11
  3. package/dist/index.js +2554 -976
  4. package/examples/mr-comments.workflow.yaml +16 -8
  5. package/examples/prompts/mr-comments/implement.md +13 -9
  6. package/examples/prompts/mr-comments/plan.md +44 -15
  7. package/examples/prompts/mr-comments/verify.md +8 -7
  8. package/examples/settings.yaml +7 -1
  9. package/examples/starter-kit/mr-comment.workflow.yaml +149 -0
  10. package/examples/starter-kit/mr-review.workflow.yaml +130 -0
  11. package/examples/starter-kit/steps/mr-comment/fetch.md +33 -0
  12. package/examples/starter-kit/steps/mr-comment/implement.md +38 -0
  13. package/examples/starter-kit/steps/mr-comment/plan.md +62 -0
  14. package/examples/starter-kit/steps/mr-comment/publish.md +36 -0
  15. package/examples/starter-kit/steps/mr-comment/verify.md +35 -0
  16. package/examples/starter-kit/steps/mr-review/fetch.md +25 -0
  17. package/examples/starter-kit/steps/mr-review/publish.md +30 -0
  18. package/examples/starter-kit/steps/mr-review/review.md +69 -0
  19. package/examples/starter-kit/steps/mr-review/verify.md +24 -0
  20. package/examples/starter-kit/steps/shared/prepare-workspace.md +57 -0
  21. package/examples/starter-kit/steps/ticket/implement.md +35 -0
  22. package/examples/starter-kit/steps/ticket/plan.md +40 -0
  23. package/examples/starter-kit/steps/ticket/verify.md +29 -0
  24. package/examples/starter-kit/steps/work/implement.md +34 -0
  25. package/examples/starter-kit/steps/work/plan.md +38 -0
  26. package/examples/starter-kit/steps/work/verify.md +30 -0
  27. package/examples/starter-kit/ticket.workflow.yaml +100 -0
  28. package/examples/starter-kit/work.workflow.yaml +97 -0
  29. package/package.json +3 -3
  30. package/schemas/workflow.schema.json +78 -44
  31. package/src/command-names.ts +1 -0
  32. package/src/commands.ts +33 -4
  33. package/src/config/ceiling.ts +6 -12
  34. package/src/config/load-workflows.ts +12 -1
  35. package/src/config/step-digests.ts +19 -0
  36. package/src/config/types.ts +17 -22
  37. package/src/config/validation/permissions.ts +5 -66
  38. package/src/config/validation/prompt.ts +1 -0
  39. package/src/config/validation/shared.ts +0 -2
  40. package/src/config/validation/step.ts +106 -2
  41. package/src/config/validation/workflow.ts +76 -0
  42. package/src/engine/create-run.ts +3 -0
  43. package/src/engine/gate-transitions.ts +58 -9
  44. package/src/engine/reconciliation-history.ts +61 -24
  45. package/src/engine/run-advance.ts +37 -4
  46. package/src/engine/run-lifecycle.ts +11 -0
  47. package/src/engine/run-reconciliation.ts +51 -21
  48. package/src/engine/run-validation.ts +235 -0
  49. package/src/engine/run-workflow-validation.ts +191 -0
  50. package/src/engine/state-types.ts +108 -5
  51. package/src/engine/state.ts +15 -1
  52. package/src/engine/step-trace.ts +495 -0
  53. package/src/engine/transitions.ts +1 -0
  54. package/src/harness/action-context.ts +31 -6
  55. package/src/harness/core-actions.ts +30 -1
  56. package/src/harness/delegation-control-actions.ts +27 -2
  57. package/src/harness/delegation-failure.ts +42 -9
  58. package/src/harness/delegation-plan.ts +96 -40
  59. package/src/harness/delegation-recovery-validation.ts +3 -1
  60. package/src/harness/delegation-response-actions.ts +73 -3
  61. package/src/harness/delegation-retry-policy.ts +5 -3
  62. package/src/harness/dependencies.ts +11 -2
  63. package/src/harness/gate-submission-action.ts +5 -31
  64. package/src/harness/lifecycle-actions.ts +4 -5
  65. package/src/harness/pause-actions.ts +10 -1
  66. package/src/harness/plannotator-result-actions.ts +20 -11
  67. package/src/harness/prompt-gate-actions.ts +40 -2
  68. package/src/harness/resume-action.ts +75 -1
  69. package/src/harness/start-actions.ts +77 -3
  70. package/src/harness/status-actions.ts +7 -17
  71. package/src/harness/step-effects.ts +61 -0
  72. package/src/harness/step-execution-actions.ts +181 -25
  73. package/src/harness/step-reporting.ts +168 -0
  74. package/src/harness/types.ts +1 -0
  75. package/src/harness/workspace-directory.ts +67 -0
  76. package/src/harness.ts +37 -9
  77. package/src/integrations/plannotator-requests.ts +40 -5
  78. package/src/integrations/subagents/child-policy-sections.ts +71 -84
  79. package/src/integrations/subagents/child-policy-types.ts +7 -7
  80. package/src/integrations/subagents/child-policy-validation.ts +10 -4
  81. package/src/integrations/subagents/child-runtime-completion.ts +1 -0
  82. package/src/integrations/subagents/child-runtime-files.ts +29 -86
  83. package/src/integrations/subagents/child-runtime-policy.ts +8 -33
  84. package/src/integrations/subagents/child-runtime.ts +3 -15
  85. package/src/integrations/subagents/delegated-result.ts +1 -0
  86. package/src/integrations/subagents/diagnostic-format.ts +5 -0
  87. package/src/integrations/subagents/diagnostic-types.ts +7 -1
  88. package/src/integrations/subagents/diagnostics.ts +2 -0
  89. package/src/integrations/subagents/failure-correlation.ts +52 -2
  90. package/src/integrations/subagents/failure-transcript.ts +60 -1
  91. package/src/integrations/subagents/replay-audit.ts +0 -1
  92. package/src/integrations/subagents/replay-safety.ts +12 -27
  93. package/src/integrations/subagents/session-diagnostics.ts +102 -3
  94. package/src/policy/bash-authorization.ts +2 -21
  95. package/src/policy/bash-types.ts +0 -5
  96. package/src/policy/bash.ts +1 -2
  97. package/src/policy/tool-call-authorization.ts +1 -7
  98. package/src/preflight.ts +0 -5
  99. package/src/prompt/retry-task.ts +11 -3
  100. package/src/prompt/step-contract.ts +10 -56
  101. package/src/prompt/step-sections.ts +8 -10
  102. package/src/prompt/step-task.ts +41 -10
  103. package/src/prompt/template.ts +3 -2
  104. package/src/runtime/completion-tool.ts +14 -0
  105. package/src/runtime/main-step-completion.ts +3 -0
  106. package/src/runtime/main-step-policy.ts +2 -1
  107. package/src/runtime/main-step-runtime-types.ts +9 -1
  108. package/src/runtime/main-step-runtime.ts +2 -0
  109. package/src/runtime/main-step-state.ts +6 -0
  110. package/src/runtime/main-step-trace.ts +45 -0
  111. package/src/runtime/step-result.ts +57 -0
  112. package/src/step-log.ts +178 -0
  113. package/src/workflow-doctor.ts +254 -0
  114. package/src/workflow-status/format-status.ts +32 -3
  115. package/src/workflow-status/render-board.ts +7 -4
  116. package/src/workflow-status/render-path.ts +51 -24
  117. package/src/workflow-status/render-step-detail.ts +379 -0
  118. package/src/workflow-status/render-summary.ts +22 -0
  119. package/src/workflow-status/transcript-reader.ts +231 -0
  120. package/src/workflow-status/types.ts +8 -0
  121. package/src/workflow-status/view.ts +161 -14
  122. package/src/workflow-status.ts +6 -0
  123. package/src/policy/approved-command-extraction.ts +0 -71
  124. package/src/policy/approved-commands.ts +0 -15
  125. package/src/policy/bash-read-only.ts +0 -161
  126. package/src/policy/restricted-git.ts +0 -30
  127. package/src/policy/reviewed-artifact.ts +0 -97
  128. package/src/policy/reviewed-command-safety.ts +0 -150
  129. package/src/policy/reviewed-command-shape.ts +0 -49
  130. package/src/policy/reviewed-repository-contract.ts +0 -138
  131. package/src/policy/reviewed-repository-cwd.ts +0 -102
package/src/harness.ts CHANGED
@@ -53,6 +53,7 @@ import { createGateSubmissionAction } from './harness/gate-submission-action.ts'
53
53
  import { createPromptGateActions } from './harness/prompt-gate-actions.ts';
54
54
  import { createPlannotatorResultActions } from './harness/plannotator-result-actions.ts';
55
55
  import { createCoreActions } from './harness/core-actions.ts';
56
+ import type { SettledStepReport } from './harness/step-reporting.ts';
56
57
  import {
57
58
  formatShortcutLabel,
58
59
  type WorkflowStatusSnapshot,
@@ -106,14 +107,16 @@ export class WorkflowHarness implements WorkflowCommandController {
106
107
  STATUS_ACTIONS.stopStatusRefresh;
107
108
  private readonly registerWorkflowStatusShortcut: () => void =
108
109
  STATUS_ACTIONS.registerWorkflowStatusShortcut;
109
- private readonly openWorkflowStatus: (context: ExtensionContext) => void =
110
- STATUS_ACTIONS.openWorkflowStatus;
111
110
  private readonly showWorkflowStatus: (
112
111
  context: ExtensionContext,
113
112
  ) => Promise<void> = STATUS_ACTIONS.showWorkflowStatus;
114
113
  private readonly listWorkflows: (
115
114
  context: ExtensionCommandContext,
116
115
  ) => Promise<void> = START_ACTIONS.listWorkflows;
116
+ private readonly doctorWorkflows: (
117
+ workflowId: string,
118
+ context: ExtensionCommandContext,
119
+ ) => Promise<void> = START_ACTIONS.doctorWorkflows;
117
120
  private readonly startNow: (
118
121
  workflowId: string,
119
122
  input: string,
@@ -133,6 +136,7 @@ export class WorkflowHarness implements WorkflowCommandController {
133
136
  ) => Promise<void> = PAUSE_ACTIONS.abortNow;
134
137
  private readonly resumeNow: (
135
138
  context: ExtensionCommandContext,
139
+ input?: string,
136
140
  ) => Promise<void> = RESUME_ACTION.resumeNow;
137
141
  private readonly registerMultilineCommandInput: () => void =
138
142
  LIFECYCLE_ACTIONS.registerMultilineCommandInput;
@@ -149,6 +153,16 @@ export class WorkflowHarness implements WorkflowCommandController {
149
153
  run: WorkflowRun,
150
154
  step: WorkflowStep,
151
155
  ) => void = STEP_EXECUTION_ACTIONS.launchMainStep;
156
+ private readonly queueMainStepLog: (
157
+ identity: MainStepIdentity,
158
+ lines: ReadonlyArray<string>,
159
+ context: ExtensionContext,
160
+ ) => Promise<void> = STEP_EXECUTION_ACTIONS.queueMainStepLog;
161
+ private readonly recordMainStepLog: (
162
+ identity: MainStepIdentity,
163
+ lines: ReadonlyArray<string>,
164
+ context: ExtensionContext,
165
+ ) => Promise<void> = STEP_EXECUTION_ACTIONS.recordMainStepLog;
152
166
  private readonly queueMainStepResult: (
153
167
  identity: MainStepIdentity,
154
168
  result: WorkflowStepResult | undefined,
@@ -186,11 +200,14 @@ export class WorkflowHarness implements WorkflowCommandController {
186
200
  failure: DelegationFailureDetails | undefined,
187
201
  reason: string,
188
202
  ) => boolean = DELEGATION_CONTROL_ACTIONS.retryDelegationAfterFailure;
189
- private readonly pauseForDelegationFailure: (reason: string) => void =
190
- DELEGATION_CONTROL_ACTIONS.pauseForDelegationFailure;
203
+ private readonly pauseForDelegationFailure: (
204
+ reason: string,
205
+ failureSummary?: string,
206
+ ) => void = DELEGATION_CONTROL_ACTIONS.pauseForDelegationFailure;
191
207
  private readonly pauseForExecutionFailure: (
192
208
  label: string,
193
209
  reason: string,
210
+ failureSummary?: string,
194
211
  ) => void = DELEGATION_CONTROL_ACTIONS.pauseForExecutionFailure;
195
212
  private readonly retainUnconfirmedDelegation: (
196
213
  active: ActiveDelegation,
@@ -203,6 +220,7 @@ export class WorkflowHarness implements WorkflowCommandController {
203
220
  workflow: LoadedWorkflow,
204
221
  originalRun: WorkflowRun,
205
222
  outcome: string,
223
+ summary: string,
206
224
  artifact: string,
207
225
  ) => Promise<void> = GATE_SUBMISSION_ACTION.submitGate;
208
226
  private readonly launchPromptReview: (
@@ -233,8 +251,10 @@ export class WorkflowHarness implements WorkflowCommandController {
233
251
  PLANNOTATOR_RESULT_ACTIONS.registerPlannotatorResults;
234
252
  private readonly handlePlannotatorResult: (data: unknown) => Promise<void> =
235
253
  PLANNOTATOR_RESULT_ACTIONS.handlePlannotatorResult;
236
- private readonly settleAfterTransition: (workflow: LoadedWorkflow) => void =
237
- CORE_ACTIONS.settleAfterTransition;
254
+ private readonly settleAfterTransition: (
255
+ workflow: LoadedWorkflow,
256
+ report: SettledStepReport,
257
+ ) => void = CORE_ACTIONS.settleAfterTransition;
238
258
  private readonly preflight: (
239
259
  workflow: LoadedWorkflow,
240
260
  stepId: string,
@@ -297,6 +317,14 @@ export class WorkflowHarness implements WorkflowCommandController {
297
317
  await this.listWorkflows(context);
298
318
  }
299
319
 
320
+ /** Diagnoses declarative completion paths and loop risks. */
321
+ async doctor(
322
+ workflowId: string,
323
+ context: ExtensionCommandContext,
324
+ ): Promise<void> {
325
+ await this.doctorWorkflows(workflowId, context);
326
+ }
327
+
300
328
  /** Starts a loaded workflow with the supplied user input. */
301
329
  start(
302
330
  workflowId: string,
@@ -322,9 +350,9 @@ export class WorkflowHarness implements WorkflowCommandController {
322
350
  return this.enqueueMutation(context, () => this.pauseNow(reason, context));
323
351
  }
324
352
 
325
- /** Resumes the active paused workflow after reconciling configuration. */
326
- resume(context: ExtensionCommandContext): Promise<void> {
327
- return this.enqueueMutation(context, () => this.resumeNow(context));
353
+ /** Resumes the active paused workflow with optional user guidance. */
354
+ resume(input: string, context: ExtensionCommandContext): Promise<void> {
355
+ return this.enqueueMutation(context, () => this.resumeNow(context, input));
328
356
  }
329
357
 
330
358
  /** Aborts the active workflow and cancels any delegated execution. */
@@ -35,6 +35,7 @@ type RequestResponseOptions<TResponse> = {
35
35
  readonly timeoutMs: number;
36
36
  readonly timeoutResponse: TResponse;
37
37
  readonly normalize: (response: unknown) => TResponse;
38
+ readonly ignoreResponse?: (response: unknown) => boolean;
38
39
  readonly dependencies: PlannotatorDependencies;
39
40
  };
40
41
 
@@ -44,12 +45,13 @@ const requestResponse = <TResponse>({
44
45
  timeoutMs,
45
46
  timeoutResponse,
46
47
  normalize,
48
+ ignoreResponse,
47
49
  dependencies,
48
50
  }: RequestResponseOptions<TResponse>): Promise<TResponse> =>
49
51
  new Promise((resolve) => {
50
52
  let isSettled = false;
51
53
  const finish = (response: unknown): void => {
52
- if (isSettled) return;
54
+ if (isSettled || ignoreResponse?.(response)) return;
53
55
  isSettled = true;
54
56
  dependencies.cancelTimeout(timer);
55
57
  resolve(normalize(response));
@@ -65,6 +67,39 @@ const requestResponse = <TResponse>({
65
67
  });
66
68
  });
67
69
 
70
+ const MISSING_PLAN_CONTENT_RESPONSE =
71
+ 'Missing planContent for plan-review request.';
72
+
73
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
74
+ value !== null && typeof value === 'object' && !Array.isArray(value);
75
+
76
+ const isUnclaimedPlanReviewResponse = (response: unknown): boolean =>
77
+ isRecord(response) &&
78
+ response.status === 'error' &&
79
+ response.error === MISSING_PLAN_CONTENT_RESPONSE;
80
+
81
+ /**
82
+ * Pi keeps its shared event bus across extension reloads. Plannotator versions
83
+ * that do not dispose their request listener can therefore receive one request
84
+ * multiple times and open one browser pane per stale listener. A consumable
85
+ * plan value lets exactly one listener claim the side-effecting request.
86
+ */
87
+ const singleConsumerPlanPayload = (
88
+ planContent: string,
89
+ origin: string,
90
+ ): Readonly<Record<string, unknown>> => {
91
+ let claimed = false;
92
+
93
+ return {
94
+ get planContent(): string | undefined {
95
+ if (claimed) return undefined;
96
+ claimed = true;
97
+ return planContent;
98
+ },
99
+ origin,
100
+ };
101
+ };
102
+
68
103
  /**
69
104
  * Requests a new Plannotator plan review through the injected event bus.
70
105
  *
@@ -83,10 +118,7 @@ export const requestPlannotatorReview = (
83
118
  request: {
84
119
  requestId,
85
120
  action: 'plan-review',
86
- payload: {
87
- planContent: content,
88
- origin,
89
- },
121
+ payload: singleConsumerPlanPayload(content, origin),
90
122
  },
91
123
  timeoutMs,
92
124
  timeoutResponse: {
@@ -94,6 +126,9 @@ export const requestPlannotatorReview = (
94
126
  error: `Plannotator did not respond within ${timeoutMs}ms`,
95
127
  },
96
128
  normalize: normalizePlannotatorStartResponse,
129
+ ...(content.trim()
130
+ ? { ignoreResponse: isUnclaimedPlanReviewResponse }
131
+ : {}),
97
132
  dependencies,
98
133
  });
99
134
 
@@ -1,17 +1,19 @@
1
- import { isAbsolute, resolve } from 'node:path';
2
- import type { StepPermissions } from '../../config/types.ts';
1
+ import { isAbsolute, win32 } from 'node:path';
2
+ import {
3
+ MAX_WORKSPACE_ALLOWED_ROOTS,
4
+ MAX_WORKSPACE_PATH_CHARS,
5
+ type StepPermissions,
6
+ } from '../../config/types.ts';
3
7
  import type { ChildStepPolicy } from './child-policy-types.ts';
4
8
 
5
9
  type PolicySections = Pick<
6
10
  ChildStepPolicy,
7
11
  | 'permissions'
8
- | 'approvedBashCommands'
9
- | 'repositoryCwd'
10
- | 'bootstrapCwd'
11
12
  | 'outcomes'
12
13
  | 'pauseOutcomes'
13
14
  | 'summaryMaxChars'
14
15
  | 'gateSubmitOutcome'
16
+ | 'workspace'
15
17
  >;
16
18
 
17
19
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -20,6 +22,11 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
20
22
  const isStringArray = (value: unknown): value is Array<string> =>
21
23
  Array.isArray(value) && value.every((item) => typeof item === 'string');
22
24
 
25
+ const hasOnlyKeys = (
26
+ value: Readonly<Record<string, unknown>>,
27
+ allowed: ReadonlySet<string>,
28
+ ): boolean => Object.keys(value).every((key) => allowed.has(key));
29
+
23
30
  const isStepPermissions = (value: unknown): value is StepPermissions => {
24
31
  if (!isRecord(value) || !isRecord(value.bash)) return false;
25
32
 
@@ -27,7 +34,6 @@ const isStepPermissions = (value: unknown): value is StepPermissions => {
27
34
  const bashRules = Array.isArray(bash.allow) ? bash.allow : undefined;
28
35
  const isValidMode =
29
36
  bash.mode === 'deny' ||
30
- bash.mode === 'read-only' ||
31
37
  bash.mode === 'allow-list' ||
32
38
  bash.mode === 'unrestricted';
33
39
  const hasValidRules =
@@ -35,96 +41,33 @@ const isStepPermissions = (value: unknown): value is StepPermissions => {
35
41
  bashRules.every(
36
42
  (rule) =>
37
43
  isRecord(rule) &&
44
+ hasOnlyKeys(rule, new Set(['executable', 'argsPrefix'])) &&
38
45
  typeof rule.executable === 'string' &&
39
46
  isStringArray(rule.argsPrefix),
40
47
  );
41
- const hasValidApprovedSources =
42
- bash.approvedSources === undefined ||
43
- (isStringArray(bash.approvedSources) &&
44
- new Set(bash.approvedSources).size === bash.approvedSources.length &&
45
- bash.approvedSources.every(
46
- (source) =>
47
- source === 'verification-worker' ||
48
- source === 'verification-reviewer' ||
49
- source === 'remote-actions' ||
50
- source === 'remote-push' ||
51
- source === 'remote-drafts',
52
- ));
53
- const hasValidHandoffSources =
54
- bash.handoffSources === undefined ||
55
- (isStringArray(bash.handoffSources) &&
56
- new Set(bash.handoffSources).size === bash.handoffSources.length &&
57
- bash.handoffSources.every(
58
- (source) =>
59
- source === 'verification-worker' ||
60
- source === 'verification-reviewer',
61
- ));
62
- const hasValidApprovalShape =
63
- bash.mode === 'allow-list'
64
- ? (bashRules?.length ?? 0) > 0 ||
65
- (Array.isArray(bash.approvedSources) && bash.approvedSources.length > 0)
66
- : bash.approvedSources === undefined;
67
-
68
48
  return (
49
+ hasOnlyKeys(
50
+ value,
51
+ new Set(['tools', 'mcp', 'extensions', 'skills', 'bash']),
52
+ ) &&
53
+ hasOnlyKeys(bash, new Set(['mode', 'allow'])) &&
69
54
  isStringArray(value.tools) &&
70
55
  isStringArray(value.mcp) &&
71
56
  isStringArray(value.extensions) &&
72
57
  isStringArray(value.skills) &&
73
58
  isValidMode &&
74
59
  hasValidRules &&
75
- hasValidApprovedSources &&
76
- hasValidHandoffSources &&
77
- hasValidApprovalShape
60
+ (bash.mode !== 'allow-list' || bashRules.length > 0)
78
61
  );
79
62
  };
80
63
 
81
64
  const parsePermissions = (
82
65
  value: Readonly<Record<string, unknown>>,
83
- ): Pick<PolicySections, 'permissions' | 'approvedBashCommands'> => {
66
+ ): Pick<PolicySections, 'permissions'> => {
84
67
  if (!isStepPermissions(value.permissions)) {
85
68
  throw new Error('child policy permissions are invalid');
86
69
  }
87
- const approvedBashCommands = value.approvedBashCommands;
88
- if (
89
- approvedBashCommands !== undefined &&
90
- (!isStringArray(approvedBashCommands) ||
91
- new Set(approvedBashCommands).size !== approvedBashCommands.length)
92
- ) {
93
- throw new Error('child policy approved Bash commands are invalid');
94
- }
95
- return {
96
- permissions: value.permissions,
97
- ...(approvedBashCommands === undefined ? {} : { approvedBashCommands }),
98
- };
99
- };
100
-
101
- const parseRepositoryPaths = (
102
- value: Readonly<Record<string, unknown>>,
103
- ): Pick<PolicySections, 'repositoryCwd' | 'bootstrapCwd'> => {
104
- const repositoryCwd = value.repositoryCwd;
105
- const bootstrapCwd = value.bootstrapCwd;
106
- if (
107
- repositoryCwd !== undefined &&
108
- (typeof repositoryCwd !== 'string' ||
109
- !isAbsolute(repositoryCwd) ||
110
- repositoryCwd.includes('\0'))
111
- ) {
112
- throw new Error('child policy repository cwd is invalid');
113
- }
114
- if (
115
- bootstrapCwd !== undefined &&
116
- (typeof bootstrapCwd !== 'string' ||
117
- !isAbsolute(bootstrapCwd) ||
118
- bootstrapCwd.includes('\0') ||
119
- typeof repositoryCwd !== 'string' ||
120
- resolve(bootstrapCwd) === resolve(repositoryCwd))
121
- ) {
122
- throw new Error('child policy bootstrap cwd is invalid');
123
- }
124
- return {
125
- ...(repositoryCwd === undefined ? {} : { repositoryCwd }),
126
- ...(bootstrapCwd === undefined ? {} : { bootstrapCwd }),
127
- };
70
+ return { permissions: value.permissions };
128
71
  };
129
72
 
130
73
  const parseOutcomes = (
@@ -174,13 +117,57 @@ const parseOutcomes = (
174
117
  };
175
118
  };
176
119
 
120
+ const parseWorkspace = (
121
+ value: Readonly<Record<string, unknown>>,
122
+ outcomes: ReadonlyArray<string>,
123
+ ): Pick<PolicySections, 'workspace'> => {
124
+ if (value.workspace === undefined) return {};
125
+ if (
126
+ !isRecord(value.workspace) ||
127
+ !hasOnlyKeys(value.workspace, new Set(['bindOn', 'allowedRoots']))
128
+ ) {
129
+ throw new Error('child policy workspace is invalid');
130
+ }
131
+ const bindOn = value.workspace.bindOn;
132
+ const allowedRoots = value.workspace.allowedRoots;
133
+ if (
134
+ !isStringArray(bindOn) ||
135
+ bindOn.length === 0 ||
136
+ new Set(bindOn).size !== bindOn.length ||
137
+ bindOn.some((outcome) => !outcomes.includes(outcome))
138
+ ) {
139
+ throw new Error('child policy workspace bindOn outcomes are invalid');
140
+ }
141
+ if (
142
+ !isStringArray(allowedRoots) ||
143
+ allowedRoots.length === 0 ||
144
+ allowedRoots.length > MAX_WORKSPACE_ALLOWED_ROOTS ||
145
+ new Set(allowedRoots).size !== allowedRoots.length ||
146
+ allowedRoots.some(
147
+ (root) =>
148
+ !root.trim() ||
149
+ root !== root.trim() ||
150
+ root.length > MAX_WORKSPACE_PATH_CHARS ||
151
+ root.includes('\0') ||
152
+ isAbsolute(root) ||
153
+ win32.parse(root).root !== '',
154
+ )
155
+ ) {
156
+ throw new Error('child policy workspace allowed roots are invalid');
157
+ }
158
+ return { workspace: { bindOn, allowedRoots } };
159
+ };
160
+
177
161
  /**
178
- * Parses permission, repository, and outcome sections of a child policy.
162
+ * Parses permission and outcome sections of a child policy.
179
163
  */
180
164
  export const parseChildPolicySections = (
181
165
  value: Readonly<Record<string, unknown>>,
182
- ): PolicySections => ({
183
- ...parsePermissions(value),
184
- ...parseRepositoryPaths(value),
185
- ...parseOutcomes(value),
186
- });
166
+ ): PolicySections => {
167
+ const outcomeSections = parseOutcomes(value);
168
+ return {
169
+ ...parsePermissions(value),
170
+ ...outcomeSections,
171
+ ...parseWorkspace(value, outcomeSections.outcomes),
172
+ };
173
+ };
@@ -1,4 +1,7 @@
1
- import type { StepPermissions } from '../../config/types.ts';
1
+ import type {
2
+ StepPermissions,
3
+ StepWorkspaceBinding,
4
+ } from '../../config/types.ts';
2
5
 
3
6
  export type ChildStepPolicy = {
4
7
  readonly version: 1;
@@ -8,22 +11,19 @@ export type ChildStepPolicy = {
8
11
  readonly runId: string;
9
12
  readonly stepId: string;
10
13
  readonly stepTitle: string;
14
+ /** Effective run directory: the captured start cwd or accepted binding. */
15
+ readonly cwd: string;
11
16
  readonly policyDigest: string;
12
17
  readonly capabilityPath: string;
13
18
  readonly capabilityToken: string;
14
19
  readonly resultPath: string;
15
20
  readonly permissions: StepPermissions;
16
- /** Exact Bash command strings extracted from a reviewed gate artifact. */
17
- readonly approvedBashCommands?: ReadonlyArray<string>;
18
- /** Reviewed repository root that file mutations must remain inside. */
19
- readonly repositoryCwd?: string;
20
- /** Existing reviewed source directory used only to bootstrap repositoryCwd. */
21
- readonly bootstrapCwd?: string;
22
21
  readonly outcomes: ReadonlyArray<string>;
23
22
  /** Outcomes that pause instead of advancing to another workflow step. */
24
23
  readonly pauseOutcomes: ReadonlyArray<string>;
25
24
  readonly summaryMaxChars: number;
26
25
  readonly gateSubmitOutcome?: string;
26
+ readonly workspace?: StepWorkspaceBinding;
27
27
  };
28
28
 
29
29
  export type ExtractedChildPolicy = {
@@ -1,4 +1,4 @@
1
- import { dirname, resolve } from 'node:path';
1
+ import { dirname, isAbsolute, resolve } from 'node:path';
2
2
  import { SUBAGENT_RUNTIME_NAME_PATTERN } from '../../config/types.ts';
3
3
  import {
4
4
  DEFAULT_CHILD_POLICY_ENVIRONMENT,
@@ -19,18 +19,17 @@ const POLICY_KEYS: ReadonlySet<string> = new Set([
19
19
  'runId',
20
20
  'stepId',
21
21
  'stepTitle',
22
+ 'cwd',
22
23
  'policyDigest',
23
24
  'capabilityPath',
24
25
  'capabilityToken',
25
26
  'resultPath',
26
27
  'permissions',
27
- 'approvedBashCommands',
28
- 'repositoryCwd',
29
- 'bootstrapCwd',
30
28
  'outcomes',
31
29
  'pauseOutcomes',
32
30
  'summaryMaxChars',
33
31
  'gateSubmitOutcome',
32
+ 'workspace',
34
33
  ]);
35
34
 
36
35
  type RequiredStringField =
@@ -40,6 +39,7 @@ type RequiredStringField =
40
39
  | 'runId'
41
40
  | 'stepId'
42
41
  | 'stepTitle'
42
+ | 'cwd'
43
43
  | 'policyDigest'
44
44
  | 'capabilityPath'
45
45
  | 'capabilityToken'
@@ -85,6 +85,7 @@ type IdentityAndPaths = Pick<
85
85
  | 'runId'
86
86
  | 'stepId'
87
87
  | 'stepTitle'
88
+ | 'cwd'
88
89
  | 'policyDigest'
89
90
  | 'capabilityPath'
90
91
  | 'capabilityToken'
@@ -101,12 +102,16 @@ const parseIdentityAndPaths = (
101
102
  const runId = requiredString(value, 'runId');
102
103
  const stepId = requiredString(value, 'stepId');
103
104
  const stepTitle = requiredString(value, 'stepTitle');
105
+ const cwd = requiredString(value, 'cwd');
104
106
  const policyDigest = requiredString(value, 'policyDigest');
105
107
  const capabilityPath = requiredString(value, 'capabilityPath');
106
108
  const capabilityToken = requiredString(value, 'capabilityToken');
107
109
  const resultPath = requiredString(value, 'resultPath');
108
110
 
109
111
  if (value.version !== 1) throw new Error('unsupported child policy version');
112
+ if (!isAbsolute(cwd)) {
113
+ throw new Error('child policy cwd must be an absolute path');
114
+ }
110
115
  if (!POLICY_DIGEST_PATTERN.test(policyDigest)) {
111
116
  throw new Error('child policy digest is invalid');
112
117
  }
@@ -137,6 +142,7 @@ const parseIdentityAndPaths = (
137
142
  runId,
138
143
  stepId,
139
144
  stepTitle,
145
+ cwd,
140
146
  policyDigest,
141
147
  capabilityPath,
142
148
  capabilityToken,
@@ -12,6 +12,7 @@ const STRUCTURED_RESULT_KEYS: ReadonlySet<string> = new Set([
12
12
  'outcome',
13
13
  'summary',
14
14
  'artifact',
15
+ 'workspace',
15
16
  ]);
16
17
 
17
18
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -1,52 +1,41 @@
1
- import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
2
1
  import type { ChildStepPolicy } from './child-policy-types.ts';
3
2
  import type { SubagentChildRuntimeDependencies } from './child-runtime-types.ts';
4
3
 
5
- const FILE_MUTATION_TOOLS: ReadonlySet<string> = new Set(['edit', 'write']);
6
-
7
- const errorCode = (error: unknown): unknown =>
8
- error !== null && typeof error === 'object' && 'code' in error
9
- ? error.code
10
- : undefined;
11
-
12
- const pathIsInside = (root: string, candidate: string): boolean => {
13
- const relativePath = relative(root, candidate);
14
- return (
15
- relativePath !== '..' &&
16
- !relativePath.startsWith(`..${sep}`) &&
17
- !isAbsolute(relativePath)
18
- );
19
- };
20
-
21
- const nearestCanonicalAncestor = (
22
- path: string,
23
- dependencies: SubagentChildRuntimeDependencies,
24
- ): string | undefined => {
25
- let candidate = path;
26
- while (true) {
27
- try {
28
- dependencies.fileSystem.inspect(candidate);
29
- } catch (error) {
30
- if (errorCode(error) !== 'ENOENT') return undefined;
31
- const parent = dirname(candidate);
32
- if (parent === candidate) return undefined;
33
- candidate = parent;
34
- continue;
35
- }
36
- try {
37
- return dependencies.fileSystem.realPath(candidate);
38
- } catch {
39
- return undefined;
40
- }
41
- }
42
- };
43
-
44
4
  type VerifyChildCapabilityOptions = {
45
5
  readonly policy: ChildStepPolicy;
46
6
  readonly childAgent: string;
47
7
  readonly dependencies: SubagentChildRuntimeDependencies;
48
8
  };
49
9
 
10
+ /**
11
+ * Verifies that the delegated child started in the workflow run's captured
12
+ * working directory.
13
+ *
14
+ * @throws When either directory cannot be resolved or they differ.
15
+ */
16
+ export const verifyChildWorkingDirectory = (
17
+ policy: ChildStepPolicy,
18
+ dependencies: SubagentChildRuntimeDependencies,
19
+ ): void => {
20
+ let expected: string;
21
+ let actual: string;
22
+ try {
23
+ expected = dependencies.fileSystem.realPath(policy.cwd);
24
+ actual = dependencies.fileSystem.realPath(
25
+ dependencies.currentWorkingDirectory(),
26
+ );
27
+ } catch {
28
+ throw new Error(
29
+ 'child working directory does not match the delegated workflow policy',
30
+ );
31
+ }
32
+ if (expected !== policy.cwd || actual !== expected) {
33
+ throw new Error(
34
+ 'child working directory does not match the delegated workflow policy',
35
+ );
36
+ }
37
+ };
38
+
50
39
  /**
51
40
  * Verifies and consumes the one-time capability for a delegated child.
52
41
  *
@@ -108,49 +97,3 @@ export const writeChildResult = ({
108
97
  throw error;
109
98
  }
110
99
  };
111
-
112
- type RepositoryMutationOptions = {
113
- readonly toolName: string;
114
- readonly input: Readonly<Record<string, unknown>>;
115
- readonly policy: ChildStepPolicy;
116
- readonly dependencies: SubagentChildRuntimeDependencies;
117
- };
118
-
119
- /**
120
- * Returns a policy error when a file mutation escapes the reviewed repository.
121
- */
122
- export const repositoryMutationError = ({
123
- toolName,
124
- input,
125
- policy,
126
- dependencies,
127
- }: RepositoryMutationOptions): string | undefined => {
128
- if (!policy.repositoryCwd || !FILE_MUTATION_TOOLS.has(toolName)) return;
129
- if (typeof input.path !== 'string' || !input.path.trim()) {
130
- return `${toolName} must name a path inside the reviewed repository root`;
131
- }
132
-
133
- const candidate = resolve(dependencies.currentWorkingDirectory(), input.path);
134
- const root = resolve(policy.repositoryCwd);
135
- if (!pathIsInside(root, candidate)) {
136
- return `${toolName} path is outside the reviewed repository root "${policy.repositoryCwd}"`;
137
- }
138
-
139
- let canonicalRoot: string;
140
- try {
141
- if (!dependencies.fileSystem.stat(root).isDirectory()) {
142
- throw new Error('not a directory');
143
- }
144
- canonicalRoot = dependencies.fileSystem.realPath(root);
145
- } catch {
146
- return `reviewed repository root is not an existing directory: ${policy.repositoryCwd}`;
147
- }
148
- const canonicalAncestor = nearestCanonicalAncestor(candidate, dependencies);
149
- if (
150
- canonicalAncestor === undefined ||
151
- !pathIsInside(canonicalRoot, canonicalAncestor)
152
- ) {
153
- return `${toolName} path is outside the reviewed repository root "${policy.repositoryCwd}"`;
154
- }
155
- return;
156
- };