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
7 changes: 4 additions & 3 deletions docs/canvases/agent-bundle-walkthrough.canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -403,8 +403,9 @@ export default function AgentBundleWalkthrough() {
<Text as="span" weight="semibold"> inputSchema</Text>, and
<Text as="span" weight="semibold"> resultSchema</Text> exports — there is no
execute/render split (exporting either is the AB4811 error). Other
route kinds (events, CLI commands, skills) carry their own export
contracts.
route kinds (events, CLI commands) carry their own export
contracts; skills are Markdown documents parsed from frontmatter
unless the rendered SKILL.tsx form is used.
</Text>
<Grid columns="1fr 1fr" gap={12} align="start">
<Card>
Expand Down Expand Up @@ -643,7 +644,7 @@ export default function AgentBundleWalkthrough() {
n={5}
title="Thin client prints the host-native response and exits 0"
channel="wrapper → Claude · stdout"
note="Claude blocks the Write and surfaces the reason to the model. If the route had decided to allow it, the wrapper prints an explicit hookSpecificOutput.permissionDecision: 'allow' (optionally with updatedInput / additionalContext); a route that renders no decision prints nothing."
note="Claude blocks the Write and surfaces the reason to the model. On tool/before the wrapper always answers: an explicit hookSpecificOutput.permissionDecision ('allow' unless the route denied, optionally with updatedInput / additionalContext) — even when the route renders no decision. Silence is reserved for observation-only families such as session/end."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit the silence claim to this tool/before step

The last sentence is broader than the implementation: projectEventDocument returns undefined for decision-capable stop when the outcome is not deny (packages/agent-bundle/src/events/projection.ts:398-402), and for prompt/submit when neither a block reason nor context is emitted (projection.ts:472-474). These families are not observation-only, so readers could incorrectly infer that every actionable hook always emits a response. Keep the explicit-allow statement scoped to Claude/Codex tool/before without claiming silence is exclusive to observation-only families.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #397 (merged as d25a9c6). The walkthrough canvas wording now matches events/projection.ts: silence is the default for observation-only families and is also valid for decision-capable families (stop, prompt/submit) that choose not to decide. The same edit was applied to the managed copy at ~/.cursor/projects/fast-projects-agent-bundle/canvases/agent-bundle-walkthrough.canvas.tsx; cmp confirms the two files are byte-identical.

payload={WIRE_STDOUT}
last
/>
Expand Down
21 changes: 15 additions & 6 deletions examples/audiobook-curator/src/evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,11 +260,13 @@ export const identifyAudibleSample = async (
for (const candidate of selected) {
const asin = String(candidate.asin ?? '');
const region = String(candidate.region ?? 'us') as AudibleRegion;
const score = asRecord(candidate.evidence).score as JsonValue | undefined;
// Receipts are strict JSON; absent fields are omitted, never undefined.
const base = {
asin: asin || undefined,
...(asin === '' ? {} : { asin }),
region,
score: asRecord(candidate.evidence).score as JsonValue | undefined,
title: candidate.title,
...(score === undefined ? {} : { score }),
...(candidate.title === undefined ? {} : { title: candidate.title }),
};
if (asin === '') {
attempts.push({ ...base, reason: 'candidate has no ASIN', status: 'skipped' });
Expand All @@ -276,20 +278,27 @@ export const identifyAudibleSample = async (
attempts: input.attempts,
chunkSeconds: input.chunkSeconds,
file: input.file,
// Staging follows the receipt's disk; without this, identification
// would stage beside source media the receipt was chosen to avoid.
...(input.receipt === undefined ? {} : { receipt: input.receipt }),
region,
...(typeof candidate.sample_url === 'string' ? { sampleUrl: candidate.sample_url } : {}),
verbose: input.verbose,
}, dependencies);
const found = outcome.fingerprint.found === true;
const attemptTitle = candidate.title ?? outcome.audible.title;
attempts.push({
...base,
fingerprint: outcome.fingerprint,
sampleUrl: outcome.audible.sampleUrl,
...(outcome.audible.sampleUrl === undefined ? {} : { sampleUrl: outcome.audible.sampleUrl }),
status: found ? 'matched' : 'no-match',
title: candidate.title ?? outcome.audible.title,
...(attemptTitle === undefined ? {} : { title: attemptTitle }),
});
if (found) {
identified ??= { asin, region, title: candidate.title ?? outcome.audible.title };
const title = candidate.title ?? outcome.audible.title;
// A titleless identification must not place an undefined (non-JSON)
// value into the durable receipt.
identified ??= { asin, region, ...(title === undefined ? {} : { title }) };
if (input.all !== true) break;
}
} catch (error) {
Expand Down
28 changes: 28 additions & 0 deletions examples/audiobook-curator/tests/evidence-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,32 @@ describe('optional identity evidence parity', () => {
expect(receipt).toMatchObject({ exitCode: 0, operation: 'whisper-identity', status: 'transcript-ready', usableWindows: 5 });
expect(whisperCalls).toBe(5);
});


it('stages acoustic-identify candidate work beside the receipt when one is requested', async () => {
const root = await mkdtemp(join(tmpdir(), 'curator-identify-receipt-'));
roots.push(root);
const file = join(root, 'library', 'book.m4b');
const receiptPath = join(root, 'receipts', 'identify.json');
await mkdir(dirname(file), { recursive: true });
await writeFile(file, 'book');
const sampleDirs: string[] = [];
const matcher: AcousticMatcher = async (_source, sample) => {
sampleDirs.push(dirname(sample));
return { found: true };
};
const http: CuratorHttpClient = async (url, options) => {
if (options?.binary === true) return Buffer.from(url);
throw new Error('unexpected product request');
};
await identifyAudibleSample({
candidates: [{ asin: 'MATCH', evidence: { score: 90 }, region: 'us', sample_url: 'https://samples/match.mp3' }],
candidatesReport: join(root, 'candidates.json'),
file,
receipt: receiptPath,
top: 1,
}, { http, matcher });
expect(sampleDirs).toHaveLength(1);
expect(sampleDirs[0]!.startsWith(join(dirname(receiptPath), '.audiobook-curator-acoustic-'))).toBe(true);
});
});
Loading