gentle-pi 3.2.1 → 3.4.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 (108) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -440,6 +440,67 @@ test("capabilities/v2.5 negotiates the v2.6.0 advertisement and status/v7 decode
440
440
  assert.throws(() => decodeReviewStatusV3(v6WithDigest), /eligible_untracked_inventory/);
441
441
  });
442
442
 
443
+ test("capabilities/v2.6 negotiates the v3.4.0 advertisement on the same v2.5 requirement floor", () => {
444
+ // gentle-ai v3.4.0 advertises capabilities/v2.6: the v2.5 surface plus
445
+ // status/v8 (status/v6 and status/v7 stay advertised for compatibility).
446
+ // The `review assess` review_due/review_due_reason/next_transition
447
+ // additions are a separate schema (gentle-ai.review-assessment/v1),
448
+ // unrelated to this negotiated capabilities surface.
449
+ const v26 = clone(fixture(DEV_FIXTURES, "capabilities-v2.2.captured.json") as JsonObject);
450
+ v26.schema = "gentle-ai.review-integration.capabilities/v2.6";
451
+ (v26.protocol as JsonObject).minor = 6;
452
+ const features = v26.features as JsonObject;
453
+ features.mandatory = (features.mandatory as JsonObject[]).filter((feature) =>
454
+ !["exact_receipt_replay", "five_delivery_gates", "sdd_receipt_binding"].includes(feature.name as string));
455
+ v26.schemas = [
456
+ ...(v26.schemas as string[]).map((schema) => schema
457
+ .replace("capabilities/v2.2", "capabilities/v2.6")
458
+ .replace("start/v3", "start/v4")
459
+ .replace("status/v5", "status/v6")),
460
+ "gentle-ai.review-intended-untracked-selection/v1",
461
+ "gentle-ai.review-integration.status/v7",
462
+ "gentle-ai.review-integration.status/v8",
463
+ ];
464
+ const decoded = decodeReviewCapabilitiesV2(v26, CAPTURED_DIGEST);
465
+ assert.equal(decoded.schemas.has("gentle-ai.review-integration.status/v6"), true);
466
+ // The negotiated set is the v2.5 requirement floor unchanged; status/v7
467
+ // and status/v8 are additive and never required, so an advertisement
468
+ // without either still negotiates.
469
+ const withoutV7V8 = clone(v26);
470
+ withoutV7V8.schemas = (withoutV7V8.schemas as string[]).filter((schema) => !["gentle-ai.review-integration.status/v7", "gentle-ai.review-integration.status/v8"].includes(schema as string));
471
+ assert.equal(decodeReviewCapabilitiesV2(withoutV7V8, CAPTURED_DIGEST).schemas.has("gentle-ai.review-integration.status/v6"), true);
472
+
473
+ // status/v6 stays required: v7/v8 are additive extensions, not a replacement.
474
+ const missingStatusV6 = clone(v26);
475
+ missingStatusV6.schemas = (missingStatusV6.schemas as string[]).filter((schema) => schema !== "gentle-ai.review-integration.status/v6");
476
+ assert.throws(() => decodeReviewCapabilitiesV2(missingStatusV6, CAPTURED_DIGEST), /status\/v6/);
477
+ });
478
+
479
+ // status/v8 (gentle-ai main, PR #4765; the current released contract) only
480
+ // extended the reviewer-result transition for OpenCode provider tasks -- no
481
+ // new top-level key -- so it decodes on the exact v7 surface: same optional
482
+ // eligible_untracked_inventory digest, same rejection of a v6 envelope
483
+ // carrying it. status/v9 (the sibling gentle-ai branch's contract, ahead of
484
+ // the released v8) adds nothing new at the top level either; its one
485
+ // addition -- the host-mediated role submission -- lives in
486
+ // next_transition.collect.inputs and is covered by
487
+ // tests/review-integration-v2.test.ts's "v9 host-mediated" tests.
488
+ test("status/v8 and status/v9 decode with their exact identity on the v7 surface", () => {
489
+ const v8 = initialIntendedUntrackedStatusV6();
490
+ v8.schema = "gentle-ai.review-integration.status/v8";
491
+ v8.eligible_untracked_inventory = sha("e");
492
+ const decodedV8 = decodeReviewStatusV3(v8);
493
+ assert.equal(decodedV8.raw.schema, "gentle-ai.review-integration.status/v8");
494
+ assert.equal(decodedV8.eligibleUntrackedInventory, sha("e"));
495
+
496
+ const v9 = initialIntendedUntrackedStatusV6();
497
+ v9.schema = "gentle-ai.review-integration.status/v9";
498
+ v9.eligible_untracked_inventory = sha("e");
499
+ const decodedV9 = decodeReviewStatusV3(v9);
500
+ assert.equal(decodedV9.raw.schema, "gentle-ai.review-integration.status/v9");
501
+ assert.equal(decodedV9.eligibleUntrackedInventory, sha("e"));
502
+ });
503
+
443
504
  test("status/v6 decodes and enforces the intended-untracked selection submission", () => {
444
505
  const decoded = decodeReviewStatusV3(initialIntendedUntrackedStatusV6());
445
506
  const input = decoded.nextTransition?.collect?.inputs[0];
@@ -14,6 +14,7 @@ import {
14
14
  decodeReviewProjectionV1,
15
15
  decodeReviewRepairV2,
16
16
  decodeReviewStartV3,
17
+ decodeReviewStartV4,
17
18
  decodeReviewStatusV3,
18
19
  } from "../lib/review-integration-v2.ts";
19
20
 
@@ -349,6 +350,35 @@ test("capabilities gates and projections still enforce the required floor", () =
349
350
  assert.throws(() => decode(missingProjection), /projections/);
350
351
  });
351
352
 
353
+ test("START/v4 preserves provider-generated manifest evidence while retaining exact omitempty validation", () => {
354
+ const source = fixture<JsonObject>("start.fixture.json");
355
+ source.schema = "gentle-ai.review-integration.start/v4";
356
+ source.action = "closed";
357
+ source.lenses_required = false;
358
+ source.state = "approved";
359
+ source.selected_lenses = [];
360
+ delete source.repository_context;
361
+ const entry = (source.changed_path_manifest as JsonObject[])[0];
362
+ entry.generated = true;
363
+
364
+ const decoded = decodeReviewStartV4(source);
365
+ assert.equal(decoded.changedPathManifest?.[0]?.generated, true);
366
+
367
+ const omitted = clone(source);
368
+ delete (omitted.changed_path_manifest as JsonObject[])[0].generated;
369
+ assert.doesNotThrow(() => decodeReviewStartV4(omitted));
370
+
371
+ for (const generated of [false, "true"] as const) {
372
+ const invalid = clone(source);
373
+ ((invalid.changed_path_manifest as JsonObject[])[0]).generated = generated;
374
+ assert.throws(() => decodeReviewStartV4(invalid), /generated/);
375
+ }
376
+
377
+ const unknown = clone(source);
378
+ ((unknown.changed_path_manifest as JsonObject[])[0]).unrelated = true;
379
+ assert.throws(() => decodeReviewStartV4(unknown), /not allowed/);
380
+ });
381
+
352
382
  test("START independently binds base/candidate tree and the target-mode overlay pair", () => {
353
383
  const source = fixture<JsonObject>("start.fixture.json");
354
384
  const decoded = decodeReviewStartV3(source);
@@ -818,11 +848,14 @@ test("next_transition decodes the self-contained provider role capture vectors s
818
848
  () => decodeRoleTransition(roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/reviewer/v1")),
819
849
  /schema must be https:\/\/gentle-ai\.dev\/schema\/review\/refuter\/v1/,
820
850
  );
851
+ // This fixture decodes on the v5 surface only (no v9): a submission
852
+ // descriptor on a role input is rejected for lacking the v9 provider
853
+ // contract, before the materialize/execute discriminator is even read.
821
854
  assert.throws(
822
855
  () => decodeRoleTransition(roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/refuter/v1", {
823
856
  submission: { operation_token: "capture-refuter", argument_tokens: ["--input={{value}}"], values: [{ slot: "{{value}}", domain: "artifact-path", substitution_location: 0 }] },
824
857
  })),
825
- /submission is not allowed on the self-contained/,
858
+ /submission requires the v9 provider contract/,
826
859
  );
827
860
 
828
861
  const validator = roleInput("provider_targeted_validator", "review.capture-validation", "https://gentle-ai.dev/schema/review/validator/v1");
@@ -837,6 +870,87 @@ test("next_transition decodes the self-contained provider role capture vectors s
837
870
  );
838
871
  });
