@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
@@ -18,6 +18,7 @@ export const childPolicyStep = (policy: ChildStepPolicy): WorkflowStep => ({
18
18
  permissions: policy.permissions,
19
19
  requires: { tools: [], extensions: [], skills: [] },
20
20
  transitions: {},
21
+ ...(policy.workspace ? { workspace: policy.workspace } : {}),
21
22
  });
22
23
 
23
24
  /**
@@ -25,7 +26,6 @@ export const childPolicyStep = (policy: ChildStepPolicy): WorkflowStep => ({
25
26
  * policy.
26
27
  */
27
28
  export const childSystemPrompt = (policy: ChildStepPolicy): string => {
28
- const hasPauseOutcome = policy.pauseOutcomes.length > 0;
29
29
  return [
30
30
  '# Pi Workflows delegated step',
31
31
  '',
@@ -36,7 +36,7 @@ export const childSystemPrompt = (policy: ChildStepPolicy): string => {
36
36
  'The parent workflow harness owns orchestration and state transitions.',
37
37
  'Perform only this delegated step. Its child-side tool policy is enforced.',
38
38
  'When finished, call `structured_output` exactly once and as the only tool call in that message.',
39
- 'Pass the workflow result as its `value`: outcome, summary, and optional artifact.',
39
+ 'Pass the workflow result as its `value`: outcome, summary, optional artifact, and workspace only when required below.',
40
40
  `Valid outcomes: ${policy.outcomes.join(', ')}`,
41
41
  `Pause outcomes: ${policy.pauseOutcomes.join(', ') || '(none)'}`,
42
42
  `Summary limit: ${policy.summaryMaxChars} characters`,
@@ -45,39 +45,14 @@ export const childSystemPrompt = (policy: ChildStepPolicy): string => {
45
45
  `Outcome "${policy.gateSubmitOutcome}" requires the complete gate artifact.`,
46
46
  ]
47
47
  : []),
48
- ...(hasPauseOutcome
48
+ ...(policy.workspace
49
49
  ? [
50
- `If the workflow definition or environment is wrong, choose a pause outcome (${policy.pauseOutcomes.join(', ')}).`,
50
+ `Workspace-binding outcomes: ${policy.workspace.bindOn.join(', ')}`,
51
+ `For those outcomes, include workspace.cwd as an absolute directory under one allowed root relative to the run-start directory: ${policy.workspace.allowedRoots.join(', ')}`,
52
+ 'For every other outcome, omit workspace.',
51
53
  ]
52
- : [
53
- 'If the workflow definition or environment is wrong, do not fabricate success or call the completion tool; end with a concise declarative error so the parent pauses the step.',
54
- ]),
54
+ : ['This step cannot bind a workspace; omit workspace.']),
55
55
  'This is a non-interactive workflow child. Never call contact_supervisor, subagent_supervisor, or intercom.',
56
- ...(policy.gateSubmitOutcome
57
- ? [
58
- 'Put every unresolved decision in the gate artifact with evidence, options, a recommendation, and an adopted default; do not ask a terminal question.',
59
- ]
60
- : hasPauseOutcome
61
- ? [
62
- 'Treat the step instructions and incoming handoff as the final execution contract.',
63
- 'If that contract is missing, stale, or contradictory, finish with a pause outcome and describe the unresolved contract and evidence declaratively in the summary; do not ask a terminal question.',
64
- ]
65
- : [
66
- 'Treat the step instructions and incoming handoff as the final execution contract.',
67
- 'If that contract is missing, stale, or contradictory, do not fabricate success or call the completion tool; end with a concise declarative error so the parent pauses the step. Do not ask a terminal question.',
68
- ]),
69
- ...(policy.repositoryCwd
70
- ? [
71
- `Reviewed repository root: ${policy.repositoryCwd}`,
72
- ...(policy.bootstrapCwd
73
- ? [
74
- `Bootstrap directory: ${policy.bootstrapCwd}`,
75
- 'The reviewed repository root does not exist yet. Run only its exact approved setup command first, then use absolute paths under the reviewed repository root for every edit and write. Never mutate the bootstrap directory.',
76
- ]
77
- : [
78
- 'Keep every edit and write inside the reviewed repository root.',
79
- ]),
80
- ]
81
- : []),
56
+ 'Follow the supplied step instructions when choosing one valid outcome; outcome names have no built-in domain meaning.',
82
57
  ].join('\n');
83
58
  };
