gentle-pi 2.5.0 → 2.6.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 +139 -30
- package/assets/agents/gentle-ai-worker.md +4 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +21 -3
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/telemetry.md +57 -1
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +143 -15
- package/extensions/codegraph-tools.ts +1 -0
- package/extensions/gentle-agents.ts +799 -50
- package/extensions/gentle-ai.ts +2033 -322
- package/extensions/gentle-shell.ts +145 -42
- package/extensions/gentle-todo.ts +47 -12
- package/extensions/quiet-tools.ts +1 -0
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +7 -10
- package/lib/agents-history.ts +9 -1
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +77 -5
- package/lib/agents-runner.ts +548 -26
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +548 -191
- package/lib/agents-widget.ts +33 -14
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +8 -6
- package/lib/native-review-cli.ts +283 -1
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +296 -46
- package/lib/review-candidate-view.ts +30 -20
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +53 -8
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +187 -5
- package/lib/review-last-event-controller.ts +7 -4
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-session-standing-permission.ts +27 -6
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +47 -1
- package/lib/shell-card.ts +1 -4
- package/lib/shell-changes-view.ts +362 -37
- package/lib/shell-changes.ts +81 -1
- package/lib/shell-prompt.ts +11 -15
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +28 -11
- package/lib/telemetry-trigger.ts +2 -0
- package/package.json +6 -3
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +283 -1
- package/runtime/review-integration-v2.mjs +187 -5
- package/runtime/telemetry-trigger.mjs +2 -0
- package/scripts/build-runtime-modules.mjs +9 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +4 -2
- package/skills/_shared/review-ledger-contract.md +17 -1
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +62 -0
- package/tests/agents-fake-child.ts +15 -1
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +45 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner.test.ts +571 -14
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-view.test.ts +476 -65
- package/tests/agents-widget.test.ts +31 -1
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +169 -3
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +2 -1
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +2 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +1460 -33
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +38 -0
- package/tests/gentle-ai.test.ts +944 -4
- package/tests/gentle-shell.test.ts +303 -12
- package/tests/gentle-todo.test.ts +54 -10
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-review-capability-contract.test.ts +12 -1
- package/tests/native-review-cli.test.ts +277 -3
- package/tests/native-review-parity.test.ts +14 -7
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/package-manifest.test.ts +232 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +1 -0
- package/tests/rdd-aware-verification-contract.test.ts +10 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +304 -6
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +14 -0
- package/tests/review-controller-native-routing.test.ts +563 -3
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +364 -4
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +164 -0
- package/tests/review-last-event-closure.test.ts +105 -1
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-session-standing-permission-controller.test.ts +52 -4
- package/tests/review-session-standing-permission.test.ts +30 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-card.test.ts +24 -3
- package/tests/shell-changes-view.test.ts +471 -8
- package/tests/shell-changes.test.ts +168 -0
- package/tests/shell-prompt.test.ts +28 -6
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +100 -11
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +3 -1
|
@@ -699,3 +699,47 @@ test("review-acknowledged/v1 is disjoint from every prior captured identity in b
|
|
|
699
699
|
assert.throws(() => decoder(clone(acknowledged)), `${name} must reject the acknowledged envelope`);
|
|
700
700
|
}
|
|
701
701
|
});
|
|
702
|
+
|
|
703
|
+
// Producer-shaped projection: gentle-ai d521b5e, target_status.go and
|
|
704
|
+
// last-event-closure.schema.json#/properties/escalation (not a live capture).
|
|
705
|
+
function escalatedStatusV7(): JsonObject {
|
|
706
|
+
const body = currentStatusFixture("status-v5-capture-result-submission.captured.json");
|
|
707
|
+
body.schema = "gentle-ai.review-integration.status/v7";
|
|
708
|
+
(body.authority as JsonObject).state = "escalated";
|
|
709
|
+
body.action = "stop";
|
|
710
|
+
body.replayability = "manual_action_required";
|
|
711
|
+
body.next_transition = { kind: "stop", reason_code: "escalated" };
|
|
712
|
+
delete body.forecast;
|
|
713
|
+
body.escalation = { cause: "unknown_causality", finding_ids: ["R1"] };
|
|
714
|
+
return body;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
test("v7 escalation is typed metadata and preserves native stop authority", () => {
|
|
718
|
+
const body = escalatedStatusV7();
|
|
719
|
+
const decoded = decodeReviewStatusV3(body);
|
|
720
|
+
assert.deepEqual(decoded.escalation, { cause: "unknown_causality", findingIds: ["R1"] });
|
|
721
|
+
delete body.escalation;
|
|
722
|
+
const older = decodeReviewStatusV3(body);
|
|
723
|
+
assert.deepEqual({ ...decoded, escalation: undefined, raw: undefined }, { ...older, escalation: undefined, raw: undefined });
|
|
724
|
+
assert.equal(decoded.action, "stop");
|
|
725
|
+
assert.equal(decoded.authority?.state, "escalated");
|
|
726
|
+
assert.deepEqual(decoded.nextTransition, { kind: "stop", reasonCode: "escalated" });
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test("v7 escalation strictly decodes all causes and optional refuter evidence", () => {
|
|
730
|
+
for (const cause of ["unknown_causality", "insufficient_evidence", "missing_refuter_outcome", "targeted_validator_rejected", "correction_budget_exceeded", "unresolved_severe_findings"]) {
|
|
731
|
+
const body = escalatedStatusV7();
|
|
732
|
+
const rows = ["corroborated", "refuted", "inconclusive"].map((outcome) => ({ finding_id: "R2", outcome, proof: "observed evidence" }));
|
|
733
|
+
body.escalation = { cause, finding_ids: [], refuter_outcomes: rows };
|
|
734
|
+
assert.deepEqual(decodeReviewStatusV3(body).escalation, { cause, findingIds: [], refuterOutcomes: rows.map(({ finding_id, ...row }) => ({ findingId: finding_id, ...row })) });
|
|
735
|
+
}
|
|
736
|
+
for (const escalation of [null, undefined, [], {}, { cause: "future", finding_ids: [] }, { cause: "unknown_causality" }, { cause: "unknown_causality", finding_ids: [""] }, { cause: "unknown_causality", finding_ids: "R1" }, { cause: "unknown_causality", finding_ids: [], extra: true }]) {
|
|
737
|
+
assert.throws(() => decodeReviewStatusV3({ ...escalatedStatusV7(), escalation }), /status\.escalation/);
|
|
738
|
+
}
|
|
739
|
+
for (const refuter_outcomes of [null, undefined, {}, [null], [{}], [{ finding_id: "R1", outcome: "future", proof: "proof" }], [{ finding_id: "", outcome: "refuted", proof: "proof" }], [{ finding_id: "R1", outcome: "refuted", proof: "" }], [{ finding_id: "R1", outcome: "refuted", proof: "proof", extra: true }]]) {
|
|
740
|
+
assert.throws(() => decodeReviewStatusV3({ ...escalatedStatusV7(), escalation: { cause: "missing_refuter_outcome", finding_ids: ["R1"], refuter_outcomes } }), /refuter_outcomes/);
|
|
741
|
+
}
|
|
742
|
+
for (const version of [3, 5, 6]) {
|
|
743
|
+
assert.throws(() => decodeReviewStatusV3({ ...escalatedStatusV7(), schema: `gentle-ai.review-integration.status/v${version}` }), /not allowed/);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
@@ -478,6 +478,170 @@ test("next_transition stop decodes a managed_assets_outdated continuation and re
|
|
|
478
478
|
assert.equal(plainStop.continuation, undefined);
|
|
479
479
|
});
|
|
480
480
|
|
|
481
|
+
// gentle-pi#638: after the host relay declares a selected lens slot
|
|
482
|
+
// unachievable through the native capture-unachievable verb, gentle-ai's
|
|
483
|
+
// STATUS stops with reason_code unachievable_lens_slot and carries one entry
|
|
484
|
+
// per declared slot, each naming the complete withdraw command
|
|
485
|
+
// (contracts/review-integration/v2/schemas/status-v7.schema.json, the stop
|
|
486
|
+
// variant). A restart that never saw the collect offer must still be able to
|
|
487
|
+
// retract the declaration from the stop alone, so the withdraw binding is
|
|
488
|
+
// decoded, never dropped.
|
|
489
|
+
function unachievableSlot(overrides: Partial<JsonObject> = {}): JsonObject {
|
|
490
|
+
return {
|
|
491
|
+
lens: "review-risk",
|
|
492
|
+
selected_order: 0,
|
|
493
|
+
subject_hash: digest,
|
|
494
|
+
reason: "relay_transport_bound_exceeded",
|
|
495
|
+
withdraw: {
|
|
496
|
+
operation: "review.capture-unachievable",
|
|
497
|
+
command: `gentle-ai review capture-unachievable --lineage=review-fixture --expected-revision=${digest} --target=${digest} --repository-context=rctx1_${"e".repeat(64)} --request-hash=${digest} --withdraw=true`,
|
|
498
|
+
arguments: [
|
|
499
|
+
{ name: "lineage", value: "review-fixture", token: "--lineage=review-fixture" },
|
|
500
|
+
{ name: "expected-revision", value: digest, token: `--expected-revision=${digest}` },
|
|
501
|
+
{ name: "target", value: digest, token: `--target=${digest}` },
|
|
502
|
+
{ name: "repository-context", value: `rctx1_${"e".repeat(64)}`, token: `--repository-context=rctx1_${"e".repeat(64)}` },
|
|
503
|
+
{ name: "request-hash", value: digest, token: `--request-hash=${digest}` },
|
|
504
|
+
{ name: "withdraw", value: "true", token: "--withdraw=true" },
|
|
505
|
+
],
|
|
506
|
+
binding: { lineage_id: "review-fixture", revision: digest, target_identity: digest, repository_context: `rctx1_${"e".repeat(64)}` },
|
|
507
|
+
},
|
|
508
|
+
...overrides,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
test("next_transition stop decodes unachievable_lens_slots and their withdraw bindings only on that reason code", () => {
|
|
513
|
+
const stop: JsonObject = { kind: "stop", reason_code: "unachievable_lens_slot", unachievable_lens_slots: [unachievableSlot()] };
|
|
514
|
+
const decoded = decodeReviewNextTransitionV3(stop);
|
|
515
|
+
assert.equal(decoded.kind, "stop");
|
|
516
|
+
assert.equal(decoded.reasonCode, "unachievable_lens_slot");
|
|
517
|
+
const slot = decoded.unachievableLensSlots?.[0];
|
|
518
|
+
assert.equal(slot?.lens, "review-risk");
|
|
519
|
+
assert.equal(slot?.selectedOrder, 0);
|
|
520
|
+
assert.equal(slot?.subjectHash, digest);
|
|
521
|
+
assert.equal(slot?.reason, "relay_transport_bound_exceeded");
|
|
522
|
+
assert.equal(slot?.detail, undefined);
|
|
523
|
+
assert.equal(slot?.withdraw.operation, "review.capture-unachievable");
|
|
524
|
+
assert.deepEqual(slot?.withdraw.arguments.map((argument) => argument.token), ["--lineage=review-fixture", `--expected-revision=${digest}`, `--target=${digest}`, `--repository-context=rctx1_${"e".repeat(64)}`, `--request-hash=${digest}`, "--withdraw=true"]);
|
|
525
|
+
assert.equal(slot?.withdraw.binding.targetIdentity, digest);
|
|
526
|
+
assert.equal(slot?.withdraw.command, `gentle-ai review capture-unachievable --lineage=review-fixture --expected-revision=${digest} --target=${digest} --repository-context=rctx1_${"e".repeat(64)} --request-hash=${digest} --withdraw=true`);
|
|
527
|
+
|
|
528
|
+
// detail is the one optional field the schema declares
|
|
529
|
+
const detailed = decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ detail: "killed after 2256004ms against a 2256000ms relay bound" })] });
|
|
530
|
+
assert.equal(detailed.unachievableLensSlots?.[0]?.detail, "killed after 2256004ms against a 2256000ms relay bound");
|
|
531
|
+
|
|
532
|
+
// malformed entries are refused: a negative selected_order, a slot missing
|
|
533
|
+
// its withdraw form, a foreign withdraw operation, or an empty array
|
|
534
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ selected_order: -1 })] }), /selected_order/);
|
|
535
|
+
const withoutWithdraw = unachievableSlot();
|
|
536
|
+
delete withoutWithdraw.withdraw;
|
|
537
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [withoutWithdraw] }), /withdraw.*required|required.*withdraw/);
|
|
538
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ withdraw: { ...(unachievableSlot().withdraw as JsonObject), operation: "review.capture-result" } })] }), /operation/);
|
|
539
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [] }), /length/);
|
|
540
|
+
|
|
541
|
+
// the slots are only valid on their own exact stop reason code
|
|
542
|
+
assert.throws(() => decodeReviewNextTransitionV3({ kind: "stop", reason_code: "rdd_disabled", unachievable_lens_slots: [unachievableSlot()] }), /unachievable_lens_slots is only valid/);
|
|
543
|
+
// an execute or collect transition never carries them
|
|
544
|
+
assert.throws(() => decodeReviewNextTransitionV3({ kind: "execute", reason_code: "fresh_target_ready", execute: { operation: "review.start", arguments: [{ name: "lineage", value: "review-fixture" }], preconditions: [], binding: { target_identity: digest } }, unachievable_lens_slots: [unachievableSlot()] }), /unachievable_lens_slots is only valid/);
|
|
545
|
+
// plain stops stay unchanged
|
|
546
|
+
assert.equal(decodeReviewNextTransitionV3({ kind: "stop", reason_code: "rdd_disabled" }).unachievableLensSlots, undefined);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// gentle-pi#822: the withdraw form names the slot identity twice — as named arguments and as the binding object — and a slot whose two renderings disagree never decodes, so restart cannot withdraw a different slot.
|
|
550
|
+
test("next_transition stop refuses an unachievable slot whose withdraw arguments disagree with its identity", () => {
|
|
551
|
+
const stop: JsonObject = { kind: "stop", reason_code: "unachievable_lens_slot", unachievable_lens_slots: [unachievableSlot()] };
|
|
552
|
+
const drift = (name: string, value: string): JsonObject => {
|
|
553
|
+
const drifted = unachievableSlot();
|
|
554
|
+
const withdraw = drifted.withdraw as JsonObject;
|
|
555
|
+
withdraw.arguments = (withdraw.arguments as unknown[]).map((argument) => (argument as JsonObject).name === name ? { ...(argument as JsonObject), value } : argument);
|
|
556
|
+
return drifted;
|
|
557
|
+
};
|
|
558
|
+
for (const [name, value] of [["request-hash", `sha256:${"b".repeat(64)}`], ["target", `sha256:${"c".repeat(64)}`], ["lineage", "review-fixture-drifted"], ["expected-revision", `sha256:${"d".repeat(64)}`]] as const) {
|
|
559
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [drift(name, value)] }), new RegExp(`${name} does not match`), name);
|
|
560
|
+
}
|
|
561
|
+
// the pristine fixture still decodes with both renderings agreeing
|
|
562
|
+
assert.doesNotThrow(() => decodeReviewNextTransitionV3(stop));
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// gentle-pi#822: identity arguments appear exactly once, and the rendered
|
|
566
|
+
// command is an exact, ordered rendering of every validated provider token.
|
|
567
|
+
// Substring checks would accept a missing non-identity token, repeated command,
|
|
568
|
+
// suffix, or shell payload despite the command no longer naming this slot alone.
|
|
569
|
+
test("next_transition stop refuses duplicate identities and non-exact withdraw commands", () => {
|
|
570
|
+
const stop: JsonObject = { kind: "stop", reason_code: "unachievable_lens_slot", unachievable_lens_slots: [unachievableSlot()] };
|
|
571
|
+
const duplicated = unachievableSlot();
|
|
572
|
+
const duplicatedWithdraw = duplicated.withdraw as JsonObject;
|
|
573
|
+
duplicatedWithdraw.arguments = [...(duplicatedWithdraw.arguments as unknown[]), { name: "lineage", value: "review-fixture-smuggled", token: "--lineage=review-fixture-smuggled" }];
|
|
574
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [duplicated] }), /withdraw\.arguments lineage must appear exactly once/);
|
|
575
|
+
|
|
576
|
+
const canonical = `gentle-ai review capture-unachievable --lineage=review-fixture --expected-revision=${digest} --target=${digest} --repository-context=rctx1_${"e".repeat(64)} --request-hash=${digest} --withdraw=true`;
|
|
577
|
+
for (const [name, command] of [
|
|
578
|
+
["omitted token", canonical.replace(" --withdraw=true", "")],
|
|
579
|
+
["duplicate command", `gentle-ai review capture-unachievable ${canonical}`],
|
|
580
|
+
["extra suffix", `${canonical} --surplus=true`],
|
|
581
|
+
["shell payload", `${canonical} && printf injected`],
|
|
582
|
+
] as const) {
|
|
583
|
+
const malformed = unachievableSlot();
|
|
584
|
+
(malformed.withdraw as JsonObject).command = command;
|
|
585
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [malformed] }), /withdraw\.command must exactly render/, name);
|
|
586
|
+
}
|
|
587
|
+
assert.doesNotThrow(() => decodeReviewNextTransitionV3(stop));
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// gentle-pi#822 (CodeRabbit finding): withdrawal is a narrowly scoped, runnable
|
|
591
|
+
// retraction. Its one affirmative flag is part of the exact native vector, not
|
|
592
|
+
// just another provider-defined argument that a malformed STATUS can omit or
|
|
593
|
+
// negate while still rendering a plausible command.
|
|
594
|
+
test("next_transition stop requires exactly one canonical --withdraw=true argument", () => {
|
|
595
|
+
const stop: JsonObject = { kind: "stop", reason_code: "unachievable_lens_slot", unachievable_lens_slots: [unachievableSlot()] };
|
|
596
|
+
const canonicalCommand = (slot: JsonObject): string => {
|
|
597
|
+
const withdraw = slot.withdraw as JsonObject;
|
|
598
|
+
return `gentle-ai review capture-unachievable ${(withdraw.arguments as JsonObject[]).map((argument) => String(argument.token)).join(" ")}`;
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const omitted = unachievableSlot();
|
|
602
|
+
const omittedWithdraw = omitted.withdraw as JsonObject;
|
|
603
|
+
omittedWithdraw.arguments = (omittedWithdraw.arguments as JsonObject[]).filter((argument) => argument.name !== "withdraw");
|
|
604
|
+
omittedWithdraw.command = canonicalCommand(omitted);
|
|
605
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [omitted] }), /withdraw must appear exactly once/);
|
|
606
|
+
|
|
607
|
+
const falseValue = unachievableSlot();
|
|
608
|
+
const falseWithdraw = falseValue.withdraw as JsonObject;
|
|
609
|
+
falseWithdraw.arguments = (falseWithdraw.arguments as JsonObject[]).map((argument) => argument.name === "withdraw"
|
|
610
|
+
? { ...argument, value: "false", token: "--withdraw=false" }
|
|
611
|
+
: argument);
|
|
612
|
+
falseWithdraw.command = canonicalCommand(falseValue);
|
|
613
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [falseValue] }), /withdraw must be true/);
|
|
614
|
+
|
|
615
|
+
const noncanonicalToken = unachievableSlot();
|
|
616
|
+
const tokenWithdraw = noncanonicalToken.withdraw as JsonObject;
|
|
617
|
+
tokenWithdraw.arguments = (tokenWithdraw.arguments as JsonObject[]).map((argument) => argument.name === "withdraw"
|
|
618
|
+
? { ...argument, token: "--withdraw true" }
|
|
619
|
+
: argument);
|
|
620
|
+
tokenWithdraw.command = canonicalCommand(noncanonicalToken);
|
|
621
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [noncanonicalToken] }), /token must exactly render/);
|
|
622
|
+
|
|
623
|
+
const duplicate = unachievableSlot();
|
|
624
|
+
const duplicateWithdraw = duplicate.withdraw as JsonObject;
|
|
625
|
+
duplicateWithdraw.arguments = [...duplicateWithdraw.arguments as JsonObject[], { name: "withdraw", value: "true", token: "--withdraw=true" }];
|
|
626
|
+
duplicateWithdraw.command = canonicalCommand(duplicate);
|
|
627
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [duplicate] }), /withdraw must appear exactly once/);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// gentle-pi#822 (CodeRabbit finding): Go bounds the CAPTURE_UNACHIEVABLE detail at 512 UTF-8 bytes (native-review-cli.ts enforces the same limit when declaring), so the transition decoder mirrors that bound — measured in bytes, not UTF-16 code units — and a STATUS stop can never carry a detail this client would have refused to declare.
|
|
631
|
+
test("next_transition stop bounds an unachievable slot detail at 512 UTF-8 bytes", () => {
|
|
632
|
+
const stop: JsonObject = { kind: "stop", reason_code: "unachievable_lens_slot", unachievable_lens_slots: [unachievableSlot()] };
|
|
633
|
+
// exactly 512 one-byte characters still decodes
|
|
634
|
+
assert.equal(decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ detail: "a".repeat(512) })] }).unachievableLensSlots?.[0]?.detail, "a".repeat(512));
|
|
635
|
+
// exactly 512 bytes of two-byte characters still decodes: 256 × 2
|
|
636
|
+
assert.equal(decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ detail: "é".repeat(256) })] }).unachievableLensSlots?.[0]?.detail, "é".repeat(256));
|
|
637
|
+
// 257 two-byte characters are 514 bytes
|
|
638
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ detail: "é".repeat(257) })] }), /detail exceeds 512 bytes/);
|
|
639
|
+
// 171 three-byte characters are 513 bytes
|
|
640
|
+
assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ detail: "世".repeat(171) })] }), /detail exceeds 512 bytes/);
|
|
641
|
+
// an absent detail still decodes as undefined
|
|
642
|
+
assert.equal(decodeReviewNextTransitionV3(stop).unachievableLensSlots?.[0]?.detail, undefined);
|
|
643
|
+
});
|
|
644
|
+
|
|
481
645
|
function approvedAcknowledgementTransition(): JsonObject {
|
|
482
646
|
return {
|
|
483
647
|
kind: "execute",
|
|
@@ -156,6 +156,45 @@ test("strictly decodes the real zero-lens closed START and every last-event clos
|
|
|
156
156
|
}
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
test("approved closure preserves complete admitted reviewer results before acknowledgement", () => {
|
|
160
|
+
const approved = closure("review/capture-result", "review-results");
|
|
161
|
+
approved.reviewer_results = [{
|
|
162
|
+
lens: "review-reliability",
|
|
163
|
+
findings: [{
|
|
164
|
+
id: "R3-W01",
|
|
165
|
+
lens: "reliability",
|
|
166
|
+
location: "lib/session.ts:4",
|
|
167
|
+
severity: "SUGGESTION",
|
|
168
|
+
claim: "the complete explanatory narrative remains available",
|
|
169
|
+
proof_refs: ["the changed line was inspected"],
|
|
170
|
+
evidence_class: "insufficient",
|
|
171
|
+
causal_disposition: "pre-existing",
|
|
172
|
+
}],
|
|
173
|
+
evidence: ["inspected the complete frozen candidate"],
|
|
174
|
+
result_hash: SHA,
|
|
175
|
+
}];
|
|
176
|
+
const decoded = decodeReviewLastEventClosureV1(approved);
|
|
177
|
+
assert.deepEqual(decoded.reviewerResults, [{
|
|
178
|
+
lens: "review-reliability",
|
|
179
|
+
findings: [{
|
|
180
|
+
id: "R3-W01",
|
|
181
|
+
lens: "reliability",
|
|
182
|
+
location: "lib/session.ts:4",
|
|
183
|
+
severity: "SUGGESTION",
|
|
184
|
+
claim: "the complete explanatory narrative remains available",
|
|
185
|
+
proofRefs: ["the changed line was inspected"],
|
|
186
|
+
evidenceClass: "insufficient",
|
|
187
|
+
causalDisposition: "pre-existing",
|
|
188
|
+
}],
|
|
189
|
+
evidence: ["inspected the complete frozen candidate"],
|
|
190
|
+
resultHash: SHA,
|
|
191
|
+
}]);
|
|
192
|
+
|
|
193
|
+
const correctionPlan = closure("review.capture-correction-plan", "review-results");
|
|
194
|
+
correctionPlan.reviewer_results = approved.reviewer_results;
|
|
195
|
+
assert.throws(() => decodeReviewLastEventClosureV1(correctionPlan), /reviewer_results requires approved state/);
|
|
196
|
+
});
|
|
197
|
+
|
|
159
198
|
test("retired legacy client no longer exposes a FINALIZE route", () => {
|
|
160
199
|
assert.equal("NativeReviewCliV214" in nativeReviewCliModule, false);
|
|
161
200
|
assert.equal("NativeReviewCliV213" in nativeReviewCliModule, false);
|
|
@@ -337,7 +376,7 @@ test("unknown-capture reconciliation preserves agentless legacy callers and forw
|
|
|
337
376
|
targetStatus: async (request: Record<string, unknown>) => {
|
|
338
377
|
calls.push(request);
|
|
339
378
|
assert.equal(request.lineageId, "reconcile-lineage");
|
|
340
|
-
return { targetIdentity: SHA, authority: { lineageId: "reconcile-lineage" } } as ReviewStatusV3;
|
|
379
|
+
return { applicability: "current_target", targetIdentity: SHA, authority: { lineageId: "reconcile-lineage" } } as ReviewStatusV3;
|
|
341
380
|
},
|
|
342
381
|
} as unknown as NativeReviewCli;
|
|
343
382
|
const result = await reconcileUnknownReviewLastEventCapture(native, "/repo", { lineageId: "reconcile-lineage", targetIdentity: SHA });
|
|
@@ -355,6 +394,71 @@ test("unknown-capture reconciliation preserves agentless legacy callers and forw
|
|
|
355
394
|
assert.equal(launches, 0);
|
|
356
395
|
});
|
|
357
396
|
|
|
397
|
+
test("unknown targeted-validator capture reconciliation requires current exact authority and target", async () => {
|
|
398
|
+
const lineageId = "targeted-validator-reconciliation";
|
|
399
|
+
const input = roleInput(lineageId, "review.capture-validation");
|
|
400
|
+
const expected = status(lineageId, [input]);
|
|
401
|
+
const cases = [
|
|
402
|
+
["current matching STATUS", expected, true],
|
|
403
|
+
["unrelated STATUS", { ...expected, applicability: "unrelated" }, false],
|
|
404
|
+
["STATUS without authority", { ...expected, authority: undefined }, false],
|
|
405
|
+
["STATUS for another lineage", { ...expected, authority: { ...expected.authority!, lineageId: "other-lineage" } }, false],
|
|
406
|
+
["STATUS for another target", { ...expected, targetIdentity: `sha256:${"d".repeat(64)}` }, false],
|
|
407
|
+
] as const;
|
|
408
|
+
|
|
409
|
+
for (const [_name, reconciliationStatus, trusted] of cases) {
|
|
410
|
+
let statusCalls = 0;
|
|
411
|
+
let captureCalls = 0;
|
|
412
|
+
let startCalls = 0;
|
|
413
|
+
const native = {
|
|
414
|
+
targetStatus: async () => {
|
|
415
|
+
statusCalls += 1;
|
|
416
|
+
return statusCalls === 1 ? expected : reconciliationStatus;
|
|
417
|
+
},
|
|
418
|
+
captureProviderRole: async () => {
|
|
419
|
+
captureCalls += 1;
|
|
420
|
+
throw Object.assign(new Error("targeted-validator response lost"), {
|
|
421
|
+
mutationOutcome: "unknown",
|
|
422
|
+
nextAction: "review.status",
|
|
423
|
+
failureEnvelope: { raw: { code: "targeted-validator-response-lost" }, mutationOutcome: "unknown" },
|
|
424
|
+
});
|
|
425
|
+
},
|
|
426
|
+
start: async () => { startCalls += 1; },
|
|
427
|
+
} as unknown as NativeReviewCli;
|
|
428
|
+
|
|
429
|
+
const result = await capture(lineageId, input, native);
|
|
430
|
+
assert.equal(result.outcome, trusted ? "native-capture-outcome-unknown" : "native-capture-status-reconciliation-failed");
|
|
431
|
+
assert.equal(captureCalls, 1, "an ambiguous capture is never replayed");
|
|
432
|
+
assert.equal(statusCalls, 2, "an ambiguous capture gets one reconciliation STATUS");
|
|
433
|
+
assert.equal(startCalls, 0, "reconciliation never invokes START");
|
|
434
|
+
if (trusted) {
|
|
435
|
+
assert.equal(result.status, "reconciled");
|
|
436
|
+
assert.deepEqual((result.native_failure as { native_failure: unknown }).native_failure, { code: "targeted-validator-response-lost" });
|
|
437
|
+
} else {
|
|
438
|
+
assert.deepEqual(result.native_failure, { code: "targeted-validator-response-lost" }, "the original capture failure is preserved");
|
|
439
|
+
assert.ok(result.reconciliation_failure, "the rejected STATUS diagnostic remains nested");
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("unknown-capture reconciliation rejects a binding without a target identity", async () => {
|
|
445
|
+
const lineageId = "missing-reconciliation-target";
|
|
446
|
+
const input = roleInput(lineageId, "review.capture-validation");
|
|
447
|
+
let statusCalls = 0;
|
|
448
|
+
const native = {
|
|
449
|
+
targetStatus: async () => {
|
|
450
|
+
statusCalls += 1;
|
|
451
|
+
return status(lineageId, [input]);
|
|
452
|
+
},
|
|
453
|
+
} as unknown as NativeReviewCli;
|
|
454
|
+
|
|
455
|
+
await assert.rejects(
|
|
456
|
+
() => reconcileUnknownReviewLastEventCapture(native, process.cwd(), { lineageId }),
|
|
457
|
+
/capture reconciliation returned missing or different target/,
|
|
458
|
+
);
|
|
459
|
+
assert.equal(statusCalls, 1, "the missing binding is rejected after one reconciliation STATUS");
|
|
460
|
+
});
|
|
461
|
+
|
|
358
462
|
// One approved terminal closure carrying the exact continuation the provider
|
|
359
463
|
// renders: closed positional arguments, the single approved precondition, and a
|
|
360
464
|
// binding that names the candidate being burned.
|
|
@@ -66,13 +66,28 @@ function assertNativeJsonHasNoMetadata(path: string, value: unknown): void {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const
|
|
70
|
-
/Judgment Day starts only when explicitly requested and replaces ordinary review for that lineage\./,
|
|
69
|
+
const JUDGMENT_DAY_DISCOVERY_PATTERNS = [
|
|
71
70
|
/Judgment Day starts with exactly two blind judges and zero refuters\./,
|
|
72
71
|
/Judgment Day alone may iterate discovery and scoped re-judgment, for at most two rounds\./,
|
|
73
72
|
/Findings surviving round two escalate; no third-round transition exists\./,
|
|
74
73
|
] as const;
|
|
75
74
|
|
|
75
|
+
const JUDGMENT_DAY_STANDALONE_SEMANTICS =
|
|
76
|
+
"Judgment Day is independent: it neither enables nor replaces ordinary review; a separately requested ordinary review remains independent.";
|
|
77
|
+
|
|
78
|
+
const OBSOLETE_JUDGMENT_DAY_REPLACEMENT =
|
|
79
|
+
/Judgment Day starts only when explicitly requested and replaces ordinary review for that lineage\./;
|
|
80
|
+
|
|
81
|
+
const JUDGMENT_DAY_SEMANTIC_SURFACES = [
|
|
82
|
+
CANONICAL,
|
|
83
|
+
JD_SKILL,
|
|
84
|
+
JD_PROMPTS,
|
|
85
|
+
...JUDGES,
|
|
86
|
+
FIX_AGENT,
|
|
87
|
+
"assets/orchestrator-delegation.md",
|
|
88
|
+
SDD_WORKFLOW,
|
|
89
|
+
] as const;
|
|
90
|
+
|
|
76
91
|
const JUDGMENT_DAY_REJUDGMENT_PATTERNS = [
|
|
77
92
|
/Initial discovery and scoped re-judgment are separate modes\./,
|
|
78
93
|
/On controller-requested scoped re-judgment, receive only requested frozen IDs, their exact hash-bound rows, and the fix diff\./,
|
|
@@ -115,7 +130,7 @@ test("canonical contract defines compact risk, causal admission, correction, CAS
|
|
|
115
130
|
/reviewer and validator outputs remain semantically untrusted/i,
|
|
116
131
|
/do not report.*trusted local orchestrator.*security finding/i,
|
|
117
132
|
/untrusted repository content.*malformed inputs.*stale authority.*path drift.*external callers/i,
|
|
118
|
-
...
|
|
133
|
+
...JUDGMENT_DAY_DISCOVERY_PATTERNS,
|
|
119
134
|
]);
|
|
120
135
|
assert.match(read(README), /Review outcomes and receipt state are informational; commit, push, pull-request, and release delivery follow ordinary repository policy\./);
|
|
121
136
|
assert.doesNotMatch(read(README), /one one-shot authorization for the exact command/i);
|
|
@@ -267,7 +282,7 @@ test("the Pi-owned adversarial role agents are retired: roles execute through Go
|
|
|
267
282
|
for (const path of JUDGES) {
|
|
268
283
|
test(`${path} preserves graph-v1 Judgment Day discovery and scoped re-judgment`, () => {
|
|
269
284
|
const content = read(path);
|
|
270
|
-
assertMatches(path, content,
|
|
285
|
+
assertMatches(path, content, JUDGMENT_DAY_DISCOVERY_PATTERNS);
|
|
271
286
|
assertMatches(path, content, JUDGMENT_DAY_REJUDGMENT_PATTERNS);
|
|
272
287
|
});
|
|
273
288
|
}
|
|
@@ -303,13 +318,53 @@ test("Judgment Day judge prompts contain distinct graph-v1 discovery and re-judg
|
|
|
303
318
|
assert.doesNotMatch(judgePrompt, /End with `Skill Resolution:/);
|
|
304
319
|
});
|
|
305
320
|
|
|
321
|
+
test("Judgment Day canonical and packaged surfaces preserve the independent lifecycle", () => {
|
|
322
|
+
for (const path of JUDGMENT_DAY_SEMANTIC_SURFACES) {
|
|
323
|
+
const content = read(path);
|
|
324
|
+
assert.ok(content.includes(JUDGMENT_DAY_STANDALONE_SEMANTICS), `${path} must use the current standalone Judgment Day sentence`);
|
|
325
|
+
assert.doesNotMatch(content, OBSOLETE_JUDGMENT_DAY_REPLACEMENT, `${path} must reject the obsolete replacement semantics`);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
306
329
|
test("Judgment Day skill and prompts preserve bounded fix and re-judgment authority", () => {
|
|
307
|
-
|
|
308
|
-
|
|
330
|
+
const skill = read(JD_SKILL);
|
|
331
|
+
const judgePrompt = fencedBlock(JD_PROMPTS, "## Judge Prompt");
|
|
332
|
+
assertMatches(JD_SKILL, skill, [...JUDGMENT_DAY_DISCOVERY_PATTERNS, ...JUDGMENT_DAY_REJUDGMENT_PATTERNS, ...FIX_PATTERNS]);
|
|
333
|
+
assertMatches(JD_PROMPTS, judgePrompt, JUDGMENT_DAY_DISCOVERY_PATTERNS);
|
|
309
334
|
assertMatches(JD_PROMPTS, fencedBlock(JD_PROMPTS, "## Fix Agent Prompt"), FIX_PATTERNS);
|
|
310
335
|
assertMatches(FIX_AGENT, read(FIX_AGENT), FIX_PATTERNS);
|
|
311
336
|
});
|
|
312
337
|
|
|
338
|
+
test("Judgment Day fix routing has one canonical shape and never falls back to generic roles", () => {
|
|
339
|
+
const canonicalShape = [
|
|
340
|
+
"## Judgment Day activation",
|
|
341
|
+
"User explicitly requested Judgment Day.",
|
|
342
|
+
"## Exact authorized severe IDs",
|
|
343
|
+
"- `JD-A-001`",
|
|
344
|
+
"## Judgment Day correction batch",
|
|
345
|
+
"Round: 1 of 2.",
|
|
346
|
+
"Frozen ledger SHA-256: `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`",
|
|
347
|
+
"## Exact frozen finding rows",
|
|
348
|
+
'{"id":"JD-A-001","lens":"judgment-day","location":"path/to/authorized-file.ts:1","severity":"CRITICAL","status_at_freeze":"open","evidence_class":"deterministic","evidence_claim":"Concrete user-impact claim supported by the frozen location."}',
|
|
349
|
+
"## Allowed edit surfaces",
|
|
350
|
+
].join("\n");
|
|
351
|
+
for (const [path, content] of [
|
|
352
|
+
[CANONICAL, read(CANONICAL)],
|
|
353
|
+
[FIX_AGENT, read(FIX_AGENT)],
|
|
354
|
+
[JD_SKILL, read(JD_SKILL)],
|
|
355
|
+
[JD_PROMPTS, fencedBlock(JD_PROMPTS, "## Fix Agent Prompt")],
|
|
356
|
+
["assets/orchestrator-delegation.md", read("assets/orchestrator-delegation.md")],
|
|
357
|
+
[SDD_WORKFLOW, read(SDD_WORKFLOW)],
|
|
358
|
+
] as const) {
|
|
359
|
+
assert.ok(content.includes(canonicalShape), `${path} must carry the canonical Judgment Day fix shape`);
|
|
360
|
+
assert.match(content, /requires no graph-v1 or native review lineage/i);
|
|
361
|
+
}
|
|
362
|
+
const routing = `${read("assets/orchestrator-delegation.md")}\n${read(SDD_WORKFLOW)}`;
|
|
363
|
+
assert.match(routing, /Judgment Day phase roles are never generic fallbacks\./);
|
|
364
|
+
assert.match(routing, /If the generic writer chain is unavailable, use the documented native generic fallback or stop\./);
|
|
365
|
+
assert.match(read(SDD_WORKFLOW), /\| default\s+\| balanced\s+\| SDD phase fallback; never a Judgment Day role\s+\|/);
|
|
366
|
+
});
|
|
367
|
+
|
|
313
368
|
test("orchestrator, injected skill, and README defer RDD lifecycle ownership to Gentle AI", () => {
|
|
314
369
|
const boundary = "This package injects the mirrored provider-bundle review execution contract into this session's system prompt at start; Gentle AI writes nothing into the Pi system prompt, and this package owns everything else here. Absent that mirrored contract, this package invents no lifecycle instructions.";
|
|
315
370
|
const orchestrator = union(ORCHESTRATOR);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { consumeReviewMutation, pendingReviewMutation, recordReviewMutation, REVIEW_REMINDER_RECEIPT } from "../lib/review-reminder-receipt.ts";
|
|
4
|
+
|
|
5
|
+
function fixture() {
|
|
6
|
+
let sessionId = "parent";
|
|
7
|
+
const entries: Array<{ type: string; customType: string; data: unknown }> = [];
|
|
8
|
+
const session = { getSessionId: () => sessionId, getBranch: () => entries };
|
|
9
|
+
const host = { appendEntry: (customType: string, data: unknown) => { entries.push({ type: "custom", customType, data }); } };
|
|
10
|
+
const write = (toolCallId: string, root = "/repo") => recordReviewMutation(host, session, root, { source: "direct", toolName: "write", toolCallId });
|
|
11
|
+
return { host, session, entries, write, switchSession: (id: string) => { sessionId = id; } };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test("receipts survive reload but are isolated by session, root, and active branch", () => {
|
|
15
|
+
const f = fixture();
|
|
16
|
+
f.write("a");
|
|
17
|
+
const captured = pendingReviewMutation(f.session, "/repo");
|
|
18
|
+
assert.ok(captured);
|
|
19
|
+
assert.equal(pendingReviewMutation({ ...f.session }, "/repo"), captured);
|
|
20
|
+
assert.equal(pendingReviewMutation(f.session, "/sibling"), undefined);
|
|
21
|
+
f.switchSession("fork");
|
|
22
|
+
assert.equal(pendingReviewMutation(f.session, "/repo"), undefined);
|
|
23
|
+
f.switchSession("new");
|
|
24
|
+
assert.equal(pendingReviewMutation(f.session, "/repo"), undefined);
|
|
25
|
+
f.switchSession("parent");
|
|
26
|
+
assert.equal(pendingReviewMutation({ ...f.session, getBranch: () => [] }, "/repo"), undefined);
|
|
27
|
+
f.write("a");
|
|
28
|
+
assert.equal(f.entries.length, 1, "duplicate tool completion cannot re-enable a generation");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
for (const kind of ["nudged", "acknowledged"] as const) {
|
|
32
|
+
test(`${kind} consumes only its captured prefix, retaining a concurrent own mutation`, () => {
|
|
33
|
+
const f = fixture();
|
|
34
|
+
f.write("a");
|
|
35
|
+
const captured = pendingReviewMutation(f.session, "/repo");
|
|
36
|
+
f.write("b");
|
|
37
|
+
consumeReviewMutation(f.host, f.session, "/repo", captured, kind, "whole-target");
|
|
38
|
+
assert.equal(pendingReviewMutation(f.session, "/repo", captured), undefined);
|
|
39
|
+
const next = pendingReviewMutation(f.session, "/repo");
|
|
40
|
+
assert.ok(next && next !== captured);
|
|
41
|
+
consumeReviewMutation(f.host, f.session, "/repo", next, kind, "whole-target");
|
|
42
|
+
assert.equal(pendingReviewMutation(f.session, "/repo"), undefined);
|
|
43
|
+
f.write("c");
|
|
44
|
+
assert.ok(pendingReviewMutation(f.session, "/repo"));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("malformed, unknown-version, and unmatched consumption entries do not establish ownership", () => {
|
|
49
|
+
const f = fixture();
|
|
50
|
+
f.write("valid");
|
|
51
|
+
const good = f.entries.pop()!;
|
|
52
|
+
const data = good.data as Record<string, unknown>;
|
|
53
|
+
for (const patch of [{ kind: "spawn" }, { source: "prose" }, { toolName: "bash" }, { sessionId: "" }, { root: "relative" }, { taskId: "foreign" }, { extra: true }, { toolCallId: "" }, { id: null }]) {
|
|
54
|
+
f.entries.push({ ...good, data: { ...data, ...patch } });
|
|
55
|
+
}
|
|
56
|
+
f.entries.push({ ...good, customType: "gentle-pi.review-reminder-receipt/v99" });
|
|
57
|
+
assert.equal(pendingReviewMutation(f.session, "/repo"), undefined);
|
|
58
|
+
f.entries.push(good);
|
|
59
|
+
const captured = pendingReviewMutation(f.session, "/repo");
|
|
60
|
+
consumeReviewMutation(f.host, f.session, "/repo", "unknown", "acknowledged", "target");
|
|
61
|
+
assert.equal(pendingReviewMutation(f.session, "/repo"), captured);
|
|
62
|
+
});
|
|
@@ -41,6 +41,31 @@ function nonPiV3Consent() {
|
|
|
41
41
|
return decodeReviewConsentV3(consentFixture(), "claude-code");
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function consentCustom(select: (title: string, options: string[]) => Promise<string | undefined>) {
|
|
45
|
+
return async (factory: (...args: never[]) => unknown) => {
|
|
46
|
+
let result: unknown;
|
|
47
|
+
const component = factory(
|
|
48
|
+
{ terminal: { rows: 24 }, requestRender() {} },
|
|
49
|
+
{ fg: (_color: string, text: string) => text, bold: (text: string) => text },
|
|
50
|
+
undefined,
|
|
51
|
+
(value: unknown) => { result = value; },
|
|
52
|
+
) as { getActionOptions(): readonly string[]; render(width: number): string[]; handleInput?(data: string): void };
|
|
53
|
+
component.render(80);
|
|
54
|
+
const options = [...component.getActionOptions()];
|
|
55
|
+
const selected = await select("", options);
|
|
56
|
+
const index = options.indexOf(selected ?? "");
|
|
57
|
+
if (index === -1) component.handleInput?.("\u001b");
|
|
58
|
+
else {
|
|
59
|
+
for (let current = 0; current < index; current += 1) {
|
|
60
|
+
component.handleInput?.("\u001b[B");
|
|
61
|
+
component.render(80);
|
|
62
|
+
}
|
|
63
|
+
component.handleInput?.("\r");
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
44
69
|
test("host UI preserves the complete provider envelope and adds a separately owned third action", () => {
|
|
45
70
|
const envelope = consent();
|
|
46
71
|
const before = structuredClone(envelope.raw);
|
|
@@ -54,9 +79,16 @@ test("host UI preserves the complete provider envelope and adds a separately own
|
|
|
54
79
|
assert.ok(model.options[index]!.includes(choice.label));
|
|
55
80
|
assert.ok(model.options[index]!.includes(choice.effect));
|
|
56
81
|
}
|
|
82
|
+
assert.equal(model.content.actions[2]?.label, "Review and allow this session");
|
|
83
|
+
assert.equal(model.content.actions[2]?.effect, "Reviews this change and allows later reviews in this session and repository; revoke anytime.");
|
|
57
84
|
assert.ok(model.options[2]!.includes(HOST_REVIEW_SESSION_PERMISSION_LABEL));
|
|
58
85
|
assert.match(model.title, /The first two actions are provider-owned and apply only to this candidate/);
|
|
59
86
|
assert.match(model.title, /The third action is owned by the Pi host/);
|
|
87
|
+
assert.match(model.content.ownership, /runs the current provider grant for this frozen candidate/);
|
|
88
|
+
assert.match(model.content.ownership, /one fresh validated provider grant for each later candidate/);
|
|
89
|
+
assert.match(model.content.ownership, /canonical Git repository identity, including sibling worktrees/);
|
|
90
|
+
assert.match(model.content.ownership, /Reload preserves it; new, resume, fork, quit, process restart, or explicit revocation ends it/);
|
|
91
|
+
assert.match(model.content.ownership, /no verdict, acknowledgement, maintenance, delivery, or cross-repository authority/);
|
|
60
92
|
assert.equal(envelope.choices.length, 2, "the decoded provider envelope must remain a two-choice contract");
|
|
61
93
|
assert.deepEqual(envelope.raw, before, "formatting must not mutate or append to the provider envelope");
|
|
62
94
|
});
|
|
@@ -83,13 +115,29 @@ test("selection maps only exact displayed actions and cancellation or UI failure
|
|
|
83
115
|
undefined,
|
|
84
116
|
];
|
|
85
117
|
for (let index = 0; index < selections.length; index += 1) {
|
|
86
|
-
const ctx = { ui: {
|
|
118
|
+
const ctx = { mode: "tui", ui: { custom: consentCustom(async () => selections[index]) } } as unknown as ExtensionContext;
|
|
87
119
|
assert.deepEqual(await presentReviewConsentUi(ctx, envelope), expected[index]);
|
|
88
120
|
}
|
|
89
|
-
const failed = { ui: {
|
|
121
|
+
const failed = { mode: "tui", ui: { custom: consentCustom(async () => { throw new Error("UI unavailable"); }) } } as unknown as ExtensionContext;
|
|
90
122
|
assert.equal(await presentReviewConsentUi(failed, envelope), undefined);
|
|
91
123
|
});
|
|
92
124
|
|
|
125
|
+
test("non-TUI selection preserves the existing action and cancellation mapping", async () => {
|
|
126
|
+
const envelope = consent();
|
|
127
|
+
const model = formatReviewConsentUi(envelope);
|
|
128
|
+
const selections = [model.options[0], model.options[1], model.options[2], undefined] as const;
|
|
129
|
+
const expected = [
|
|
130
|
+
{ kind: "provider", answer: "granted" },
|
|
131
|
+
{ kind: "provider", answer: "declined" },
|
|
132
|
+
{ kind: "host-session" },
|
|
133
|
+
undefined,
|
|
134
|
+
];
|
|
135
|
+
for (let index = 0; index < selections.length; index += 1) {
|
|
136
|
+
const ctx = { mode: "rpc", ui: { select: async () => selections[index] } } as unknown as ExtensionContext;
|
|
137
|
+
assert.deepEqual(await presentReviewConsentUi(ctx, envelope), expected[index]);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
93
141
|
interface RegisteredTool {
|
|
94
142
|
execute(id: string, parameters: unknown, signal: undefined, onUpdate: undefined, context: ExtensionContext): Promise<{ details: Record<string, unknown> }>;
|
|
95
143
|
}
|
|
@@ -253,7 +301,7 @@ function interactiveContext(cwd: string, manager: object, select: (title: string
|
|
|
253
301
|
mode: "tui",
|
|
254
302
|
hasUI: true,
|
|
255
303
|
sessionManager: Object.assign(manager, { getSessionId: () => sessionId }),
|
|
256
|
-
ui: { select, notify() {}, setStatus() {}, theme: { fg: (_color: string, text: string) => text } },
|
|
304
|
+
ui: { custom: consentCustom(select), notify() {}, setStatus() {}, theme: { fg: (_color: string, text: string) => text } },
|
|
257
305
|
} as unknown as ExtensionContext;
|
|
258
306
|
}
|
|
259
307
|
|
|
@@ -484,7 +532,7 @@ test("headless, child, and changed post-UI identity never use host permission",
|
|
|
484
532
|
hasUI: true,
|
|
485
533
|
sessionManager: manager,
|
|
486
534
|
ui: {
|
|
487
|
-
|
|
535
|
+
custom: consentCustom(async (_title: string, options: string[]) => { manager.sessionId = "after"; return options[2]; }),
|
|
488
536
|
notify() {},
|
|
489
537
|
setStatus() {},
|
|
490
538
|
theme: { fg: (_color: string, text: string) => text },
|