839
872
 
873
+ // gentle-ai's v9 contract makes the same two role operations host-mediated
874
+ // exactly like a lens materialize slot: the input carries a submission
875
+ // descriptor alongside --materialize=true (never --execute), and the host
876
+ // completes the frozen prompt in-process and submits through that
877
+ // descriptor instead of executing a Go-owned pi subprocess.
878
+ test("next_transition decodes the v9 host-mediated provider role submission form", () => {
879
+ const materializeArguments = [
880
+ { name: "lineage", value: "review-fixture", token: "--lineage=review-fixture" },
881
+ { name: "expected-revision", value: digest, token: `--expected-revision=${digest}` },
882
+ { name: "target", value: digest, token: `--target=${digest}` },
883
+ { name: "repository-context", value: `rctx1_${"c".repeat(64)}`, token: `--repository-context=rctx1_${"c".repeat(64)}` },
884
+ { name: "agent", value: "pi", token: "--agent=pi" },
885
+ { name: "materialize", value: "true", token: "--materialize=true" },
886
+ ];
887
+ const roleInput = (name: string, captureOperation: string, schema: string, extra: Record<string, unknown> = {}) => ({
888
+ kind: "collect",
889
+ reason_code: "provider_refuter_required",
890
+ collect: { inputs: [{ name, schema, capture_operation: captureOperation, arguments: materializeArguments, ...extra }] },
891
+ });
892
+ // This decoder path is gated on v9 specifically -- the sibling gentle-ai
893
+ // branch's contract, ahead of the currently-released v8 -- not on v5,
894
+ // which v8 (and v6/v7) already satisfy.
895
+ const decodeRoleTransition = (value: unknown) => decodeReviewNextTransitionV3(value, { v9: true });
896
+ const refuterSubmission = {
897
+ operation_token: "capture-refuter",
898
+ argument_tokens: [...materializeArguments.map((argument) => argument.token), "--input={{value}}"],
899
+ value: { slot: "provider_refuter", domain: "artifact_path_or_stdin", schema: "https://gentle-ai.dev/schema/review/refuter/v1", substitution_location: materializeArguments.length },
900
+ };
901
+
902
+ const refuter = roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/refuter/v1", { submission: refuterSubmission });
903
+ const decodedSubmission = decodeRoleTransition(refuter).collect?.inputs[0]?.submission;
904
+ assert.equal(decodedSubmission?.operationToken, "capture-refuter");
905
+ assert.equal(decodedSubmission?.argumentTokens.length, materializeArguments.length + 1);
906
+ assert.deepEqual(decodedSubmission?.values, [{
907
+ slot: "provider_refuter",
908
+ domain: "artifact_path_or_stdin",
909
+ schema: "https://gentle-ai.dev/schema/review/refuter/v1",
910
+ substitutionLocation: materializeArguments.length,
911
+ }]);
912
+
913
+ // The exact same payload under v8 (or any earlier non-v9 rung) is
914
+ // rejected: v8 only extended the reviewer-result transition for OpenCode
915
+ // provider tasks and never renders a role submission descriptor.
916
+ assert.throws(
917
+ () => decodeReviewNextTransitionV3(refuter, { v6: true }),
918
+ /submission requires the v9 provider contract/,
919
+ );
920
+
921
+ // A submission descriptor with no --materialize=true (and no --execute
922
+ // either) matches neither wire form.
923
+ assert.throws(
924
+ () => decodeRoleTransition(roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/refuter/v1", {
925
+ submission: refuterSubmission,
926
+ arguments: materializeArguments.filter((argument) => argument.name !== "materialize"),
927
+ })),
928
+ /submission requires --materialize=true/,
929
+ );
930
+
931
+ // --execute alongside a submission descriptor mixes both wire forms on
932
+ // one input, which is never valid.
933
+ assert.throws(
934
+ () => decodeRoleTransition(roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/refuter/v1", {
935
+ submission: refuterSubmission,
936
+ arguments: [...materializeArguments, { name: "execute", value: "true", token: "--execute=true" }],
937
+ })),
938
+ /must not carry --execute alongside a submission descriptor/,
939
+ );
940
+
941
+ // The targeted-validator's host-mediated form still requires its
942
+ // validation_request, exactly like the self-contained vector.
943
+ const validatorSubmission = {
944
+ operation_token: "capture-validation",
945
+ argument_tokens: [...materializeArguments.map((argument) => argument.token), "--input={{value}}"],
946
+ value: { slot: "provider_targeted_validator", domain: "artifact_path_or_stdin", schema: "https://gentle-ai.dev/schema/review/validator/v1", substitution_location: materializeArguments.length },
947
+ };
948
+ assert.throws(
949
+ () => decodeRoleTransition(roleInput("provider_targeted_validator", "review.capture-validation", "https://gentle-ai.dev/schema/review/validator/v1", { submission: validatorSubmission })),
950
+ /validation_request is required/,
951
+ );
952
+ });
953
+
840
954
  test("v5 targeted-validator collect inputs carry the exact provider-owned validation request", () => {
841
955
  const requestHash = `sha256:${"a".repeat(64)}`;
842
956
  const expectedRevision = `sha256:${"b".repeat(64)}`;
@@ -913,6 +1027,37 @@ test("v5 targeted-validator collect inputs carry the exact provider-owned valida
913
1027
  }]);
