@dieulc/workflow 0.10.0 → 0.11.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.
package/review-runtime.ts CHANGED
@@ -27,9 +27,11 @@ import * as path from "node:path";
27
27
  import {
28
28
  DEFAULT_REVIEW_MODE_CONFIG,
29
29
  buildReviewerAttempts,
30
+ classifyAssistantStop,
30
31
  createReviewRoundGate,
31
32
  createReviewSession,
32
33
  createReviewState,
34
+ isAbortStopText,
33
35
  planHash,
34
36
  readProviderErrorEvent,
35
37
  reviewNeedsVerification,
@@ -392,6 +394,31 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
392
394
  | { message: string; retries: number; at: number }
393
395
  | undefined;
394
396
 
397
+ /**
398
+ * Agent runs stopped by Review Mode's own abort (pass complete, pause,
399
+ * teardown). The SDK's setup path can report such a stop as
400
+ * `stopReason: "error"` with an abort-shaped message, so the subscriber
401
+ * pairs this expectation with `classifyAssistantStop` before suppressing it.
402
+ * Bounded by two anchors: reset before every pass and on every settle.
403
+ */
404
+ let expectedAbortEnds = 0;
405
+
406
+ /** Record that the next child run end was requested by this runtime. */
407
+ const noteExpectedAbort = (): void => {
408
+ expectedAbortEnds += 1;
409
+ };
410
+
411
+ /** Consume one pending self-abort expectation; false when none is pending. */
412
+ const consumeExpectedAbort = (): boolean => {
413
+ if (expectedAbortEnds <= 0) return false;
414
+ expectedAbortEnds -= 1;
415
+ return true;
416
+ };
417
+
418
+ const resetExpectedAborts = (): void => {
419
+ expectedAbortEnds = 0;
420
+ };
421
+
395
422
  /** Ordered plan chunks for the current pass; reset on submit/abort/switch. */
396
423
  let planAssembler:
397
424
  | { chunks: string[]; nextIndex: number; totalChunks?: number }
@@ -470,6 +497,8 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
470
497
  /** Drop the captured error when a new pass starts. */
471
498
  const clearProviderError = (): void => {
472
499
  lastProviderError = undefined;
500
+ // A pending self-abort expectation belongs to the run that already ended.
501
+ resetExpectedAborts();
473
502
  };
474
503
 
475
504
  const config = (): ReviewModeConfig => {
@@ -604,6 +633,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
604
633
  /** Abort the reviewer's in-flight run. Best-effort; never throws. */
605
634
  async function abortChild(): Promise<void> {
606
635
  try {
636
+ noteExpectedAbort();
607
637
  await session?.abort();
608
638
  } catch {
609
639
  /* cancellation is best-effort */
@@ -712,6 +742,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
712
742
  async function disposeSession(options: { keepRoundController?: boolean } = {}): Promise<void> {
713
743
  try {
714
744
  if (options.keepRoundController !== true) abortController?.abort();
745
+ noteExpectedAbort();
715
746
  await session?.abort();
716
747
  } catch {
717
748
  /* ignore */
@@ -920,44 +951,68 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
920
951
  ensureTranscript();
921
952
  const child = created.session;
922
953
  session.subscribe((event) => {
923
- let ops: ReviewTranscriptOp[] = [];
924
- try {
925
- ops = ensureTranscript().ingest(event);
926
- } catch {
927
- /* a malformed event must not break the review */
928
- }
929
- if (ops.length > 0) {
954
+ // D4: a round ends at `review_pass_done`/settle, but the child may
955
+ // still stream a closing tail. Once the round is inactive its events
956
+ // must never reach the main transcript (that tail used to appear
957
+ // while Build/Plan had already started).
958
+ if (!roundActive) return;
959
+ const type = readEventType(event);
960
+ // Classify BEFORE ingest/capture: a stop the runtime requested itself
961
+ // must never reach the transcript or the provider-error slot. The
962
+ // SDK can label that stop `error` with an abort-shaped message, so
963
+ // the expectation and the classification are both required.
964
+ const stop =
965
+ type === "agent_end"
966
+ ? classifyAssistantStop(
967
+ (event as { messages?: unknown } | undefined)?.messages,
968
+ )
969
+ : undefined;
970
+ // Consume on EVERY agent_end so a real error after a silent abort
971
+ // cannot leave a stale expectation behind.
972
+ const expectedAbort = type === "agent_end" && consumeExpectedAbort();
973
+ const selfAbort = expectedAbort && stop?.kind === "aborted";
974
+ if (!selfAbort) {
975
+ let ops: ReviewTranscriptOp[] = [];
930
976
  try {
931
- deps.onTranscriptOps?.(ops);
977
+ ops = ensureTranscript().ingest(event);
932
978
  } catch {
933
- /* streaming to the main transcript is display-only: never fatal */
979
+ /* a malformed event must not break the review */
934
980
  }
935
- }
936
- const type = readEventType(event);
937
- // Capture provider failures as they happen, so a pass that ends
938
- // without a submission can be diagnosed with the reviewer's real
939
- // API error instead of a generic "no plan" message (see runRound).
940
- try {
941
- const failure = readProviderErrorEvent(event);
942
- if (failure.kind === "retry") {
943
- noteProviderError(failure.message, failure.retries);
944
- } else if (failure.kind === "exhaust") {
945
- noteProviderError(
946
- failure.message ||
947
- lastProviderError?.message ||
948
- "provider error (no detail)",
949
- failure.retries || lastProviderError?.retries || 0,
950
- );
951
- } else if (failure.kind === "stop") {
952
- noteProviderError(
953
- failure.message,
954
- lastProviderError?.retries ?? 0,
955
- );
981
+ if (ops.length > 0) {
982
+ try {
983
+ deps.onTranscriptOps?.(ops);
984
+ } catch {
985
+ /* streaming to the main transcript is display-only: never fatal */
986
+ }
987
+ }
988
+ // Capture provider failures as they happen, so a pass that ends
989
+ // without a submission can be diagnosed with the reviewer's real
990
+ // API error instead of a generic "no plan" message (see runRound).
991
+ // `aborted` is deliberately not captured: it is our own cancel.
992
+ try {
993
+ const failure = readProviderErrorEvent(event);
994
+ if (failure.kind === "retry") {
995
+ noteProviderError(failure.message, failure.retries);
996
+ } else if (failure.kind === "exhaust") {
997
+ noteProviderError(
998
+ failure.message ||
999
+ lastProviderError?.message ||
1000
+ "provider error (no detail)",
1001
+ failure.retries || lastProviderError?.retries || 0,
1002
+ );
1003
+ } else if (failure.kind === "stop") {
1004
+ noteProviderError(
1005
+ failure.message,
1006
+ lastProviderError?.retries ?? 0,
1007
+ );
1008
+ }
1009
+ } catch {
1010
+ /* error capture is diagnostics only: never break the review */
956
1011
  }
957
- } catch {
958
- /* error capture is diagnostics only: never break the review */
959
1012
  }
960
1013
  if (type === "agent_settled") {
1014
+ // The abort (if any) has landed; no expectation may outlive it.
1015
+ resetExpectedAborts();
961
1016
  noteSettled();
962
1017
  } else if (type === "agent_end") {
963
1018
  // Fallback for a runtime without `agent_settled`: a retry or a
@@ -1248,6 +1303,21 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1248
1303
  return child.prompt(pass1Prompt);
1249
1304
  });
1250
1305
 
1306
+ // A round aborted by ESC / the host signal must take the abort path even
1307
+ // when the in-flight send rejected first: the rejection is a symptom of
1308
+ // the abort, not a provider/send failure to recover from.
1309
+ if (p1.stop === "aborted" || rc.signal.aborted) {
1310
+ await abortChild();
1311
+ if (state) state.phase = "aborted";
1312
+ return {
1313
+ ok: false,
1314
+ fallback: true,
1315
+ planText: p1.submitted?.planText ?? originalPlan,
1316
+ planHash: hash,
1317
+ state: state as ReviewState,
1318
+ reason: "aborted",
1319
+ };
1320
+ }
1251
1321
  if (p1.stop === "error") {
1252
1322
  await abortChild();
1253
1323
  const sendMessage = errorText(p1.error);
@@ -1279,18 +1349,6 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1279
1349
  }
1280
1350
  return fail(lastFailure);
1281
1351
  }
1282
- if (p1.stop === "aborted" || rc.signal.aborted) {
1283
- await abortChild();
1284
- if (state) state.phase = "aborted";
1285
- return {
1286
- ok: false,
1287
- fallback: true,
1288
- planText: p1.submitted?.planText ?? originalPlan,
1289
- planHash: hash,
1290
- state: state as ReviewState,
1291
- reason: "aborted",
1292
- };
1293
- }
1294
1352
  if (!p1.submitted) {
1295
1353
  // The run is over (or out of time) and nothing reached the plan file.
1296
1354
  await abortChild();
@@ -1352,6 +1410,13 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1352
1410
  cfg.passes > 1 &&
1353
1411
  reviewNeedsVerification(state?.findings ?? []);
1354
1412
 
1413
+ // Pass 1's protocol submission is final; stop a tail that is still
1414
+ // streaming before deciding whether pass 2 runs. This is what keeps the
1415
+ // reviewer quiescent when the handoff opens.
1416
+ if (!wantVerify && session?.isStreaming()) {
1417
+ await abortChild();
1418
+ }
1419
+
1355
1420
  if (wantVerify) {
1356
1421
  setPhase("verifying");
1357
1422
  await settleInFlight(deadline, rc);
@@ -1369,16 +1434,29 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1369
1434
  });
1370
1435
  // Name the provider when the verification pass died on one; pass 2
1371
1436
  // stays best-effort either way — pass 1's plan is never lost to it.
1372
- const p2Provider = lastProviderError
1373
- ? `provider error after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`
1374
- : undefined;
1437
+ // A round abort (or an abort-shaped stop) is not provider health.
1438
+ const p2Send = p2.error === undefined ? "" : errorText(p2.error);
1439
+ const p2Aborted =
1440
+ rc.signal.aborted ||
1441
+ isAbortStopText(p2Send) ||
1442
+ isAbortStopText(lastProviderError?.message);
1443
+ const p2Provider =
1444
+ !p2Aborted && lastProviderError
1445
+ ? `provider error after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`
1446
+ : undefined;
1375
1447
  if (p2.error !== undefined) {
1376
1448
  // Verification is an enhancement: never lose pass 1's plan over it.
1449
+ let failureText: string;
1450
+ if (p2Aborted) {
1451
+ failureText = "verification pass aborted";
1452
+ } else if (p2Provider) {
1453
+ failureText = `verification pass failed (${p2Provider}; send error: ${p2Send})`;
1454
+ } else {
1455
+ failureText = `verification pass failed (${p2Send})`;
1456
+ }
1377
1457
  deps.log(
1378
- p2Provider
1379
- ? `verification pass failed (${p2Provider}; send error: ${errorText(p2.error)}) — keeping pass 1's plan.`
1380
- : `verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
1381
- "warning",
1458
+ `${failureText} — keeping pass 1's plan.`,
1459
+ p2Aborted ? "info" : "warning",
1382
1460
  );
1383
1461
  } else if (p2.submitted) {
1384
1462
  finalPlan = p2.submitted.planText;
@@ -1427,8 +1505,19 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1427
1505
  } catch {
1428
1506
  /* listener cleanup is best-effort */
1429
1507
  }
1430
- abortController = undefined;
1508
+ // D4: no terminal path may return while the reviewer child is still
1509
+ // streaming. `review_pass_done` ends the protocol, not the SDK run; the
1510
+ // remaining tail could still call review tools or write the plan while
1511
+ // the parent has moved to Build/Plan. The submission is already captured.
1512
+ // Mark the round inactive FIRST: the abort's own `agent_end` is teardown
1513
+ // noise, not a run result, and must never reach the transcript.
1431
1514
  roundActive = false;
1515
+ try {
1516
+ if (session?.isStreaming()) await session.abort();
1517
+ } catch {
1518
+ /* cancellation is best-effort */
1519
+ }
1520
+ abortController = undefined;
1432
1521
  }
1433
1522
  }
1434
1523
 
@@ -1448,6 +1537,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1448
1537
  async abortRound() {
1449
1538
  try {
1450
1539
  abortController?.abort();
1540
+ noteExpectedAbort();
1451
1541
  await session?.abort();
1452
1542
  } catch {
1453
1543
  /* cancellation is best-effort */
package/review-tools.ts CHANGED
@@ -29,6 +29,16 @@ import {
29
29
  type ReviewFinding,
30
30
  } from "./utils.ts";
31
31
  import { explainCommandRefusal } from "./permissions.ts";
32
+ import { analyzeShellPaths } from "./shell-paths.ts";
33
+ import {
34
+ DEFAULT_FOLDER_POLICY_CONFIG,
35
+ detectRepoRoot,
36
+ evaluateFolderTargets,
37
+ matchSensitivePath,
38
+ type FolderPolicyConfig,
39
+ type FolderTarget,
40
+ } from "./folder-policy.ts";
41
+ import { redactSensitiveText } from "./read-policy-redact.ts";
32
42
  import type {
33
43
  PiCodingAgentSdk,
34
44
  ReviewPassResult,
@@ -145,6 +155,35 @@ export function createReviewBashTool(
145
155
  };
146
156
  }
147
157
 
158
+ const guard = reviewerReadGuard(deps.cwd);
159
+ const readPaths = analyzeShellPaths(command, deps.cwd).reads;
160
+ const readEval = evaluateFolderTargets(readPaths, {
161
+ config: guard.config,
162
+ grants: [],
163
+ cwd: deps.cwd,
164
+ lockedRoots: [guard.root],
165
+ action: "read",
166
+ });
167
+ if (readEval.sensitive.length > 0 || readEval.outside.length > 0) {
168
+ const target =
169
+ readEval.sensitive[0]?.path ??
170
+ readEval.outside[0]?.path ??
171
+ "the requested path";
172
+ deps.log(
173
+ `Review Mode refused a read outside the repository/sensitive guard: ${target}`,
174
+ "warning",
175
+ );
176
+ return {
177
+ content: [
178
+ {
179
+ type: "text",
180
+ text: `review_bash: REFUSED — \`${target}\` is outside the reviewer's permitted repository or is a sensitive file. Work only inside the repository; do not work around this.`,
181
+ },
182
+ ],
183
+ isError: true,
184
+ };
185
+ }
186
+
148
187
  try {
149
188
  const result = (await inner.execute(
150
189
  toolCallId,
@@ -153,7 +192,7 @@ export function createReviewBashTool(
153
192
  onUpdate,
154
193
  ctx,
155
194
  )) as ReviewToolTextResult;
156
- return result;
195
+ return redactReviewResult(result, "bash", rawParams, deps.cwd, guard.config);
157
196
  } catch (e: unknown) {
158
197
  return {
159
198
  content: [
@@ -1015,6 +1054,128 @@ export function createReviewPassDoneTool(deps: ReviewPassDoneDeps): {
1015
1054
  };
1016
1055
  }
1017
1056
 
1057
+ interface ReviewerReadGuard {
1058
+ root: string;
1059
+ config: FolderPolicyConfig;
1060
+ }
1061
+
1062
+ function reviewerReadGuard(cwd: string): ReviewerReadGuard {
1063
+ return {
1064
+ root: detectRepoRoot(cwd) ?? cwd,
1065
+ config: {
1066
+ ...DEFAULT_FOLDER_POLICY_CONFIG,
1067
+ mode: "cwd",
1068
+ outside: "block",
1069
+ },
1070
+ };
1071
+ }
1072
+
1073
+ function reviewerRefusal(target: FolderTarget): ReviewToolTextResult {
1074
+ return {
1075
+ content: [
1076
+ {
1077
+ type: "text",
1078
+ text: `review read: REFUSED — \`${target.path}\` is outside the reviewer's repository or is a sensitive file. Work inside the repository; sensitive files stay blocked.`,
1079
+ },
1080
+ ],
1081
+ isError: true,
1082
+ };
1083
+ }
1084
+
1085
+ function guardReviewerTarget(
1086
+ tool: string,
1087
+ input: Record<string, unknown>,
1088
+ guard: ReviewerReadGuard,
1089
+ cwd: string,
1090
+ ): ReviewToolTextResult | undefined {
1091
+ const raw = input.path;
1092
+ const value =
1093
+ typeof raw === "string" && raw.trim()
1094
+ ? raw
1095
+ : tool === "read"
1096
+ ? ""
1097
+ : cwd;
1098
+ if (!value) {
1099
+ return {
1100
+ content: [{ type: "text", text: `review ${tool}: missing path` }],
1101
+ isError: true,
1102
+ };
1103
+ }
1104
+ const target: FolderTarget = {
1105
+ path: value,
1106
+ kind: tool === "read" ? "file" : "dir",
1107
+ };
1108
+ const hit = matchSensitivePath(value, guard.config, cwd);
1109
+ const evaluation = evaluateFolderTargets([target], {
1110
+ config: guard.config,
1111
+ grants: [],
1112
+ cwd,
1113
+ lockedRoots: [guard.root],
1114
+ action: "read",
1115
+ });
1116
+ if (hit.sensitive || evaluation.sensitive.length > 0)
1117
+ return reviewerRefusal(target);
1118
+ if (evaluation.outside.length > 0) return reviewerRefusal(target);
1119
+ return undefined;
1120
+ }
1121
+
1122
+ function redactReviewResult(
1123
+ result: ReviewToolTextResult,
1124
+ tool: string,
1125
+ input: Record<string, unknown>,
1126
+ cwd: string,
1127
+ config: FolderPolicyConfig,
1128
+ ): ReviewToolTextResult {
1129
+ let redacted = false;
1130
+ const content = (result.content ?? []).map((block) => {
1131
+ if (block?.type !== "text") return block;
1132
+ const next = redactSensitiveText(block.text, tool, input, cwd, config);
1133
+ if (next.redacted) redacted = true;
1134
+ return next.redacted ? { ...block, text: next.text } : block;
1135
+ });
1136
+ if (!redacted) return result;
1137
+ return { ...result, content };
1138
+ }
1139
+
1140
+ function createGuardedBuiltinTools(
1141
+ sdk: PiCodingAgentSdk | undefined,
1142
+ cwd: string,
1143
+ ): { tools: unknown[]; unavailable: string[] } {
1144
+ const tools: unknown[] = [];
1145
+ const unavailable: string[] = [];
1146
+ const guard = reviewerReadGuard(cwd);
1147
+ const builders: Array<[string, string, unknown]> = [
1148
+ ["read", "read", (sdk as any)?.createReadToolDefinition],
1149
+ ["grep", "grep", (sdk as any)?.createGrepToolDefinition],
1150
+ ["find", "find", (sdk as any)?.createFindToolDefinition],
1151
+ ["ls", "ls", (sdk as any)?.createLsToolDefinition],
1152
+ ];
1153
+ for (const [name, tool, builder] of builders) {
1154
+ if (typeof builder !== "function") {
1155
+ unavailable.push(`${name} (guarded builder unavailable)`);
1156
+ continue;
1157
+ }
1158
+ const definition = builder(cwd) as {
1159
+ name: string;
1160
+ execute: (...args: unknown[]) => Promise<unknown>;
1161
+ };
1162
+ tools.push({
1163
+ ...definition,
1164
+ name,
1165
+ async execute(...args: unknown[]) {
1166
+ const input = (args[1] ?? {}) as Record<string, unknown>;
1167
+ const refusal = guardReviewerTarget(tool, input, guard, cwd);
1168
+ if (refusal) return refusal;
1169
+ const result = (await definition.execute(
1170
+ ...args,
1171
+ )) as ReviewToolTextResult;
1172
+ return redactReviewResult(result, tool, input, cwd, guard.config);
1173
+ },
1174
+ });
1175
+ }
1176
+ return { tools, unavailable };
1177
+ }
1178
+
1018
1179
  /**
1019
1180
  * Build every tool the reviewer may use.
1020
1181
  */
@@ -1032,6 +1193,13 @@ export function createReviewTools(
1032
1193
  unavailable.push("review_bash (createBashToolDefinition unavailable)");
1033
1194
  }
1034
1195
 
1196
+ // The reviewer session has NO ambient extensions, so its built-in
1197
+ // read/grep/find/ls are otherwise a complete bypass of the read gate.
1198
+ // Custom tools with those names override the built-ins.
1199
+ const guarded = createGuardedBuiltinTools(sdk, deps.cwd);
1200
+ tools.push(...guarded.tools);
1201
+ unavailable.push(...guarded.unavailable);
1202
+
1035
1203
  tools.push(createReviewExploreTool(deps.explore));
1036
1204
  tools.push(createReviewPlanBeginTool(deps.submit));
1037
1205
  tools.push(createReviewPlanChunkTool(deps.submit));