feat(harness): single-select answers auto-confirm (reviewer_select persists)
F — reviewer_select options may now carry a `decision` payload {type, subject,
detail?, rationale?} plus an optional `advance`. Picking such an option IS the
confirmation: the gate persists it directly (tht decision add) and optionally
advances, with no redundant reviewer_decide/reviewer_confirm follow-up gate.
Options without a payload stay ask-only; back/exit/Other never persist.
Pure logic extracted + exported for unit tests: resolveSelectOutcome (classifies
the response) and decisionAddArgs (shared with reviewer_decide, DRY). Gate JS
suite 33/33 (gate_select_decision.test.js, +5); harness pytest 269 unchanged.
Contract docs updated together: reviewer_select tool description, SKILL.md
(widget summary, disciplines 2-3, Phase-1 single-pick), and the CLAUDE.md gate
note. Live verification (model truly emits reviewer_select+decision, decision in
review_decisions.jsonl, no follow-up gate) deferred to workstream G — it is
model-behavior-dependent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -215,6 +215,38 @@ export function selectedChoice(resp) {
|
||||
return resp?.choice;
|
||||
}
|
||||
|
||||
// Builds the `tht decision add` argv for one decision payload {type, subject, detail?,
|
||||
// rationale?}. Shared by reviewer_decide (multiselect) and reviewer_select (auto-confirm).
|
||||
export function decisionAddArgs(session, d) {
|
||||
const args = [
|
||||
"decision",
|
||||
"add",
|
||||
"--session",
|
||||
session,
|
||||
"--type",
|
||||
d.type,
|
||||
"--subject",
|
||||
d.subject,
|
||||
];
|
||||
if (d.detail) args.push("--detail", d.detail);
|
||||
if (d.rationale) args.push("--rationale", d.rationale);
|
||||
return args;
|
||||
}
|
||||
|
||||
// Workstream F: classifies a reviewer_select response into the action the gate takes.
|
||||
// A concrete choice that carries a `decision` payload auto-confirms (persist directly,
|
||||
// no second gate); a bare choice stays ask-only; back/exit/Other never persist.
|
||||
export function resolveSelectOutcome(opts, resp) {
|
||||
if (resp?.control === "freetext") return { kind: "freetext", text: resp.text };
|
||||
if (resp?.control === "back") return { kind: "back" };
|
||||
if (resp?.control === "exit") return { kind: "exit" };
|
||||
const choice = selectedChoice(resp);
|
||||
const option = (opts || []).find((o) => o.id === choice) || null;
|
||||
if (option && option.decision)
|
||||
return { kind: "decision", option, decision: option.decision };
|
||||
return { kind: "choice", option, choice };
|
||||
}
|
||||
|
||||
export async function emitAndWait(ctx, descriptor) {
|
||||
for (;;) {
|
||||
const value = await ctx.ui.input(JSON.stringify(descriptor), "");
|
||||
@@ -365,8 +397,12 @@ export default function (pi) {
|
||||
label: "Domanda a scelta (reviewer)",
|
||||
description:
|
||||
"Pone una domanda a scelta singola al reviewer via un widget select. Le opzioni di " +
|
||||
"controllo (Altro/Torna indietro/Esci) sono sempre presenti. NON persiste: serve a " +
|
||||
"chiedere, non a decidere.",
|
||||
"controllo (Altro/Torna indietro/Esci) sono sempre presenti. La scelta su un'opzione " +
|
||||
"concreta È la conferma: se quell'opzione porta un payload `decision` {type, subject, " +
|
||||
"detail?, rationale?}, la decisione viene PERSISTITA direttamente (tht decision add) " +
|
||||
"senza un secondo gate di conferma; se p.advance è vero, tenta tht phase advance --if-ready. " +
|
||||
"Un'opzione SENZA `decision` resta solo-richiesta (non persiste). Altro/Torna indietro/Esci " +
|
||||
"non persistono mai e tornano come testo da gestire.",
|
||||
parameters: Type.Object({
|
||||
session: Type.String({
|
||||
description: "Id sessione (per determinare la fase).",
|
||||
@@ -376,15 +412,24 @@ export default function (pi) {
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
label: Type.String(),
|
||||
decision: Type.Optional(
|
||||
Type.Object({
|
||||
type: Type.String(),
|
||||
subject: Type.String(),
|
||||
detail: Type.Optional(Type.String()),
|
||||
rationale: Type.Optional(Type.String()),
|
||||
}),
|
||||
),
|
||||
recommended: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
),
|
||||
intro: Type.Optional(Type.String()),
|
||||
advance: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
prepareArguments: prepareReviewerArguments,
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
lockActive = true;
|
||||
const { session, title, options: opts, intro } = params;
|
||||
const { session, title, options: opts, intro, advance } = params;
|
||||
const phase = phaseName(ctx, currentPhase(ctx, session));
|
||||
const recommended = opts.find((o) => o.recommended)?.id ?? null;
|
||||
|
||||
@@ -399,17 +444,30 @@ export default function (pi) {
|
||||
.map((o) => ({ id: o.id, label: o.label })),
|
||||
});
|
||||
const resp = await emitAndWait(ctx, widget);
|
||||
const outcome = resolveSelectOutcome(opts, resp);
|
||||
// control responses (back/exit/other) are surfaced as text for the model to act on.
|
||||
if (resp.control === "freetext")
|
||||
return textResult(`Altro (reviewer): ${resp.text}`);
|
||||
if (resp.control === "back")
|
||||
if (outcome.kind === "freetext")
|
||||
return textResult(`Altro (reviewer): ${outcome.text}`);
|
||||
if (outcome.kind === "back")
|
||||
return textResult("Il reviewer vuole tornare indietro.");
|
||||
if (resp.control === "exit")
|
||||
if (outcome.kind === "exit")
|
||||
return textResult("Il reviewer vuole uscire.");
|
||||
const choice = selectedChoice(resp);
|
||||
const chosen = opts.find((o) => o.id === choice);
|
||||
// concrete choice carrying a decision -> auto-confirm: persist directly, no second gate.
|
||||
if (outcome.kind === "decision") {
|
||||
const err = relayIfThtFails(
|
||||
ctx,
|
||||
decisionAddArgs(session, outcome.decision),
|
||||
"",
|
||||
);
|
||||
if (err) return err;
|
||||
if (advance) advanceIfReady(ctx, session);
|
||||
return textResult(
|
||||
`Decisione registrata (${outcome.decision.type}): ${outcome.option.label}.`,
|
||||
);
|
||||
}
|
||||
// bare choice (no decision payload) -> ask-only, non-persisting.
|
||||
return textResult(
|
||||
`Scelta del reviewer: ${chosen ? chosen.label : choice}`,
|
||||
`Scelta del reviewer: ${outcome.option ? outcome.option.label : outcome.choice}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -469,19 +527,7 @@ export default function (pi) {
|
||||
const chosen = opts.filter((o) => (resp.choices ?? []).includes(o.id));
|
||||
for (const c of chosen) {
|
||||
const d = c.decision;
|
||||
const args = [
|
||||
"decision",
|
||||
"add",
|
||||
"--session",
|
||||
session,
|
||||
"--type",
|
||||
d.type,
|
||||
"--subject",
|
||||
d.subject,
|
||||
];
|
||||
if (d.detail) args.push("--detail", d.detail);
|
||||
if (d.rationale) args.push("--rationale", d.rationale);
|
||||
const err = relayIfThtFails(ctx, args, "");
|
||||
const err = relayIfThtFails(ctx, decisionAddArgs(session, d), "");
|
||||
if (err) return err;
|
||||
toAdd.push(d);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user