914
1028
  assert.deepEqual(decoded.collect?.inputs[0]?.arguments, input.arguments, "provider-rendered arguments must remain unchanged");
915
1029
 
1030
+ // The same validation_request rides the v9 host-mediated form too: swap
1031
+ // --execute=true for --materialize=true and add the provider-owned
1032
+ // submission descriptor.
1033
+ const materializeArguments = input.arguments.map((argument) => argument.name === "execute" ? { name: "materialize", value: "true", token: "--materialize=true" } : argument);
1034
+ const hostMediatedInput = {
1035
+ ...input,
1036
+ arguments: materializeArguments,
1037
+ submission: {
1038
+ operation_token: "capture-validation",
1039
+ argument_tokens: [...materializeArguments.map((argument) => argument.token), "--input={{value}}"],
1040
+ value: { slot: "provider_targeted_validator", domain: "artifact_path_or_stdin", schema: "https://gentle-ai.dev/schema/review/validator/v1", substitution_location: materializeArguments.length },
1041
+ },
1042
+ };
1043
+ const hostMediatedTransition = { kind: "collect" as const, reason_code: "targeted_validation_required", collect: { inputs: [hostMediatedInput] } };
1044
+ const decodedHostMediated = decodeReviewNextTransitionV3(hostMediatedTransition, { v9: true });
1045
+ assert.equal(decodedHostMediated.collect?.inputs[0]?.submission?.operationToken, "capture-validation");
1046
+ assert.deepEqual(decodedHostMediated.collect?.inputs[0]?.submission?.values, [{
1047
+ slot: "provider_targeted_validator",
1048
+ domain: "artifact_path_or_stdin",
1049
+ schema: "https://gentle-ai.dev/schema/review/validator/v1",
1050
+ substitutionLocation: materializeArguments.length,
1051
+ }]);
1052
+ assert.equal(decodedHostMediated.collect?.inputs[0]?.validationRequest?.requestHash, requestHash);
1053
+
1054
+ // The identical v8 surface (v6/v7/v8 all decode collect inputs alike)
1055
+ // rejects the same host-mediated submission: only v9 renders it.
1056
+ assert.throws(
1057
+ () => decodeReviewNextTransitionV3(hostMediatedTransition, { v6: true }),
1058
+ /submission requires the v9 provider contract/,
1059
+ );
1060
+
916
1061
  const missingRequest = clone(transition);