@@ -13,8 +13,8 @@ import {
13
13
  } from './child-runtime-completion.ts';
14
14
  import { DEFAULT_CHILD_RUNTIME_DEPENDENCIES } from './child-runtime-dependencies.ts';
15
15
  import {
16
- repositoryMutationError,
17
16
  verifyChildCapability,
17
+ verifyChildWorkingDirectory,
18
18
  writeChildResult,
19
19
  } from './child-runtime-files.ts';
20
20
  import { childPolicyStep, childSystemPrompt } from './child-runtime-policy.ts';
@@ -109,6 +109,7 @@ export const registerSubagentChildRuntime = (
109
109
  'child agent does not match the delegated workflow policy',
110
110
  );
111
111
  }
112
+ verifyChildWorkingDirectory(extracted.policy, dependencies);
112
113
  verifyChildCapability({
113
114
  policy: extracted.policy,
114
115
  childAgent,
@@ -221,7 +222,7 @@ export const registerSubagentChildRuntime = (
221
222
  return {
222
223
  block: true,
223
224
  reason:
224
- 'workflow children are non-interactive; use structured_output with a pause outcome and describe the unresolved contract in summary',
225
+ 'workflow children are non-interactive; follow the step prompt and use structured_output with one configured valid outcome',
225
226
  };
226
227
  }
227
228
 
@@ -231,7 +232,6 @@ export const registerSubagentChildRuntime = (
231
232
  input,
232
233
  childPolicyStep(activePolicy),
233
234
  pi.getAllTools(),
234
- activePolicy.approvedBashCommands ?? [],
235
235
  );
236
236
  if (!authorization.allowed) {
237
237
  return {
@@ -246,18 +246,6 @@ export const registerSubagentChildRuntime = (
246
246
  };
247
247
  }
248
248
 
249
- const mutationError = repositoryMutationError({
250
- toolName: event.toolName,
251
- input,
252
- policy: activePolicy,
253
- dependencies,
254
- });
255
- if (mutationError) {
256
- return {
257
- block: true,
258
- reason: mutationError,
259
- };
260
- }
261
249
  freezeToolInput(event.input);
262
250
  });
263
251
  };
@@ -21,6 +21,7 @@ export const parseDelegatedStepResult = (
21
21
  ...(policy.gateSubmitOutcome
22
22
  ? { gateSubmitOutcome: policy.gateSubmitOutcome }
23
23
  : {}),
24
+ ...(policy.workspace ? { workspace: policy.workspace } : {}),
24
25
  });
25
26
  } catch (error) {
26
27
  const message = error instanceof Error ? error.message : String(error);
@@ -26,6 +26,11 @@ export const formatToolFailureDiagnostic = (
26
26
  `${hasSuccessfulOutputCorrelation ? 'Successful tool output' : 'Tool error'}: ${diagnostic.output}`,
27
27
  ]
28
28
  : []),
