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.
- package/README.md +63 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-shell.md +52 -15
- package/docs/readme-reference.md +67 -7
- package/docs/review-integration.md +22 -17
- package/extensions/ask-user-question.ts +210 -0
- package/extensions/gentle-agents.ts +93 -18
- package/extensions/gentle-ai.ts +180 -37
- package/extensions/gentle-shell.ts +476 -39
- package/extensions/gentle-todo.ts +19 -1
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +297 -0
- package/lib/native-review-cli.ts +50 -10
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +256 -171
- package/lib/review-integration-v2.ts +114 -27
- package/lib/shell-bar.ts +163 -75
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +118 -16
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +103 -12
- package/lib/shell-usage.ts +120 -6
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +49 -9
- package/runtime/review-integration-v2.mjs +114 -27
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/verify-package-files.mjs +3 -4
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-question.test.ts +435 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +10 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +599 -7
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +125 -9
- package/tests/gentle-shell.test.ts +1149 -24
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +460 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +34 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +297 -299
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +146 -1
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +129 -26
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +200 -124
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +301 -8
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +120 -1
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/lib/opaque-pi-reviewer-adapter.ts +0 -404
- package/skills/release/SKILL.md +0 -137
- 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
|
|
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,
|
|
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 {
|
|
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
|
|
191
|
-
// model routing config)
|
|
192
|
-
// request
|
|
193
|
-
//
|
|
194
|
-
test("capture forwards the lens's user-owned
|
|
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]!.
|
|
224
|
-
assert.
|
|
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
|
-
|
|
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]!.
|
|
252
|
-
assert.equal(relayed[0]!.
|
|
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" });
|