917
1062
  delete (missingRequest.collect.inputs[0] as JsonObject).validation_request;
918
1063
  assert.throws(() => decodeReviewNextTransitionV3(missingRequest, { v5: true }), /validation_request is required/);
@@ -22,7 +22,6 @@ const README = "README.md";
22
22
  const TECHNICAL_REFERENCE = "docs/readme-reference.md";
23
23
  const CHAIN = "assets/chains/4r-review.chain.md";
24
24
  const SDD_WORKFLOW = "assets/sdd-orchestrator-workflow.md";
25
- const RELEASE_SKILL = "skills/release/SKILL.md";
26
25
  const WORKER = "assets/agents/gentle-ai-worker.md";
27
26
  const CANONICAL_LIFECYCLE_SPECS = [
28
27
  "openspec/specs/review-orchestration/spec.md",
@@ -400,7 +399,7 @@ test("technical reference documents the dynamic runtime authority boundary witho
400
399
  });
401
400
 
402
401
  test("managed contracts retain no fresh lifecycle review directive", () => {
403
- const managed = union([...ORCHESTRATOR, SDD_WORKFLOW, RELEASE_SKILL, WORKER, GENTLE_SKILL, README]);
402
+ const managed = union([...ORCHESTRATOR, SDD_WORKFLOW, WORKER, GENTLE_SKILL, README]);
404
403
  for (const obsolete of [
405
404
  "A fresh review still follows delegated implementation.",
406
405
  "run a fresh-context review lens unless",
@@ -2,13 +2,13 @@ import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
- import { delimiter as pathDelimiter, join } from "node:path";
5
+ import { join } from "node:path";
6
6
  import test from "node:test";
7
7
  import { __testing } from "../extensions/gentle-ai.ts";
8
8
  import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError } from "../lib/review-host-relay.ts";
9
9
  import { NativeReviewIntegrationError, type NativeReviewCli } from "../lib/native-review-cli.ts";
10
10
  import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
11
- import type { ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
11
+ import type { ReviewArtifactSubjectV2, ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
12
12
  import type { ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
13
13
 
14
14
  // Third field failure on the recovered-lineage defect (2026-08-16, gentle-pi
@@ -149,10 +149,11 @@ function transportAwareNative(options: { refusalCode?: string } = {}): {
149
149
  return { native, agents };
150
150
  }
151
151
 
152
- async function runCapture(cwd: string, native: NativeReviewCli, lineageId: string, input: Record<string, unknown> = { reviewerRunAcknowledged: true }): Promise<Record<string, unknown>> {
152
+ async function runCapture(cwd: string, native: NativeReviewCli, lineageId: string, input: Record<string, unknown> = { reviewerRunAcknowledged: true }, reviewerSessionId?: string): Promise<Record<string, unknown>> {
153
153
  return await __testing.executeReviewCaptureOperation(
154
154
  { lineageId, collectBinding: JSON.stringify(collectInput(lineageId, true)), ...input },
155
155
  cwd, native, undefined, new CandidateViewRegistry(),
156
+ undefined, undefined, undefined, reviewerSessionId,
156
157
  ) as Record<string, unknown>;
157
158
  }
158
159
 
@@ -187,29 +188,21 @@ test("the negotiated status asks for the pi agent so the provider offers its mat
187
188
  assert.equal(hostRelay.transport, "pi_host_relay");
188
189
  });
189
190
 
190
- // gentle-shell#1136 / #1158: the lens's user-owned reviewer selection (agent
191
- // model routing config) and the extension allowlist environment ride the relay
192
- // request; without them the child runs the ambient default with no auth
193
- // adapters. With neither configured the launch stays selection-free.
194
- test("capture forwards the lens's user-owned reviewer selection and extension allowlist to the relay request", async (t) => {
191
+ // gentle-pi#311 P2 (superseding gentle-shell#1136 / #1158): the lens's
192
+ // user-owned completion selection (agent model routing config) rides the
193
+ // relay request alongside the live model registry; there is no extension
194
+ // allowlist and no ambient default model to fall back to.
195
+ test("capture forwards the lens's user-owned selection and thinking level to the relay request", async (t) => {
195
196
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
196
197
  const configHome = mkdtempSync(join(tmpdir(), "gentle-pi-relay-config-"));
197
198
  const cwd = repository(t);
198
199
  t.after(() => rmSync(configHome, { recursive: true, force: true }));
199
- writeFileSync(join(configHome, "models.json"), JSON.stringify({ "review-reliability": { model: "minimax/MiniMax-M3" } }), "utf8");
200
- const adapter = join(configHome, "auth-adapter.ts");
201
- const second = join(configHome, "second-adapter.ts");
202
- writeFileSync(adapter, "export default () => {};");
203
- writeFileSync(second, "export default () => {};");
200
+ writeFileSync(join(configHome, "models.json"), JSON.stringify({ "review-reliability": { model: "minimax/MiniMax-M3", thinking: "high" } }), "utf8");
204
201
  const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
205
- const previousExtensions = process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS;
206
202
  process.env.GENTLE_PI_CONFIG_HOME = configHome;
207
- process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS = [adapter, second].join(pathDelimiter);
208
203
  t.after(() => {
209
204
  if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
210
205
  else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
211
- if (previousExtensions === undefined) delete process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS;
212
- else process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS = previousExtensions;
213
206
  });
214
207
  const { native } = transportAwareNative();
215
208
  const relayed: ReviewHostRelayRequest[] = [];
@@ -220,24 +213,134 @@ test("capture forwards the lens's user-owned reviewer selection and extension al
220
213
 
221
214
  await runCapture(cwd, native, "selection-lineage");
222
215
  assert.equal(relayed.length, 1);
223
- assert.equal(relayed[0]!.reviewerModel, "minimax/MiniMax-M3", "the lens's routing entry must name the child's selection");
224
- assert.deepEqual(relayed[0]!.reviewerExtensionPaths, [adapter, second]);
216
+ assert.equal(relayed[0]!.selection, "minimax/MiniMax-M3", "the lens's routing entry must name the completion's selection");
217
+ assert.equal(relayed[0]!.thinking, "high");
218
+ assert.equal(relayed[0]!.routingKey, "review-reliability");
225
219
  });
226
220
 
227
- test("capture keeps the relay launch selection-free when the user configured neither a selection nor extensions", async (t) => {
221
+ // The live session id threads from the extension context through both
222
+ // request builders, so an OpenCode-routed reviewer model carries its
223
+ // attribution headers (a side-call bypasses pi's main agent loop, where pi
224
+ // adds them itself). A absent session id adds nothing and is never an error.
225
+ test("capture threads the caller's live session id into the relay request", async (t) => {
226
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
227
+ const cwd = repository(t);
228
+ const { native } = transportAwareNative();
229
+ const relayed: ReviewHostRelayRequest[] = [];
230
+ __testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
231
+ relayed.push(request);
232
+ return { promptByteLength: 128, resultByteLength: 64, submission: '{"admission_decision":"completed"}' };
233
+ });
234
+
235
+ await runCapture(cwd, native, "session-lineage", { reviewerRunAcknowledged: true }, "ses-live-1");
236
+ assert.equal(relayed.length, 1);
237
+ assert.equal(relayed[0]!.reviewerSessionId, "ses-live-1");
238
+ });
239
+
240
+ test("capture with no live session id adds no reviewer session id to the relay request", async (t) => {
241
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
242
+ const cwd = repository(t);
243
+ const { native } = transportAwareNative();
244
+ const relayed: ReviewHostRelayRequest[] = [];
245
+ __testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
246
+ relayed.push(request);
247
+ return { promptByteLength: 128, resultByteLength: 64, submission: '{"admission_decision":"completed"}' };
248
+ });
249
+
250
+ await runCapture(cwd, native, "no-session-lineage");
251
+ assert.equal(relayed.length, 1);
252
+ assert.equal(relayed[0]!.reviewerSessionId, undefined);
253
+ });
254
+
255
+ function groupInput(lineageId: string, lens: string, order: number): ReviewCollectInputV3 {
256
+ const subjectHash = `sha256:${String(order + 1).repeat(64)}`;
257
+ const base = collectInput(lineageId, true);
258
+ const arguments_ = base.arguments.map((argument) => {
259
+ if (argument.name === "lens") return { ...argument, value: lens, token: `--lens=${lens}` };
260
+ if (argument.name === "order") return { ...argument, value: String(order), token: `--order=${order}` };
261
+ if (argument.name === "subject-hash") return { ...argument, value: subjectHash, token: `--subject-hash=${subjectHash}` };
262
+ return argument;
263
+ });
264
+ return { ...base, arguments: arguments_, artifactSubject: { ...base.artifactSubject, subjectHash, lens: lens as ReviewArtifactSubjectV2["lens"], selectedOrder: order } };
265
+ }
266
+
267
+ function groupStatus(lineageId: string, inputs: readonly ReviewCollectInputV3[]): ReviewStatusV3 {
268
+ const status = recoveredStatus(lineageId, false) as ReviewStatusV3 & { repositoryContext?: Record<string, unknown>; nextTransition?: { kind: string; reasonCode: string; collect: { inputs: ReviewCollectInputV3[] } } };
269
+ // The reviewer group binds one repository context per slot; reuse the same
270
+ // opaque handle shape the finalizeStatus fixtures in the routing tests use.
271
+ status.repositoryContext = { capability: "review.opaque_repository_context", handle: `rctx1_${"e".repeat(64)}`, revision: SHA, targetIdentity: SHA };
272
+ status.nextTransition = { kind: "collect", reasonCode: "reviewer_results_required", collect: { inputs: [...inputs] } };
273
+ return status;
274
+ }
275
+
276
+ function queueNative(statuses: readonly ReviewStatusV3[]): { native: NativeReviewCli; agents: Array<string | undefined> } {
277
+ const agents: Array<string | undefined> = [];
278
+ const queue = [...statuses];
279
+ const native = {
280
+ targetStatus: async (request: { agent?: string }) => {
281
+ agents.push(request.agent);
282
+ const next = queue.shift();
283
+ if (next === undefined) throw new Error("status queue exhausted");
284
+ return next;
285
+ },
286
+ } as unknown as NativeReviewCli;
287
+ return { native, agents };
288
+ }
289
+
290
+ function prepared(request: ReviewHostRelayRequest) {
291
+ return { request, promptByteLength: 64, resultByteLength: 32 };
292
+ }
293
+
294
+ test("capture group threads the caller's live session id into every relay request", async (t) => {
295
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
296
+ const cwd = repository(t);
297
+ const lineageId = "group-session-lineage";
298
+ const inputs = [groupInput(lineageId, "review-risk", 0), groupInput(lineageId, "review-resilience", 1)];
299
+ // The final reconciliation STATUS must no longer offer the submitted
300
+ // reviewer suffix; a STATUS without a collect transition reconciles the
301
+ // drained group instead of replaying the submitted set.
302
+ const finalStatus = groupStatus(lineageId, []) as ReviewStatusV3 & { nextTransition?: { kind: string; reasonCode: string; collect: { inputs: ReviewCollectInputV3[] } } };
303
+ delete finalStatus.nextTransition;
304
+ const { native } = queueNative([groupStatus(lineageId, inputs), groupStatus(lineageId, inputs), groupStatus(lineageId, inputs.slice(1)), finalStatus]);
305
+ const relayed: ReviewHostRelayRequest[] = [];
306
+ __testing.setReviewHostRelayGroupRunnersForTesting(
307
+ async (requests: readonly ReviewHostRelayRequest[]) => {
308
+ relayed.push(...requests);
309
+ return requests.map(prepared);
310
+ },
311
+ async (result: { promptByteLength: number; resultByteLength: number }) => ({ promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" }),
312
+ );
313
+
314
+ const result = await __testing.executeReviewCaptureGroupOperation(
315
+ { lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged: true },
316
+ cwd, native, undefined, new CandidateViewRegistry(),
317
+ undefined, undefined, undefined, "ses-live-1",
318
+ ) as Record<string, unknown>;
319
+
320
+ assert.equal(relayed.length, 2);
321
+ assert.ok(relayed.every((request) => request.reviewerSessionId === "ses-live-1"), "every grouped reviewer request must carry the live session id");
322
+ assert.equal(result.outcome, "native-reviewer-group-status-reconciled");
323
+ });
324
+
325
+ // gentle-pi#311 P2 decision (flagged for confirmation): the in-process path
326
+ // has no ambient default model. A routing entry with no configured model used
327
+ // to launch the child selection-free (inheriting pi's own default); the real
328
+ // relay now refuses it typed instead, since there is no child to inherit a
329
+ // default from (lib/review-host-relay.ts's own tests exercise that refusal
330
+ // through the real, unfaked runner). This extension layer only forwards
331
+ // whatever the routing config yields — it never validates the selection
332
+ // itself — so with the runner faked here the request still reaches it, and
333
+ // its `selection` field is simply absent.
334
+ test("capture forwards no selection when the lens has no configured model, leaving the missing-model refusal to the relay", async (t) => {
228
335
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
229
336
  const configHome = mkdtempSync(join(tmpdir(), "gentle-pi-relay-config-empty-"));
230
337
  const cwd = repository(t);
231
338
  t.after(() => rmSync(configHome, { recursive: true, force: true }));
232
339
  const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
233
- const previousExtensions = process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS;
234
340
  process.env.GENTLE_PI_CONFIG_HOME = configHome;
235
- delete process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS;
236
341
  t.after(() => {
237
342
  if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
238
343
  else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
239
- if (previousExtensions === undefined) delete process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS;
240
- else process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS = previousExtensions;
241
344
  });
242
345
  const { native } = transportAwareNative();
243
346
  const relayed: ReviewHostRelayRequest[] = [];
@@ -248,8 +351,8 @@ test("capture keeps the relay launch selection-free when the user configured nei
248
351
 
249
352
  await runCapture(cwd, native, "default-lineage");
250
353
  assert.equal(relayed.length, 1);
251
- assert.equal(relayed[0]!.reviewerModel, undefined);
252
- assert.equal(relayed[0]!.reviewerExtensionPaths, undefined);
354
+ assert.equal(relayed[0]!.selection, undefined);
355
+ assert.equal(relayed[0]!.routingKey, "review-reliability");
253
356
  });
254
357
 
255
358
  test("a relayed empty-output failure carries the child's own evidence in the failure report", async (t) => {
@@ -9,6 +9,7 @@ import {
9
9
  NativeReviewCliV216,
10
10
  type ExecFileAdapter,
11
11
  type NativeReviewCli,
12
+ type NativeReviewAssessRequest,
12
13
  } from "../lib/native-review-cli.ts";
13
14
  import {
14
15
  decodeReviewAssessmentV1,
@@ -573,6 +574,109 @@ function nativeClient(adapter: ExecFileAdapter): NativeReviewCliV216 {
573
574
  return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024);
574
575
  }
575
576
 
577
+ test("gentle_review assess: stderr-only refusal preserves safe actionable diagnostics and fails closed", async () => {
578
+ const queue = queuedAdapter([{ stdout: "", stderr: "untracked scope declaration required; use --untracked-scope=exclude or select; token=hidden-secret", exitCode: 1 }]);
579
+ const client = nativeClient(queue.adapter);
580
+ const tool = reviewControllerTool({ assess: client.assess.bind(client) });
581
+ const result = await tool.execute("refusal", { operation: "assess" }, undefined, undefined, ctx);
582
+ const details = result.details as { risk: string; reasons: { code: string; detail: string }[]; plan: { independentVerifier: boolean } };
583
+ assert.equal(details.risk, "unassessable");
584
+ assert.equal(details.plan.independentVerifier, true);
585
+ assert.equal(details.reasons[0].code, NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT);
586
+ assert.match(details.reasons[0].detail, /untracked scope declaration required/);
587
+ assert.match(details.reasons[0].detail, /--untracked-scope=exclude/);
588
+ assert.doesNotMatch(JSON.stringify(result), /hidden-secret/);
589
+ });
590
+
591
+ test("gentle_review assess: diagnostic projection is bounded and redacts paths and environment assignments", async () => {
592
+ const queue = queuedAdapter([{ stdout: "", stderr: "untracked declaration required /private/project/file C:\\private\\project\\file PRIVATE_PROJECT=hidden-project token=hidden-token " + "x".repeat(6000), exitCode: 1 }]);
593
+ const client = nativeClient(queue.adapter);
594
+ const tool = reviewControllerTool({ assess: client.assess.bind(client) });
595
+ const result = await tool.execute("private-refusal", { operation: "assess" }, undefined, undefined, ctx);
596
+ const serialized = JSON.stringify(result);
597
+ const detail = (result.details as { reasons: { detail: string }[] }).reasons[0].detail;
598
+ assert.match(detail, /untracked declaration required/);
599
+ assert.ok(detail.length < 4300);
600
+ assert.doesNotMatch(serialized, /hidden-project|hidden-token|private.project|\/package\//);
601
+ });
602
+
603
+ for (const [shape, sensitive] of [
604
+ ["lowercase environment", "private_project=hidden-project"],
605
+ ["quoted environment", 'private_project="hidden project value"'],
606
+ ["credential argument", "--password hidden-credential"],
607
+ ["quoted credential argument", '--api-key "hidden credential value"'],
608
+ ["POSIX path with spaces", "/private/hidden project/hidden file.ts"],
609
+ ["quoted POSIX path", '"/private/hidden project/hidden file.ts"'],
610
+ ["Windows path with spaces", String.raw`C:\private\hidden project\hidden file.ts`],
611
+ ["quoted Windows path", String.raw`"C:\private\hidden project\hidden file.ts"`],
612
+ ]) {
613
+ test(`assess diagnostic redacts ${shape} without losing provider guidance`, async () => {
614
+ const queue = queuedAdapter([{ stdout: "", stderr: `${sensitive}\nuntracked scope declaration required; use --untracked-scope=exclude or select`, exitCode: 1 }]);
615
+ const client = nativeClient(queue.adapter);
616
+ const result = await reviewControllerTool({ assess: client.assess.bind(client) }).execute("private-shape", { operation: "assess" }, undefined, undefined, ctx);
617
+ const details = result.details as { risk: string; reasons: { code: string; detail: string }[] };
618
+ assert.equal(details.risk, "unassessable");
619
+ assert.equal(details.reasons[0].code, NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT);
620
+ assert.doesNotMatch(JSON.stringify(result), /hidden|private_project| project|file\.ts|\/private\//);
621
+ assert.match(details.reasons[0].detail, /untracked scope declaration required; use --untracked-scope=exclude or select/);
622
+ assert.ok(details.reasons[0].detail.length < 4300);
623
+ });
624
+ }
625
+
626
+ for (const selection of [
627
+ { untrackedScope: "exclude", expectedUntrackedInventory: "inventory-v1" },
628
+ { untrackedScope: "select", expectedUntrackedInventory: "inventory-v1", intendedUntracked: ["new/file.ts", "notes.md"] },
629
+ ] as const) {
630
+ test(`native and facade assess forward explicit ${selection.untrackedScope} without choosing scope`, async () => {
631
+ const queue = queuedAdapter([{ stdout: JSON.stringify(validEnvelope()) }, { stdout: JSON.stringify(validEnvelope()) }]);
632
+ const client = nativeClient(queue.adapter);
633
+ await client.assess({ cwd: process.cwd(), ...selection });
634
+ const tool = reviewControllerTool({ assess: client.assess.bind(client) });
635
+ const result = await tool.execute("selected", { operation: "assess", input: JSON.stringify({ ...selection, baseRef: "origin/main", committedOnly: true }) }, undefined, undefined, ctx);
636
+ assert.equal((result.details as { risk: string }).risk, "medium");
637
+ const flags = [`--untracked-scope=${selection.untrackedScope}`, "--expected-untracked-inventory=inventory-v1", ...(selection.untrackedScope === "select" ? selection.intendedUntracked.map((path) => `--intended-untracked=${path}`) : [])];
638
+ assert.deepEqual(queue.calls[0].arguments, ["review", "assess", "--cwd", process.cwd(), ...flags, "--json"]);
639
+ assert.deepEqual(queue.calls[1].arguments, ["review", "assess", "--cwd", process.cwd(), "--base-ref", "origin/main", "--committed-only", ...flags, "--json"]);
640
+ });
641
+ }
642
+
643
+ test("native and facade assess reject invalid declarations before launching", async () => {
644
+ const invalid = [
645
+ { untrackedScope: "exclude" },
646
+ { expectedUntrackedInventory: "inventory" },
647
+ { intendedUntracked: ["file.ts"] },
648
+ { untrackedScope: "all", expectedUntrackedInventory: "inventory" },
649
+ { untrackedScope: "exclude", expectedUntrackedInventory: "inventory", intendedUntracked: ["file.ts"] },
650
+ { untrackedScope: "select", expectedUntrackedInventory: "inventory" },
651
+ { untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: [] },
652
+ ...[null, 42, "", " inventory", "inventory\n", "inv\u0000entory"].map((expectedUntrackedInventory) => ({ untrackedScope: "exclude", expectedUntrackedInventory })),
653
+ ...["/absolute", "C:\\absolute", "dir\\file", ".", "..", "../file", "dir/../file", "dir/./file", "dir//file", "dir/", " file", "file\n"].map((path) => ({ untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: [path] })),
654
+ { untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: ["file", "file"] },
655
+ { untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: "file" },
656
+ ];
657
+ for (const selection of invalid) {
658
+ const queue = queuedAdapter([]);
659
+ const client = nativeClient(queue.adapter);
660
+ await assert.rejects(() => client.assess({ cwd: process.cwd(), ...selection } as NativeReviewAssessRequest), TypeError);
661
+ const tool = reviewControllerTool({ assess: client.assess.bind(client) });
662
+ await assert.rejects(() => tool.execute("invalid", { operation: "assess", input: JSON.stringify(selection) }, undefined, undefined, ctx));
663
+ assert.equal(queue.calls.length, 0);
664
+ }
665
+ });
666
+
667
+ test("facade assess leaves absent declarations to native and preserves stale-inventory refusal", async () => {
668
+ for (const selection of [{}, { untrackedScope: "exclude", expectedUntrackedInventory: "stale-inventory" }]) {
669
+ const queue = queuedAdapter([{ stdout: "", stderr: "untracked inventory changed; inspect and declare the current inventory", exitCode: 1 }]);
670
+ const client = nativeClient(queue.adapter);
671
+ const tool = reviewControllerTool({ assess: client.assess.bind(client) });
672
+ const result = await tool.execute("stale", { operation: "assess", input: JSON.stringify(selection) }, undefined, undefined, ctx);
673
+ const details = result.details as { risk: string; reasons: { detail: string }[] };
674
+ assert.equal(details.risk, "unassessable");
675
+ assert.match(details.reasons[0].detail, /inventory changed/);
676
+ if (!("untrackedScope" in selection)) assert.deepEqual(queue.calls[0].arguments, ["review", "assess", "--cwd", process.cwd(), "--json"]);
677
+ }
678
+ });
679
+
576
680
  test("native assess: decodes a well-formed envelope and sends the exact plain-versioned argv", async () => {
577
681
  const queue = queuedAdapter([{ stdout: JSON.stringify({ schema: REVIEW_ASSESSMENT_SCHEMA, risk: "medium", reasons: [], changed_paths: 1, changed_lines: 2, candidate: { kind: "current-changes" } }) }]);
578
682
  const result = await nativeClient(queue.adapter).assess!({ cwd: process.cwd() });
@@ -524,7 +524,18 @@ async function run() {
524
524
 
525
525
  const toolCwd = await tempWorkspace();
526
526
  try {
527
+ execFileSync("git", ["init"], { cwd: toolCwd, stdio: "ignore" });
527
528
  const toolHook = hooks.get("tool_call")[0];
529
+ const toolResultHook = hooks.get("tool_result")[0];
530
+ const promptHook = hooks.get("before_agent_start")[0];
531
+ const oddCtx = createCtx(toolCwd, false, "odd-runtime-gate");
532
+ await promptHook({ systemPrompt: "primary" }, oddCtx);
533
+ const firstOddPath = join(toolCwd, "first.ts");
534
+ assert.equal(await toolHook({ toolName: "write", input: { path: firstOddPath } }, oddCtx), undefined);
535
+ await toolResultHook({ toolName: "write", toolCallId: "odd-first", input: { path: firstOddPath }, isError: false }, oddCtx);
536
+ const secondOdd = await toolHook({ toolName: "edit", input: { path: join(toolCwd, "second.ts") } }, oddCtx);
537
+ assert.equal(secondOdd?.block, true, "the real primary hook must stop a second distinct direct file");
538
+ assert.match(secondOdd?.reason ?? "", /subagent_run/);
528
539
  const ghPrCwd = await tempWorkspace();
529
540
  try {
530
541
  execFileSync("git", ["init"], { cwd: ghPrCwd, stdio: "ignore" });