29
+ ...(diagnostic.postCompletionWarning
30
+ ? [
31
+ `Post-completion watchdog warning: ${diagnostic.postCompletionWarning}`,
32
+ ]
33
+ : []),
29
34
  ...(diagnostic.correlation === 'latest-before-completion'
30
35
  ? [
31
36
  'Correlation: latest failed tool call before successful structured_output; terminal text did not identify the call',
@@ -4,6 +4,7 @@ export type ToolFailureDiagnostic = {
4
4
  readonly tool: string;
5
5
  readonly call?: string;
6
6
  readonly output?: string;
7
+ readonly postCompletionWarning?: string;
7
8
  readonly replaySafe?: true;
8
9
  readonly completionAfterFailure?: true;
9
10
  readonly completionValue?: Readonly<Record<string, unknown>>;
@@ -21,7 +22,6 @@ export type DelegationReplayAudit = {
21
22
  export type DelegationReplayExpectation = {
22
23
  readonly task: string;
23
24
  readonly bashPermission: BashPermission;
24
- readonly approvedBashCommands: ReadonlyArray<string>;
25
25
  };
26
26
 
27
27
  export type SubagentSessionIdentity = {
@@ -55,6 +55,11 @@ export type RecordedCompletion = {
55
55
  readonly value: Readonly<Record<string, unknown>>;
56
56
  };
57
57
 
58
+ export type RecordedTranscriptWarning = {
59
+ readonly order: number;
60
+ readonly content: string;
61
+ };
62
+
58
63
  export type RecordedMessage = {
59
64
  readonly order: number;
60
65
  readonly value: Readonly<Record<string, unknown>>;
@@ -65,6 +70,7 @@ export type ParsedFailureTranscript = {
65
70
  readonly diagnostics: ReadonlyArray<RecordedToolFailure>;
66
71
  readonly successfulResults: ReadonlyArray<RecordedToolSuccess>;
67
72
  readonly successfulCompletions: ReadonlyArray<RecordedCompletion>;
73
+ readonly transcriptWarnings: ReadonlyArray<RecordedTranscriptWarning>;
68
74
  readonly recordedMessages: ReadonlyArray<RecordedMessage>;
69
75
  readonly resultCallIds: ReadonlySet<string>;
70
76
  readonly hasValidFalsePositiveProof: boolean;
@@ -11,11 +11,13 @@ export type {
11
11
  export { parseToolFailureDiagnostic } from './failure-correlation.ts';
12
12
  export { parseDelegationReplayAudit } from './replay-audit.ts';
13
13
  export {
14
+ auditCompletedDelegationTranscript,
14
15
  deriveSubagentSessionRoot,
15
16
  readDelegationReplayAudit,
16
17
  readToolFailureDiagnostic,
17
18
  } from './session-diagnostics.ts';
18
19
  export type {
20
+ CompletedDelegationTranscriptAudit,
19
21
  SubagentDiagnosticDependencies,
20
22
  SubagentDiagnosticFileHandle,
21
23
  SubagentDiagnosticFileSnapshot,
@@ -3,6 +3,7 @@ import type {
3
3
  RecordedCompletion,
4
4
  RecordedToolCall,
5
5
  RecordedToolFailure,
6
+ RecordedTranscriptWarning,
6
7
  ToolFailureDiagnostic,
7
8
  } from './diagnostic-types.ts';
8
9
  import { parseFailureTranscript } from './failure-transcript.ts';
@@ -40,6 +41,17 @@ const latestMatching = (
40
41
  return undefined;
41
42
  };
42
43
 
44
+ const latestWarningAfter = (
45
+ warnings: ReadonlyArray<RecordedTranscriptWarning>,
46
+ order: number,
47
+ ): RecordedTranscriptWarning | undefined => {
48
+ for (let index = warnings.length - 1; index >= 0; index -= 1) {
49
+ const warning = warnings[index];
50
+ if (warning && warning.order > order) return warning;
51
+ }
52
+ return undefined;
53
+ };
54
+
43
55
  const finalCompletion = (
44
56
  completions: ReadonlyArray<RecordedCompletion>,
45
57
  latestFailureOrder: number,
@@ -60,6 +72,7 @@ const publicDiagnostic = (
60
72
  diagnostics: ReadonlyArray<RecordedToolFailure>,
61
73
  recordedCalls: ReadonlyArray<RecordedToolCall>,
62
74
  successfulCompletions: ReadonlyArray<RecordedCompletion>,
75
+ postCompletionWarning: RecordedTranscriptWarning | undefined,
63
76
  lastInteractionOrder: number,
64
77
  allowCompletionProof: boolean,
65
78
  correlation?: ToolFailureDiagnostic['correlation'],
@@ -78,6 +91,9 @@ const publicDiagnostic = (
78
91
  );
79
92
  return {
80
93
  ...result,
94
+ ...(postCompletionWarning
95
+ ? { postCompletionWarning: postCompletionWarning.content }
96
+ : {}),
81
97
  ...(isFailureTranscriptReplaySafe({
82
98
  calls: recordedCalls,
83
99
  diagnostics,
@@ -110,17 +126,30 @@ const hiddenFalsePositiveDiagnostic = (
110
126
  resultCallIds,
111
127
  successfulCompletions,
112
128
  successfulResults,
129
+ transcriptWarnings,
113
130
  } = transcript;
114
- if (diagnostics.length > 0) return undefined;
131
+ const completionOrder = successfulCompletions.at(-1)?.order ?? 0;
132
+ const postCompletionWarning = latestWarningAfter(
133
+ transcriptWarnings,
134
+ completionOrder,
135
+ );
115
136
 
116
137
  const falsePositive = reproduceHiddenBashFalsePositive(
117
138
  recordedMessages,
118
139
  successfulResults,
119
140
  );
141
+ const hasLaterFailure =
142
+ falsePositive !== undefined &&
143
+ diagnostics.some(
144
+ (diagnostic) => diagnostic.order > falsePositive.result.order,
145
+ );
120
146
  const completion = falsePositive
121
147
  ? finalCompletion(
122
148
  successfulCompletions,
123
- falsePositive.result.order,
149
+ Math.max(
150
+ falsePositive.result.order,
151
+ diagnostics.at(-1)?.order ?? falsePositive.result.order,
152
+ ),
124
153
  lastInteractionOrder,
125
154
  allowCompletionProof,
126
155
  )
@@ -133,6 +162,8 @@ const hiddenFalsePositiveDiagnostic = (
133
162
  1 ||
134
163
  expectedTool?.toLowerCase() !== 'bash' ||
135
164
  !falsePositive ||
165
+ hasLaterFailure ||
166
+ postCompletionWarning !== undefined ||
136
167
  terminalError !== falsePositive.terminalError ||
137
168
  !completion
138
169
  ) {
@@ -170,7 +201,13 @@ export const parseToolFailureDiagnostic = (
170
201
  lastInteractionOrder,
171
202
  recordedCalls,
172
203
  successfulCompletions,
204
+ transcriptWarnings,
173
205
  } = parsed;
206
+ const completionOrder = successfulCompletions.at(-1)?.order ?? 0;
207
+ const postCompletionWarning = latestWarningAfter(
208
+ transcriptWarnings,
209
+ completionOrder,
210
+ );
174
211
  const matchesExpectedTool = (
175
212
  diagnostic: Pick<RecordedToolFailure, 'tool'>,
176
213
  ): boolean =>
@@ -203,6 +240,7 @@ export const parseToolFailureDiagnostic = (
203
240
  diagnostics,
204
241
  recordedCalls,
205
242
  successfulCompletions,
243
+ postCompletionWarning,
206
244
  lastInteractionOrder,
207
245
  allowCompletionProof,
208
246
  'latest-before-completion',
@@ -222,12 +260,24 @@ export const parseToolFailureDiagnostic = (
222
260
  selected = latestMatching(diagnostics, matchesExpectedTool);
223
261
  }
224
262
 
263
+ if (!selected && postCompletionWarning) {
264
+ selected =
265
+ latestMatching(diagnostics, matchesExpectedTool) ?? diagnostics.at(-1);
266
+ if (!selected) {
267
+ return {
268
+ tool: expectedTool ?? 'subagent',
269
+ postCompletionWarning: postCompletionWarning.content,
270
+ };
271
+ }
272
+ }
273
+
225
274
  return selected
226
275
  ? publicDiagnostic(
227
276
  selected,
228
277
  diagnostics,
229
278
  recordedCalls,
230
279
  successfulCompletions,
280
+ postCompletionWarning,
231
281
  lastInteractionOrder,
232
282
  allowCompletionProof,
233
283
  )
@@ -12,8 +12,55 @@ import type {
12
12
  RecordedToolCall,
13
13
  RecordedToolFailure,
14
14
  RecordedToolSuccess,
15
+ RecordedTranscriptWarning,
15
16
  } from './diagnostic-types.ts';
16
17
 
18
+ const WATCHDOG_WARNING_TYPE = 'subagent_watchdog_warning';
19
+ const MAX_WARNING_FIELD_CHARS = 600;
20
+
21
+ const boundedWarningField = (value: string): string => {
22
+ const normalized = value.trim().replaceAll(/\s+/g, ' ');
23
+ return normalized.length <= MAX_WARNING_FIELD_CHARS
24
+ ? normalized
25
+ : `${normalized.slice(0, MAX_WARNING_FIELD_CHARS - 1)}…`;
26
+ };
27
+
28
+ const transcriptWarningContent = (
29
+ entry: Readonly<Record<string, unknown>>,
30
+ ): string | undefined => {
31
+ if (
32
+ entry.type !== 'custom_message' ||
33
+ entry.customType !== WATCHDOG_WARNING_TYPE
34
+ ) {
35
+ return undefined;
36
+ }
37
+ const details = entry.details;
38
+ if (isDiagnosticRecord(details)) {
39
+ const fields = [
40
+ typeof details.summary === 'string' ? details.summary : undefined,
41
+ typeof details.evidence === 'string' ? details.evidence : undefined,
42
+ typeof details.recommendedAction === 'string'
43
+ ? `Recommended action: ${details.recommendedAction}`
44
+ : undefined,
45
+ ].filter((field): field is string => Boolean(field?.trim()));
46
+ if (fields.length > 0) return boundedWarningField(fields.join(' '));
47
+ }
48
+ return typeof entry.content === 'string' && entry.content.trim()
49
+ ? boundedWarningField(entry.content)
50
+ : 'The child emitted an unresolved watchdog warning after completion.';
51
+ };
52
+
53
+ const isBenignTerminalAssistant = (
54
+ message: Readonly<Record<string, unknown>>,
55
+ ): boolean =>
56
+ message.stopReason === 'stop' &&
57
+ message.errorMessage === undefined &&
58
+ Array.isArray(message.content) &&
59
+ message.content.length === 1 &&
60
+ isDiagnosticRecord(message.content[0]) &&
61
+ message.content[0].type === 'text' &&
62
+ message.content[0].text === '';
63
+
17
64
  /**
18
65
  * Parses the tool calls, results, completions, and structural evidence needed
19
66
  * to correlate a terminal failure.
@@ -26,6 +73,7 @@ export const parseFailureTranscript = (
26
73
  const diagnostics: Array<RecordedToolFailure> = [];
27
74
  const successfulResults: Array<RecordedToolSuccess> = [];
28
75
  const successfulCompletions: Array<RecordedCompletion> = [];
76
+ const transcriptWarnings: Array<RecordedTranscriptWarning> = [];
29
77
  const recordedMessages: Array<RecordedMessage> = [];
30
78
  const resultCallIds = new Set<string>();
31
79
  let hasValidFalsePositiveProof = true;
@@ -43,6 +91,14 @@ export const parseFailureTranscript = (
43
91
  hasValidFalsePositiveProof = false;
44
92
  continue;
45
93
  }
94
+ if (isDiagnosticRecord(entry)) {
95
+ const warning = transcriptWarningContent(entry);
96
+ if (warning) {
97
+ transcriptWarnings.push({ order, content: warning });
98
+ lastInteractionOrder = order;
99
+ continue;
100
+ }
101
+ }
46
102
  if (!isDiagnosticRecord(entry) || entry.type !== 'message') continue;
47
103
  const message = entry.message;
48
104
  if (!isDiagnosticRecord(message)) {
@@ -65,7 +121,9 @@ export const parseFailureTranscript = (
65
121
  hasValidFalsePositiveProof = false;
66
122
  continue;
67
123
  }
68
- lastInteractionOrder = order;
124
+ if (!isBenignTerminalAssistant(message)) {
125
+ lastInteractionOrder = order;
126
+ }
69
127
  const toolCalls = message.content.filter(
70
128
  (item): item is Record<string, unknown> =>
71
129
  isDiagnosticRecord(item) &&
@@ -184,6 +242,7 @@ export const parseFailureTranscript = (
184
242
  diagnostics,
185
243
  successfulResults,
186
244
  successfulCompletions,
245
+ transcriptWarnings,
187
246
  recordedMessages,
188
247
  resultCallIds,
189
248
  hasValidFalsePositiveProof,
@@ -139,7 +139,6 @@ export const parseDelegationReplayAudit = (
139
139
  call,
140
140
  diagnostics,
141
141
  bashPermission: expectation.bashPermission,
142
- approvedBashCommands: expectation.approvedBashCommands,
143
142
  }),
144
143
  ),
145
144
  toolCount: recordedCalls.length,
@@ -12,25 +12,16 @@ const REPLAY_SAFE_TOOLS: ReadonlySet<string> = new Set([
12
12
  'read',
13
13
  'structured_output',
14
14
  ]);
15
- const PRE_EXECUTION_BASH_FAILURES = [
16
- 'command does not match this step',
17
- 'environment assignments are not allowed',
18
- 'not enabled by subagent',
19
- 'shell operators, substitutions, expansions, and comments are not allowed',
20
- 'shell wrapper',
21
- 'substitutions and escapes are not allowed inside double quotes',
22
- 'trailing bash escape is not allowed',
23
- 'unterminated bash quote',
24
- 'unquoted pathname and tilde expansion are not allowed',
25
- ] as const;
26
15
 
27
- const isPreExecutionBashFailure = (output: string | undefined): boolean => {
28
- if (!output) return false;
29
- const normalizedOutput = output.toLowerCase();
30
- return PRE_EXECUTION_BASH_FAILURES.some((fragment) =>
31
- normalizedOutput.includes(fragment),
16
+ const isPreExecutionBashFailure = (
17
+ output: string | undefined,
18
+ rejectionReason: string | undefined,
19
+ ): boolean =>
20
+ Boolean(
21
+ output &&
22
+ rejectionReason &&
23
+ output.toLowerCase().includes(rejectionReason.toLowerCase()),
32
24
  );
33
- };
34
25
 
35
26
  /**
36
27
  * Returns whether replaying a recorded call cannot repeat a mutation.
@@ -39,29 +30,23 @@ export const isReplaySafeToolCall = ({
39
30
  call,
40
31
  diagnostics,
41
32
  bashPermission,
42
- approvedBashCommands = [],
43
33
  }: {
44
34
  readonly call: RecordedToolCall;
45
35
  readonly diagnostics: ReadonlyArray<RecordedToolFailure>;
46
36
  readonly bashPermission?: BashPermission;
47
- readonly approvedBashCommands?: ReadonlyArray<string>;
48
37
  }): boolean => {
49
38
  const tool = call.tool.toLowerCase();
50
39
  if (REPLAY_SAFE_TOOLS.has(tool)) return true;
51
40
  if (tool !== 'bash' || !call.call) return false;
52
- if (authorizeBash(call.call, { mode: 'read-only', allow: [] }).allowed) {
53
- return true;
54
- }
55
- if (
56
- !bashPermission ||
57
- authorizeBash(call.call, bashPermission, approvedBashCommands).allowed
58
- ) {
41
+ if (!bashPermission) {
59
42
  return false;
60
43
  }
44
+ const authorization = authorizeBash(call.call, bashPermission);
45
+ if (authorization.allowed) return false;
61
46
  const failure = diagnostics.find(
62
47
  (diagnostic) => diagnostic.callId === call.id,
63
48
  );
64
- return isPreExecutionBashFailure(failure?.output);
49
+ return isPreExecutionBashFailure(failure?.output, authorization.reason);
65
50
  };
66
51
 
67
52
  /**
@@ -16,6 +16,7 @@ import type {
16
16
  SubagentSessionIdentity,
17
17
  ToolFailureDiagnostic,
18
18
  } from './diagnostic-types.ts';
19
+ import { parseFailureTranscript } from './failure-transcript.ts';
19
20
  import { parseToolFailureDiagnostic } from './failure-correlation.ts';
20
21
  import { parseDelegationReplayAudit } from './replay-audit.ts';
21
22
 
@@ -25,6 +26,7 @@ const SESSION_FILE_SUFFIX = '.jsonl';
25
26
  const MAX_SESSION_TAIL_BYTES = 1024 * 1024;
26
27
 
27
28
  export type SubagentDiagnosticPathInspection = {
29
+ readonly isDirectory: () => boolean;
28
30
  readonly isFile: () => boolean;
29
31
  readonly isSymbolicLink: () => boolean;
30
32
  };
@@ -60,6 +62,16 @@ export type SubagentDiagnosticDependencies = {
60
62
  readonly fileSystem: SubagentDiagnosticFileSystem;
61
63
  };
62
64
 
65
+ export type CompletedDelegationTranscriptAudit =
66
+ | {
67
+ readonly verified: true;
68
+ readonly warning?: string;
69
+ }
70
+ | {
71
+ readonly verified: false;
72
+ readonly reason: string;
73
+ };
74
+
63
75
  const DEFAULT_DIAGNOSTIC_DEPENDENCIES = {
64
76
  fileSystem: {
65
77
  inspect: lstat,
@@ -152,15 +164,41 @@ const readContainedSessionTail = async ({
152
164
  return undefined;
153
165
  }
154
166
 
167
+ const runDirectory = resolve(trustedRoot, identity.runId);
168
+ const childDirectory = resolve(runDirectory, `run-${identity.childIndex}`);
155
169
  const resolvedSessionFile = resolve(sessionFile);
156
- const inspected = await dependencies.fileSystem.inspect(resolvedSessionFile);
157
- if (inspected.isSymbolicLink() || !inspected.isFile()) return undefined;
170
+ const [runDirectoryInfo, childDirectoryInfo, inspected] = await Promise.all([
171
+ dependencies.fileSystem.inspect(runDirectory),
172
+ dependencies.fileSystem.inspect(childDirectory),
173
+ dependencies.fileSystem.inspect(resolvedSessionFile),
174
+ ]);
175
+ if (
176
+ runDirectoryInfo.isSymbolicLink() ||
177
+ !runDirectoryInfo.isDirectory() ||
178
+ childDirectoryInfo.isSymbolicLink() ||
179
+ !childDirectoryInfo.isDirectory() ||
180
+ inspected.isSymbolicLink() ||
181
+ !inspected.isFile()
182
+ ) {
183
+ return undefined;
184
+ }
158
185
 
159
186
  const [canonicalRoot, canonicalSessionFile] = await Promise.all([
160
187
  dependencies.fileSystem.realPath(trustedRoot),
161
188
  dependencies.fileSystem.realPath(resolvedSessionFile),
162
189
  ]);
163
- if (!pathIsWithin(canonicalRoot, canonicalSessionFile)) return undefined;
190
+ const canonicalExpectedSessionFile = resolve(
191
+ canonicalRoot,
192
+ identity.runId,
193
+ `run-${identity.childIndex}`,
194
+ SESSION_FILE_NAME,
195
+ );
196
+ if (
197
+ !pathIsWithin(canonicalRoot, canonicalSessionFile) ||
198
+ canonicalSessionFile !== canonicalExpectedSessionFile
199
+ ) {
200
+ return undefined;
201
+ }
164
202
 
165
203
  const handle =
166
204
  await dependencies.fileSystem.openReadOnlyNoFollow(canonicalSessionFile);
@@ -229,6 +267,67 @@ export const readToolFailureDiagnostic = async (
229
267
  }
230
268
  };
231
269
 
270
+ /**
271
+ * Proves that a completed child transcript ends at one successful structured
272
+ * result and contains no later watchdog blocker.
273
+ */
274
+ export const auditCompletedDelegationTranscript = async (
275
+ sessionFile: string | undefined,
276
+ trustedRoot: string | undefined,
277
+ identity: SubagentSessionIdentity | undefined,
278
+ dependencies: SubagentDiagnosticDependencies = DEFAULT_DIAGNOSTIC_DEPENDENCIES,
279
+ ): Promise<CompletedDelegationTranscriptAudit> => {
280
+ if (!sessionFile || !trustedRoot || !identity) {
281
+ return {
282
+ verified: false,
283
+ reason: 'completed response has no trusted child transcript identity',
284
+ };
285
+ }
286
+ try {
287
+ const tail = await readContainedSessionTail({
288
+ sessionFile,
289
+ trustedRoot,
290
+ identity,
291
+ dependencies,
292
+ });
293
+ if (!tail) {
294
+ return {
295
+ verified: false,
296
+ reason: 'completed child transcript is missing, unstable, or untrusted',
297
+ };
298
+ }
299
+ const parsed = parseFailureTranscript(tail.content);
300
+ const completion = parsed.successfulCompletions.at(-1);
301
+ if (!completion) {
302
+ return {
303
+ verified: false,
304
+ reason:
305
+ 'completed child transcript does not contain a successful structured_output result',
306
+ };
307
+ }
308
+ const warning = parsed.transcriptWarnings.find(
309
+ (candidate) => candidate.order > completion.order,
310
+ );
311
+ if (warning) return { verified: true, warning: warning.content };
312
+ if (
313
+ !parsed.hasValidFalsePositiveProof ||
314
+ completion.order !== parsed.lastInteractionOrder
315
+ ) {
316
+ return {
317
+ verified: false,
318
+ reason:
319
+ 'completed child transcript has malformed or later terminal interactions',
320
+ };
321
+ }
322
+ return { verified: true };
323
+ } catch {
324
+ return {
325
+ verified: false,
326
+ reason: 'completed child transcript could not be read safely',
327
+ };
328
+ }
329
+ };
330
+
232
331
  /**
233
332
  * Reads and audits replay safety from a confined child session file.
234
333
  *