Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/framework/core/hooks/amadeus-mint-presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
} from "../tools/amadeus-lib.ts";
import {
choiceFromExactPrompt,
recordProtectedAdvisoryChoice,
recordAdvisoryChoice,
} from "../tools/amadeus-advisory-choice.ts";
import { detectHarnessType } from "../tools/amadeus-harness.ts";
import { initProcessObservability } from "../tools/amadeus-observability.ts";
Expand Down Expand Up @@ -139,11 +139,13 @@ try {
requireReservationRoute: detectHarnessType() === "kimi",
...(context.route === null ? {} : { route: context.route }),
});
if (context.prompt !== null) {
const promptChoice = context.prompt === null ? null : choiceFromExactPrompt(context.prompt);
if (promptChoice !== null) {
const turns = findAllEvents(readFileSync(auditFilePath(projectDir), "utf-8"), "HUMAN_TURN");
const latest = turns[turns.length - 1];
if (latest !== undefined) {
recordProtectedAdvisoryChoice(projectDir, context.prompt, {
recordAdvisoryChoice(projectDir, promptChoice, {
kind: "human-turn",
timestamp: latest.timestamp,
shard: auditShardName(projectDir),
eventIdentity: createHash("sha256").update(latest.block).digest("hex"),
Expand Down
383 changes: 338 additions & 45 deletions packages/framework/core/tools/amadeus-advisory-choice.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type AutonomyProjection,
type DecisionPolicyInput,
type DecisionFact,
type EffectClassification,
type GrantScopeDescriptor,
type HumanAutonomyCommand,
type InteractionKind,
Expand Down Expand Up @@ -70,7 +71,10 @@ const ALL_INTERACTIONS: readonly InteractionKind[] = [
"question",
];

const PROHIBITED_EFFECTS = [
// Exported so a caller that builds its own option effects can prove, in a test
// it owns, that the classification it assigns to a refusable option is still
// one this scope forbids (#2253 FR-ADV-4 secondary barrier).
export const PROHIBITED_EFFECTS = [
"new-permission",
"irreversible",
"scope-out",
Expand Down Expand Up @@ -541,6 +545,13 @@ export interface ProductionQuestionDecisionInput {
readonly applicableNormFacts?: readonly DecisionFact[];
readonly pastHumanRulings?: readonly DecisionFact[];
readonly election?: { readonly optionId: string; readonly evidenceFingerprint: string };
// Per-option effect classification. A question whose options are all ordinary
// workflow moves needs none — the default is `workflow-reversible`, which is
// what every caller before #2253 relied on. A caller whose option space
// contains a move that WAIVES something (advisory `defer-with-risk`) names it
// here, and effect authorization then refuses that option on its own, without
// this adapter growing a policy branch.
readonly effectClassifications?: Readonly<Record<string, EffectClassification>>;
}

export function commitProductionQuestionDecision(input: ProductionQuestionDecisionInput): AutonomyDecisionResult {
Expand Down Expand Up @@ -570,7 +581,7 @@ export function commitProductionQuestionDecision(input: ProductionQuestionDecisi
optionId,
payload,
payloadFingerprint: autonomyDigest(payload),
classification: "workflow-reversible" as const,
classification: input.effectClassifications?.[optionId] ?? ("workflow-reversible" as const),
requiredScopeFingerprint: scopeFingerprint,
applicableNormFingerprint: normFingerprint,
};
Expand Down
34 changes: 33 additions & 1 deletion packages/framework/core/tools/amadeus-orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ import {
advisoryReportHoldReason,
closeAdvisoryInstancesForStage,
guardAdvisoryChoices,
recordAdvisoryChoice,
resolveAdvisoryChoiceAutonomously,
} from "./amadeus-advisory-choice.ts";
import {
buildIntentSelectionSnapshot,
Expand Down Expand Up @@ -795,13 +797,43 @@ function applyPendingAdvisoryGuard(directive: Directive): Directive {
const pending = takePendingAdvisories();
if (pending.length === 0) return directive;
if (directive.kind !== "run-stage" && directive.kind !== "dispatch-subagent") return directive;
const advisoryProjectDir = resolveProjectDir(_handlerProjectDir);
const guard = guardAdvisoryChoices(
resolveProjectDir(_handlerProjectDir),
advisoryProjectDir,
directive.stage,
pending,
pluginActivationHostRoot(),
);
if (guard.kind === "allow") return directive;
// #2253 FR-ADV-1/2. A hold is offered to the autonomy ladder before it is
// turned into a question for the human. There are exactly TWO ways out: the
// ladder decided `run-now` and the receipt was accepted, in which case the
// ORIGINAL directive is returned untouched and the run continues unattended;
// or anything else at all — no grant, an expired one, a scope that does not
// cover this interaction, a park, a conflict, a deferral, a refused receipt —
// in which case the human is asked, exactly as before. Two branches is the
// whole fail-closed argument: there is no third place to land.
const graphRevision = autonomyDigest(loadGraph());
const auto = resolveAdvisoryChoiceAutonomously({
projectDir: advisoryProjectDir,
hold: guard,
phase: directive.phase,
graphRevision,
});
if (
auto.kind === "resolved"
&& recordAdvisoryChoice(advisoryProjectDir, auto.choice, {
kind: "auto-decision",
decisionId: auto.decision.decisionId,
basisKind: auto.decision.basisKind,
basisFingerprint: auto.decision.basisFingerprint,
projectionRevision: auto.projectionRevision,
phase: directive.phase,
graphRevision,
})
) {
return directive;
}
const choiceDirective: AwaitAdvisoryChoiceDirective = {
kind: "await-advisory-choice",
stage: guard.stage,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import {
isMachineInjectedTurnText,
stateFilePath,
} from "../tools/amadeus-lib.ts";
import { recordProtectedAdvisoryChoice } from "../tools/amadeus-advisory-choice.ts";
import { choiceFromExactPrompt, recordAdvisoryChoice } from "../tools/amadeus-advisory-choice.ts";
import { hostSessionCapability, mintHumanPresence } from "../tools/amadeus-presence-reservation.ts";
import { spawnHookWithRuntime } from "./amadeus-codex-hook-runtime.ts";

Expand Down Expand Up @@ -387,11 +387,13 @@ switch (target) {
projectDir,
capability: hostSessionCapability(codex.session_id),
});
if (typeof codex.prompt === "string") {
const promptChoice = typeof codex.prompt === "string" ? choiceFromExactPrompt(codex.prompt) : null;
if (promptChoice !== null) {
const turns = findAllEvents(readFileSync(auditFilePath(projectDir), "utf-8"), "HUMAN_TURN");
const latest = turns[turns.length - 1];
if (latest !== undefined) {
recordProtectedAdvisoryChoice(projectDir, codex.prompt, {
recordAdvisoryChoice(projectDir, promptChoice, {
kind: "human-turn",
timestamp: latest.timestamp,
shard: auditShardName(projectDir),
eventIdentity: createHash("sha256").update(latest.block).digest("hex"),
Expand Down
26 changes: 24 additions & 2 deletions tests/.coverage-patch-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -3844,6 +3844,28 @@
"reason": "Defensive startup-failure classification for git show. The integration suite drives the observable non-zero lookup failure; making spawnSync itself fail to start would require removing the running test process's git executable or injecting a process port. The distinct INTERNAL_ERROR branch is retained fail closed.",
"expiry": "remove when baselineAtRevision receives an injected process port that can model startup failure"
},
{
"file": "packages/framework/core/tools/amadeus-advisory-choice.ts",
"selector": {
"function": "advisoryOccurrenceMatchesDecision",
"fingerprint": "sha256:799d1c5f4401c6ade45fffbe154d25ef353610a8f9fd6261328a51ecdd9fa451",
"anchorLines": 5,
"targetLines": "1-5"
},
"reason": "These lines contain only the multiline TypeScript input type. The function body — the true, false, and fail-closed catch outcomes — is covered directly by t459 unit tests, but Bun emits zero-hit DA records for the type-only signature only in the merged suite.",
"expiry": "remove when the patch gate excludes type-only lines or Bun stops emitting DA records for them"
},
{
"file": "packages/framework/core/tools/amadeus-advisory-choice.ts",
"selector": {
"function": "resolveAdvisoryChoiceAutonomously",
"fingerprint": "sha256:0a9e7bf8c6eaf43ece44a49989a8bb312185246b2baab4538c033935c5d94a9e",
"anchorLines": 4,
"targetLines": "1-4"
},
"reason": "These lines contain only the multiline TypeScript input type. The function body — the resolved, human-required, and decision-failure catch outcomes — is covered directly by t458 integration tests, but Bun emits zero-hit DA records for the type-only signature only in the merged suite.",
"expiry": "remove when the patch gate excludes type-only lines or Bun stops emitting DA records for them"
},
{
"file": "packages/framework/core/tools/amadeus-advisory-choice.ts",
"selector": {
Expand Down Expand Up @@ -6304,7 +6326,7 @@
"anchorLines": 1,
"targetLines": "1"
},
"reason": "humanTurn.shard is derived from auditShardName(projectDir) inside latestHumanTurn one call earlier, so the mismatch arm is unreachable through the record flow; kept for contract parity with recordProtectedAdvisoryChoice, whose caller-supplied provenance CAN mismatch.",
"reason": "humanTurn.shard is derived from auditShardName(projectDir) inside latestHumanTurn one call earlier, so the mismatch arm is unreachable through the record flow; kept for contract parity with recordAdvisoryChoice, whose caller-supplied provenance CAN mismatch.",
"expiry": "remove when freshRecordRefusal accepts caller-supplied provenance"
},
{
Expand Down Expand Up @@ -6333,7 +6355,7 @@
"file": "packages/framework/core/tools/amadeus-advisory-choice.ts",
"selector": {
"function": "<module>",
"fingerprint": "sha256:bda22f12c1bef0a92b2d9e1cc7584392cb9b5308725357f96695c373768afb13",
"fingerprint": "sha256:438bc9257a619c60699f99b7a7a1c2ce77c06bda3f0d1bd85d807d15251fd826",
"anchorLines": 23,
"targetLines": "1-23"
},
Expand Down
4 changes: 4 additions & 0 deletions tests/.coverage-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,10 @@
{
"file": "tests/integration/t453-semi-ladder-runtime.integration.test.ts",
"mechanism": "none"
},
{
"file": "tests/integration/t458-advisory-auto-resolution.integration.test.ts",
"mechanism": "cli"
}
],
"status": "covered"
Expand Down
33 changes: 28 additions & 5 deletions tests/integration/t-advisory-choice-record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ import {
createPendingAdvisory,
guardAdvisoryChoices,
recordAdvisoryChoiceDecision,
recordProtectedAdvisoryChoice,
choiceFromExactPrompt,
recordAdvisoryChoice,
type AdvisoryChoiceStore,
type PendingAdvisory,
} from "../../packages/framework/core/tools/amadeus-advisory-choice.ts";
Expand All @@ -52,6 +53,25 @@ import {
import { resetOtelPerProject } from "../harness/otel-reset.ts";
import { plantV1AuditRow } from "../harness/v1-audit-fixture.ts";

// #2253 replaced the prompt-classifying acceptance entry point with one that
// takes an already-classified choice and a provenance union. These tests were
// written against the prompt shape, and what they pin — which prompts count and
// which provenance is refused — is unchanged, so they keep exercising the same
// route through the same two steps the hook now performs.
function recordAdvisoryChoiceViaPrompt(
projectDir: string,
prompt: string,
humanTurn: { timestamp: string; shard: string; eventIdentity: string },
now?: string,
): boolean {
const choice = choiceFromExactPrompt(prompt);
if (choice === null) return false;
return now === undefined
? recordAdvisoryChoice(projectDir, choice, { kind: "human-turn", ...humanTurn })
: recordAdvisoryChoice(projectDir, choice, { kind: "human-turn", ...humanTurn }, now);
}


const identity = {
plugin: "formal-model-check",
code: "changed" as const,
Expand Down Expand Up @@ -133,10 +153,10 @@ describe("advisory choice record: acceptance", () => {
const receipts = readStore(projectDir).receipts;
expect(receipts).toHaveLength(1);
expect(receipts[0]).toMatchObject({
schema: 1,
schema: 2,
identity: pending.identity,
choice: "defer-with-risk",
humanTurn: turn,
provenance: { kind: "human-turn", ...turn },
recordedAt: "2026-08-05T00:00:00.000Z",
});
});
Expand All @@ -160,13 +180,16 @@ describe("advisory choice record: acceptance", () => {

expect(result.ok).toBe(true);
if (!result.ok) return;
expect(readStore(projectDir).receipts[0]).toMatchObject({ choice: "run-now", humanTurn: turn });
expect(readStore(projectDir).receipts[0]).toMatchObject({
choice: "run-now",
provenance: { kind: "human-turn", ...turn },
});
});

test("the prompt route still works and is not disturbed by the new one", () => {
const { projectDir, pending } = track(seedPendingProject());
plantPresentation(projectDir, pending);
expect(recordProtectedAdvisoryChoice(projectDir, "1", plantHumanTurn(projectDir))).toBe(true);
expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", plantHumanTurn(projectDir))).toBe(true);
expect(readStore(projectDir).receipts[0]).toMatchObject({
identity: pending.identity,
choice: "run-now",
Expand Down
Loading
Loading