44 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 744 | pi-permission-system: introduce the structured PromptPayload and dissolve the five prompt-assembly sites |
Structured PromptPayload and the dissolution of the ask-prompt assembly sites
Release Recommendation
Release: mid-batch — defer (batch "presentation-payload"); confirm at ship time
Phase 13 Step 1 is the first member of the presentation-payload batch (Steps 1 and 2); the tail is Step 2 (#710), whose fix: is the batch's release vehicle.
Every commit in this plan is a hidden changelog type (refactor: / test: / docs:), so this step cuts no release on its own even if the release-please PR is merged.
Leave the release-please PR unmerged until Step 2 lands.
Problem Statement
Presentation is fused with decision-making.
Each gate renders its facts into an English sentence at the point of decision, and that flat string becomes PromptPermissionDetails.message, travelling unchanged to the inline TUI dialog, the select/input fallback, the review log, the permissions:ui_prompt broadcast, and the on-disk forwarded request.
Because the payload is a pre-rendered sentence, elision is a property of the payload rather than of a render. That is the direct cause of the open items ADR 0011 catalogues: the bash branch has no cap at all, nothing bounds height (#710), and a forwarded ask is assembled twice under two different configs, so consistency across local and forwarded asks is structurally unattainable.
ADR 0011 §2 states the rule this step implements: the payload is complete by contract, and elision is a property of a render, never of the payload. Nothing downstream can be bounded until the payload exists, so this step is the prerequisite for the rest of Phase 13.
Correction: six assembly sites, not five
The issue body and ADR 0011 §"What the code did before this decision" both enumerate five sites.
A grep of src/ for the shared subject idiom (Agent '<name>' / Current agent) finds a sixth:
grep -rn "Current agent" packages/pi-permission-system/src --include="*.ts"
The complete inventory of ask-message producers at main:
| # | Function | File | Consumers |
|---|---|---|---|
| 1 | formatAskPrompt (bash / MCP / generic branches) |
src/permission-prompts.ts |
handlers/gates/tool.ts |
| 2 | formatSkillAskPrompt |
src/permission-prompts.ts |
handlers/gates/skill-input.ts |
| 3 | formatSkillPathAskPrompt |
src/permission-prompts.ts |
handlers/gates/skill-read.ts |
| 4 | formatExternalDirectoryAskPrompt |
src/handlers/gates/external-directory-messages.ts |
handlers/gates/external-directory.ts |
| 5 | formatBashExternalDirectoryAskPrompt |
src/handlers/gates/external-directory-messages.ts |
handlers/gates/bash-external-directory.ts |
| 6 | formatPathAskPrompt |
src/handlers/gates/path.ts |
handlers/gates/path.ts, handlers/gates/bash-path.ts |
| 7 | formatForwardedPermissionPrompt (module-private) |
src/authority/forwarded-request-server.ts |
the serving node's ask |
Site 6 is the one both the issue and the ADR omit, and it has two consumers.
Seven gate descriptors set promptDetails.message: tool.ts, path.ts, bash-path.ts, external-directory.ts, bash-external-directory.ts, skill-input.ts, skill-read.ts.
ToolPreviewFormatter.formatToolInputForPrompt feeds site 1 and is the per-tool preview the issue counts among the five; it becomes an evidence entry rather than a migrated module (see Non-Goals).
Correction: executedUnit has no existing source
Issue #713 cites classifyAndExtractWrapper, a payloadText: null return, and a STRIPPABLE_WRAPPERS set.
None of those exist in src/access-intent/bash/command-enumeration.ts at main.
What exists is classifyWrapperCommand, which returns only a WrapperKind discriminant — it flags a wrapper and never extracts the inner command.
So timeout 10 grep foo does not currently surface grep foo; PermissionCheckResult.command carries the whole wrapper text for every wrapper kind, strippable or not.
Populating request.executedUnit therefore requires new extraction logic, planned below.
Goals
- Add
PromptPayload— therequestinvariant core (ADR 0011 §3), the completeevidencelist, and theannotationsslot — as the structured description of a permission ask. - Seed
src/presentation/and land the six ask-prompt assemblers there as payload builders, so the modules the spine rewrites reach their final home the first time. - Derive
messagefrom the payload via a single transitionalrenderLegacyMessage(payload), so the existing prompt-text tests become the payload's completeness proof. - Carry the executed unit of a bash wrapper as a payload fact (#713's fact, display-only, never gating).
- Keep behavior byte-identical: every rendered
messagestring is unchanged, pinned by the existing suite.
This change is not breaking. It adds optional-then-required internal fields and two new public types; no existing observable behavior, output shape, or default changes.
Non-Goals
- Any renderer change — the dialog, fallback, log, broadcast, and agent renderers all keep reading
message(Steps 2, 3, 4: #710, #745, #746). - Replacing
messageon the forwarded wire or thepermissions:ui_promptbroadcast (Step 3, #745). - Writing the payload to the review log —
logContext.messagestays the rendered string, so ADR 0010's log-growth bound is untouched (Step 4, #746). - Migrating
tool-preview-formatter.ts,tool-input-prompt-formatters.ts, ortool-input-preview.tsintosrc/presentation/— they also servegetPermissionLogContexton the review-log path, which Step 4 owns. Their prompt output is wrapped as an evidence entry instead. - Migrating
denial-messages.ts(Step 4) — it keeps owningresolvesToSuffix,ExternalPathDisclosure,matchQualifier, anddescribeBashCommandContext, which the presentation modules import. - Gating the extracted inner command.
#713's "gateable extraction" option is explicitly declined: the wrapper floor (#490) stands unchanged,
executedUnitis display-only, and noBashCommandunit is added or removed. - The annotator and evidence-formatter registries (ADR 0011 §8) — the
annotationsslot lands empty. - Soft-deprecating
toolInputPreviewMaxLength/toolTextSummaryMaxLength(Step 3, ADR 0011 §5). docs/architecture/v3-architecture.md— a frozen design-era snapshot, not maintained as current state (established at #437, #559).
Background
Modules in play
src/handlers/gates/descriptor.ts—GateDescriptor.promptDetails: Omit<PromptPermissionDetails, "requestId">, the single funnel every gate's prompt facts pass through.src/authority/permission-prompter.ts— declaresPromptPermissionDetails, and writesdetails.messageto the review log.src/service.ts:39re-exportsPromptPermissionDetails, so it is part of the publicdist/public.d.tsbundle, gated byscripts/verify-public-types.sh.src/access-intent/bash/command-enumeration.ts—BashCommand(text,context?,wrapperKind?),classifyWrapperCommand,readWrapperCommand,commandUnitText,INDIRECTION_WRAPPER_NAMES,EXEC_CONDITIONAL_WRAPPERS,SHELL_WRAPPER_NAMES.BashCommand's doc comment names the type as the stable extension point (#306 addedcontext).src/handlers/gates/bash-command.ts—resolveBashCommandCheckmaps eachBashCommandto aPermissionCheckResultand tags the winner withcommandContext; the same place can tagexecutedUnit.src/denial-messages.ts— the established precedent for this change's shape: a structuredDenialContextdiscriminated union rendered at the edge, which ADR 0011 §7 explicitly cites approvingly.
Constraints from AGENTS.md and the package skill
- ADR 0002's string boundary:
permission-manager.tsmust not importAccessPath. The payload carries only strings and enums, so it never crosses that boundary. - No
process.platformread insidesrc/outsideindex.ts— the new modules read none. docs/architecture/architecture.mdmodule-tree entries describe current behavior; cite an issue only when the ref encodes an active constraint.- The roadmap step must be marked
✅(heading + Mermaid node) in the implementation doc-update commit, not deferred to ship. - Health-metric rows naming symbols the phase has not created yet must either use the roadmap's name or be updated in the same commit.
Measured baselines (2026-08-15)
| Metric | Command | Baseline | Target |
|---|---|---|---|
| Flat-assembler sites | grep -rn "formatAskPrompt" packages/pi-permission-system/src --include="*.ts" | wc -l |
4 | 0 |
src/presentation/ present |
ls packages/pi-permission-system/src | grep -c presentation |
0 | 1 |
Both were run at planning time against main; the numbers are measured, not estimated.
Collision: PR #738
PR #738 ("Highlight the flagged command, path, or target in TUI permission prompts", opened 2026-08-14) touches tool.ts, path.ts, bash-path.ts, external-directory.ts, bash-external-directory.ts, permission-prompter.ts, forwarded-request-server.ts, and permission-prompt-component.ts — nearly every file this step rewrites.
It is untriaged and unmentioned in the roadmap's open-issue sweep or in ADR 0011.
Under ADR 0011 highlighting is a render concern, exactly as #716's aligned one-fact-per-line intent is.
Disposition (operator-decided at planning): its intent is adopted in Step 2's dialog renderer with authorship credited, and the PR is closed as superseded rather than rebased.
This plan records the disposition in the roadmap; the comment and close happen at ship time.
Design Overview
The payload
/** Which ask this payload describes; the renderers' dispatch discriminant. */
export type PromptPayloadKind =
| "bash"
| "mcp"
| "tool"
| "path"
| "external_directory"
| "bash_external_directory"
| "skill"
| "skill_read"
| "forwarded";
/**
* One piece of decision evidence. Complete on the payload; each renderer elides
* to fit its own budget (ADR 0011 §2, §4).
*/
export interface PromptEvidence {
readonly label: string;
readonly text: string;
/**
* A secondary fact bound to this entry that a renderer may show alongside or
* elide independently — e.g. a symlink-resolved alias for an external path.
*/
readonly detail: string | null;
}
/** A model-generated advisory; the slot owns its attribution and marking. */
export interface PromptAnnotation {
readonly source: string;
readonly text: string;
}
/** The invariant core: facts no renderer may elide (ADR 0011 §3). */
export interface PromptRequestFacts {
readonly requester: {
readonly agentName: string | null;
readonly forwarded: boolean;
readonly sessionId: string | null;
};
/** The gate surface the rule fired on. */
readonly surface: string;
/** The gated tool name; `null` when the ask is not tool-shaped. */
readonly toolName: string | null;
/** The invoked tool name when a shell alias re-exposes bash (#574); else `null`. */
readonly invokedToolName: string | null;
/** The decision-relevant value — the gate's own decision value. */
readonly value: string;
/** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
readonly matchedPattern: string | null;
/** The offending bash unit's execution context, when nested. */
readonly commandContext: BashCommandContext | null;
/** The unit that will actually run inside a wrapper (#713); `null` when it adds nothing. */
readonly executedUnit: string | null;
}
export interface PromptPayload {
readonly kind: PromptPayloadKind;
readonly request: PromptRequestFacts;
readonly evidence: readonly PromptEvidence[];
readonly annotations: readonly PromptAnnotation[];
}
Three deliberate divergences from ADR 0011 §2's illustrative sketch, each recorded here because the ADR assigns the exact types to this issue:
kinddiscriminant. The sketch has no discriminant, but nine distinct message shapes must be regenerated, and(surface, source)does not separate them — the tool external-directory ask and the bash external-directory ask share surfaceexternal_directory, and the path gate and the per-tool gate differ only in wording. AkindmirrorsDenialContext's existing discriminated union, which ADR 0011 §7 already names as the shape to copy, and it gives every renderer an exhaustiveswitchwith aneverguard instead of a set of string comparisons a new variant sails past.matchedPattern: string | null, notstring | undefined. Step 3 puts the payload on the on-disk forwarded wire, whereundefineddoes not survive JSON. The package already made this call for the same reason —accessFactsFromPathmaps an emptyboundaryValue()tonull"so the wire distinguishes 'no canonical form' cleanly". Uniform| nullacross the payload keeps Step 3 from re-deciding it field by field.commandContexton the request facts. Today's bash prompt rendersmatchQualifier(matchedPattern, commandContext), e.g.(matched 'rm *', inside command substitution). The matched rule is invariant-core by ADR 0011 §3.3, and the context is what makes it intelligible — so the rawBashCommandContextenum is a request fact anddescribeBashCommandContextstays a render. Putting the rendered qualifier inevidenceinstead would put a pre-rendered clause back in the payload, which is the thing being removed.
evidence is a list, not a record, because ADR 0011 §4 lets a renderer elide entries and order them under its own budget.
The builders and the transitional renderer
The gate computes its decision value already; the builder receives it rather than re-deriving it, so request.value is by construction the same string the decision event carries — and src/presentation/ never imports from src/handlers/.
Consumer call site (describeToolGate, ~5 lines, replacing the formatAskPrompt call):
const decisionValue = deriveDecisionValue(gateSurface, check, toolPath);
const payload = buildToolAskPayload({
check,
agentName: tcc.agentName,
invokedToolName: shell ? tcc.toolName : null,
value: decisionValue,
input: tcc.input,
formatter,
});
const askMessage = renderLegacyMessage(payload);
The gate hands over the facts it holds and asks for nothing back but the payload; the message is a render over that payload, not a second assembly.
renderLegacyMessage reads only the payload — that is what makes the existing byte-identity tests a completeness proof.
Evidence contributed per kind, and what the legacy render does with it:
| Kind | Evidence entries | Legacy render |
|---|---|---|
bash |
full command when it differs from the unit |
(full command: '…') |
mcp / tool |
input when the preview is non-empty |
appended verbatim |
path |
none | — |
external_directory |
resolves to (when the canonical alias differs), working directory |
(resolves to '…') + outside working directory '…' |
bash_external_directory |
working directory, then one external path per disclosure with detail = canonical alias |
the comma-joined path list |
skill |
none | — |
skill_read |
read path |
via '…' |
forwarded |
requested = the child's relayed message |
the three-line prefix + that text |
renderLegacyMessage is transitional and deliberately label-coupled to those entries.
It is deleted when the last message reader goes — Step 3 for the wire and broadcast, Step 4 for the review log.
The forwarded kind is the version-skew reality until Step 3: the child ships a pre-rendered sentence, so the serving node's payload carries it as a single evidence entry.
Step 3 replaces that entry with the child's own payload.
Executed-unit extraction
New pure module src/access-intent/bash/executed-unit.ts, consumed only by command-enumeration.ts:
/**
* The command that will actually run inside a wrapper unit, or `null` when it
* cannot be established. Display-only: never gated, never a `BashCommand`.
*/
export function extractExecutedUnit(node: TSNode, kind: WrapperKind): string | null;
Interaction with its upstream dependency is a read of the same command node classifyWrapperCommand already inspects, through the same shallow named-child walk readWrapperCommand performs — no re-parse, no async, no mutation of the node, and no second traversal of the program.
Algorithm:
opaque-payload— take the inline-shell payload argument (the first non-flag argument after the-ccluster for a shell; the first argument foreval) and strip one layer of matching surrounding quotes. The payload is an inner program, so it is unquoted rather than sliced.indirection— skip the wrapper name, then skip leadingvariable_assignmentchildren and leading option tokens, consuming a following value token for options in a curated per-wrapper value-taking set (sudo -u,xargs -n/-P/-I/-d/-L/-s/-a/-E,timeout's leading duration and-s/-k,nice -n,env -u/-C,stdbuf -i/-o/-e,watch -n,flock's leading file/fd and-w/-E,time -o/-f,doas -u/-C). The executed unit is thennode.textsliced from the first remaining child'sstartIndex, exactly ascommandUnitTextslices past an assignment prefix — so spacing and quoting are preserved verbatim.- exec-conditional (
find/fd) — slice from the token after the matched exec flag, up to and including a;/+terminator forfind, to the end forfd. - Nesting — re-apply while the remainder's head basename is itself a wrapper name (
sudo timeout 5 xargs grep foo→grep foo), capped at a documented depth. - Fail to
null, never to a guess — an empty remainder, a remainder whose head still begins with-, a barexargs(which defaults toecho), or an exceeded depth yieldsnull.
A curated per-wrapper table matches the package's existing convention (src/bash-arity.ts is a curated dictionary; it holds session-approval prefix arity, not flag arity, so it is not reusable here).
Because the fact is display-only and never gates, an imprecise entry is a cosmetic defect and never a permission bypass — and the fail-to-null rule keeps it from being a misleading one on a decision surface.
Plumbing, one field per hop, following commandContext's existing path:
BashCommand.executedUnit?: string— set bymakeUnitwhenclassifyWrapperCommandreturns a kind and extraction succeeds.PermissionCheckResult.executedUnit?: string— tagged onto the winning result inresolveBashCommandCheck, alongside the existingcommandContexttag.PromptRequestFacts.executedUnit: string | null— the builder emitsnullwhen the extracted unit equalsrequest.value, so a render shows it only when it adds information.
PromptPermissionDetails.payload
payload is added optional, populated at every one of the seven descriptor sites plus the forwarded server, then tightened to required in the final cycle.
Required is the point: it makes "every ask has a complete structured payload" a compile-time guarantee rather than a convention, which is exactly Step 1's stated outcome.
PromptPermissionDetails is re-exported through src/service.ts, so PromptPayload and its member types enter dist/public.d.ts transitively via the rollup-dts bundle.
An external Authorizer link consumes details and is unaffected by a widened type; only a constructor breaks, and the only constructors are in this package and its tests.
Module-Level Changes
Added
| Path | Contents |
|---|---|
src/presentation/prompt-payload.ts |
PromptPayloadKind, PromptEvidence, PromptAnnotation, PromptRequestFacts, PromptPayload |
src/presentation/legacy-message.ts |
renderLegacyMessage(payload) — exhaustive switch on kind with a never guard; imports resolvesToSuffix / matchQualifier from denial-messages.ts |
src/presentation/tool-ask-payload.ts |
buildToolAskPayload (bash / MCP / generic branches) |
src/presentation/path-ask-payload.ts |
buildPathAskPayload, buildExternalDirectoryAskPayload, buildBashExternalDirectoryAskPayload |
src/presentation/skill-ask-payload.ts |
buildSkillAskPayload, buildSkillPathAskPayload |
src/presentation/forwarded-ask-payload.ts |
buildForwardedAskPayload |
src/access-intent/bash/executed-unit.ts |
extractExecutedUnit(node, kind) + the curated per-wrapper value-taking flag table |
test/helpers/presentation-fixtures.ts |
makePermissionCheckResult, makeToolPreviewFormatter (tidy-first prep) |
test/helpers/prompt-details-fixtures.ts |
makePromptDetails, makePromptPayload |
test/presentation/*.test.ts |
one file per new src/presentation/ module |
test/access-intent/bash/executed-unit.test.ts |
extraction unit tests |
Changed
| Path | Change |
|---|---|
src/permission-prompts.ts |
formatAskPrompt, formatSkillAskPrompt, formatSkillPathAskPrompt removed; retains formatMissingToolNameReason and formatUnknownToolReason (agent-facing pre-check text, Step 4's territory) |
src/handlers/gates/path.ts |
formatPathAskPrompt removed; builds the payload, derives message |
src/handlers/gates/bash-path.ts |
switches to buildPathAskPayload (its formatPathAskPrompt import dies with the export) |
src/handlers/gates/external-directory-messages.ts |
deleted — both functions become payload builders |
src/handlers/gates/external-directory.ts |
builds the payload, derives message |
src/handlers/gates/bash-external-directory.ts |
builds the payload, derives message |
src/handlers/gates/tool.ts |
builds the payload; decisionValue computed before the payload |
src/handlers/gates/skill-input.ts |
builds the payload, derives message |
src/handlers/gates/skill-read.ts |
builds the payload, derives message |
src/authority/forwarded-request-server.ts |
formatForwardedPermissionPrompt (module-private) replaced by buildForwardedAskPayload; buildForwardedAskDetails attaches the payload and still projects only surface / matchValues / boundaryValue off the intent |
src/authority/permission-prompter.ts |
PromptPermissionDetails.payload: PromptPayload (optional, then required); writeReviewEntry unchanged — it keeps logging details.message |
src/access-intent/bash/command-enumeration.ts |
BashCommand.executedUnit?: string; makeUnit accepts it; classifyWrapperCommand's call site invokes extractExecutedUnit |
src/handlers/gates/bash-command.ts |
tags executedUnit onto the winning result next to commandContext |
src/types.ts |
PermissionCheckResult.executedUnit?: string |
scripts/verify-public-types.sh |
add PromptPayload to the required-symbol list |
Test files touched
test/permission-prompts.test.ts— string assertions for the three removed functions move totest/presentation/legacy-message.test.tsand the builder tests; the file shrinks to the two pre-check reason functions.test/handlers/gates/external-directory-messages.test.ts— deleted; cases split betweentest/presentation/path-ask-payload.test.tsandtest/presentation/legacy-message.test.ts.test/handlers/external-directory-integration.test.ts:47— theformatExternalDirectoryAskPrompt is a callable functioncase dies with the export; drop it.test/denial-messages.test.ts,test/tool-preview-formatter.test.ts— localPermissionCheckResultbuilders andToolPreviewFormatteroptions literals replaced by the shared fixtures.test/handlers/gates/{tool,path,bash-path,external-directory,skill-input,skill-read}.test.tsandtest/bash-external-directory.test.ts— keep assertingpromptDetails.messagebyte-for-byte, and gain apromptDetails.payloadassertion.test/authority/{local-user-authorizer,delegation-envelope,authorizer-selection,permission-prompter,forwarded-request-server,authorizer-chain}.test.ts— six files constructPromptPermissionDetailsliterals; migrated ontomakePromptDetailsin the cycle that makespayloadrequired.
Documentation
docs/architecture/architecture.md:- Module tree: add the
src/presentation/subtree; drop theexternal-directory-messages.tsentry (line ~779); reword thepermission-prompts.tsentry (line ~818) to pre-check error messages only; addaccess-intent/bash/executed-unit.ts. ## Prompt presentationsection: the "Today five sites still assemble a flatmessagestring" paragraph is stale on landing — rewrite it to state that the payload exists and the renderers are next, and correct the count to six.- Phase 13 Findings paragraph (line ~866): correct "five sites" to six and name
formatPathAskPrompt. - Step 1 heading and the
S1Mermaid node marked✅, with aLanded:note. - Health metrics: flat-assembler sites 4 → 0,
src/presentation/present 0 → 1. - Open-issue sweep dispositions: add the PR #738 line (intent adopted in Step 2, authorship credited, PR closed as superseded).
- Module tree: add the
- ADR 0011 is not edited — its "What the code did before this decision" section is a historical record.
docs/architecture/v3-architecture.mdis not edited (frozen snapshot)..pi/skills/package-pi-permission-system/SKILL.md— greped for every removed symbol and forpresentation; no hits, no edit.
Test Impact Analysis
Newly possible.
The payload builders are pure functions over facts, so each surface's facts can be asserted directly (request.matchedPattern, request.executedUnit, evidence labels) instead of only through an English sentence.
extractExecutedUnit becomes unit-testable against a parsed node, which no existing test could reach — today the wrapper's inner command is never computed anywhere.
renderLegacyMessage is testable from a hand-built payload with no gate, no resolver, and no formatter.
Becomes redundant.
test/permission-prompts.test.ts's ~20 formatAskPrompt cases and test/handlers/gates/external-directory-messages.test.ts in full are string-shape tests of functions that cease to exist.
They are not deleted but relocated: each becomes a renderLegacyMessage case (same expected string, payload input) plus, where it pins a fact rather than wording, a builder case.
That relocation is what turns them into the completeness proof.
Must stay as-is.
The seven gate descriptor tests asserting promptDetails.message — they exercise the whole gate → builder → renderer path end to end and are the byte-identity guard for the whole step.
test/permission-ui-prompt.test.ts and the authority tests reading details.message stay untouched: Step 1 changes no consumer.
Invariants at Risk
| Invariant | Source | Pinned by | Action |
|---|---|---|---|
An aliased shell tool gates on bash while the invoked tool name is preserved for display and the log |
#574 | test/handlers/gates/tool.test.ts |
invokedToolName is additive; the legacy render ignores it, so the message is unchanged |
A wrapper's allow is floored to ask with the <opaque-bash-wrapper> / <indirection-bash-wrapper> sentinel |
#481, #490 | test/handlers/gates/bash-command.test.ts |
extraction adds a field to BashCommand; it must add or remove no unit and change no wrapperKind |
An unparseable command fails closed to <unparseable-bash-command> unless an explicit deny covers it |
#712 | test/handlers/gates/bash-command.test.ts |
the sentinel flows into request.matchedPattern unchanged |
A forwarded ask's details carry surface / matchValues / boundaryValue and not requesterCwd / principal |
#635 | test/authority/forwarded-request-server.test.ts |
add an explicit assertion that the new payload smuggles neither |
The permissions:ui_prompt broadcast's forwarded provenance is non-degraded |
#292, #610 | test/permission-ui-prompt.test.ts |
buildUiPrompt is untouched in this step |
| The review log's growth is bounded; a complete payload is never persisted verbatim | ADR 0010 | test/authority/permission-prompter.test.ts |
writeReviewEntry keeps logging details.message; assert the payload is absent from the entry |
Quantitative invariant: every rendered message is byte-identical.
The measurement is the existing suite, not an argument — the relocated string assertions run against renderLegacyMessage, and the seven descriptor tests run against the full path.
Any deviation is a red test, not a review judgment.
TDD Order
- Tidy-first prep — shared presentation fixtures.
Extract
makePermissionCheckResultandmakeToolPreviewFormatterintotest/helpers/presentation-fixtures.ts; migrate the six local factories intest/denial-messages.test.ts,test/permission-prompts.test.ts, andtest/tool-preview-formatter.test.ts. Suite stays green throughout.test(pi-permission-system): extract shared presentation test fixtures (#744) - Executed-unit extraction (red → green).
test/access-intent/bash/executed-unit.test.ts: opaque payloads (bash -c 'rm x',sh -ec "…",eval "…"), plain indirection (sudo aws s3 rm,sudo -u root aws s3 rm,xargs grep foo,xargs -0 -n1 grep foo,timeout 10 grep foo,nice -n 5 make,env FOO=bar grep foo), exec-conditional (find . -name '*.ts' -exec grep foo {} \;,fd -x rm), nesting (sudo timeout 5 xargs grep foo), and thenullcases (barexargs, unresolvable remainder, depth cap). Green:src/access-intent/bash/executed-unit.ts.refactor(pi-permission-system): extract the executed unit of a bash wrapper (#744) - Carry
executedUnitto the check result. Red intest/access-intent/bash/command-enumeration.test.ts(a wrapper unit carriesexecutedUnit; unit count andwrapperKindunchanged) andtest/handlers/gates/bash-command.test.ts(the winning wrapper result carries it). Green:BashCommand.executedUnit,makeUnit,PermissionCheckResult.executedUnit, theresolveBashCommandChecktag.refactor(pi-permission-system): carry the wrapper's executed unit on the check result (#744) - Payload types and the transitional renderer.
Red:
test/presentation/legacy-message.test.tsrenders all nine kinds from hand-built payloads, asserting the exact strings the current prompt tests assert. Green:src/presentation/prompt-payload.tsandsrc/presentation/legacy-message.ts(exhaustive switch,neverguard). No production call site changes yet.refactor(pi-permission-system): add PromptPayload and the transitional message renderer (#744) - Tool / bash / MCP builder;
describeToolGatemigrated. Red:test/presentation/tool-ask-payload.test.ts. Green:src/presentation/tool-ask-payload.ts;tool.tsbuilds the payload and derivesmessage;formatAskPromptremoved andtest/permission-prompts.test.tsmigrated in the same commit (removing an export breaks its importers at the type level).refactor(pi-permission-system): build the tool ask payload in the presentation domain (#744) - Path and external-directory builders; four descriptors migrated.
Red:
test/presentation/path-ask-payload.test.ts. Green:src/presentation/path-ask-payload.ts;path.ts,bash-path.ts,external-directory.ts,bash-external-directory.tsmigrated;formatPathAskPromptandsrc/handlers/gates/external-directory-messages.tsremoved;test/handlers/gates/external-directory-messages.test.tsdeleted and theexternal-directory-integration.test.tscallable-function case dropped — all in one commit.refactor(pi-permission-system): build the path ask payloads in the presentation domain (#744) - Skill builders; two descriptors migrated.
Red:
test/presentation/skill-ask-payload.test.ts. Green:src/presentation/skill-ask-payload.ts;skill-input.tsandskill-read.tsmigrated; the two skill formatters removed frompermission-prompts.tswith their tests migrated in the same commit.refactor(pi-permission-system): build the skill ask payloads in the presentation domain (#744) - Forwarded builder;
payloadtightened to required. Red:test/presentation/forwarded-ask-payload.test.ts, plus aforwarded-request-server.test.tscase asserting the payload carries norequesterCwd/principal, and apermission-prompter.test.tscase asserting the review entry still logsmessageand not the payload. Green:src/presentation/forwarded-ask-payload.ts;forwarded-request-server.tsmigrated;PromptPermissionDetails.payloadmade required;test/helpers/prompt-details-fixtures.tsadded and the six authority test files migrated onto it;PromptPayloadadded toscripts/verify-public-types.sh. One commit — tightening an optional field to required breaks every constructor at the type level.refactor(pi-permission-system): require a complete PromptPayload on every ask (#744) - Documentation.
The architecture-doc updates listed above, including the Step 1
✅marks and the recomputed health-metric rows.docs(pi-permission-system): record the prompt payload seam and mark Phase 13 Step 1 (#744)
Every commit is a hidden changelog type.
refactor: is correct for cycles 2–8 because no observable behavior changes: the extracted unit is computed but rendered nowhere until Step 2, and the payload's only reader is the renderer that reproduces today's strings.
Risks and Mitigations
| Risk | Mitigation |
|---|---|
The curated wrapper flag table is wrong for some invocation, so executedUnit misleads on a decision surface |
Fail-to-null rather than fail-to-guess; display-only and never gating; nothing renders it until Step 2, so a defect cannot reach a user in this step |
renderLegacyMessage's label coupling to evidence entries silently drifts |
Exhaustive switch with a never guard, plus the relocated byte-identity suite over every kind; the module is transitional and deleted by Step 4 |
| A relocated string test loses a case during the move | Cycles 5–7 each move one file's cases and the descriptor tests keep asserting message end to end, so a lost case shows as a green-but-thinner suite — check the case count before and after each move |
Making payload required breaks six authority test files at once |
Confined to cycle 8, absorbed by a makePromptDetails fixture; a new field has no payload: undefined literals to hunt |
| PR #738 conflicts with nearly every file this step rewrites | Disposition decided at planning and recorded in the roadmap; the comment and close are ship-time actions on the PR, and no rebase is attempted |
The public .d.ts grows without the guard noticing |
PromptPayload added to scripts/verify-public-types.sh; pnpm run verify:public-types in the pre-completion checks |
src/presentation/ importing from src/handlers/ would invert the layering |
The gate passes its already-computed decision value in; no builder imports deriveDecisionValue or any handlers/ module |
Open Questions
- Whether
permission-prompts.tsshould be renamed once it holds only the two pre-check reason functions. Deferred: Step 4 moves those to the agent renderer, at which point the file disappears rather than being renamed twice. - The exact depth cap for nested wrapper extraction.
Chosen at implementation from the test cases; four hops covers every real form surveyed (
sudo timeout … xargs …). - Whether
PromptEvidence.detailearns its keep beyond the external-path disclosure. Kept for now because that is a real, current need; Step 2's renderer is the place to revisit it.