HOMELAB-1727: fix(pi-friction): the /friction command stamps the human actor #63

Merged
aaron merged 1 commit from plane/HOMELAB-1727-friction-actor into main 2026-10-03 05:45:44 +00:00
Owner

Part of HOMELAB-1727 (LD4.10), the 4.6 handoff item: "/friction stamps actor.kind: "agent", so R-28/30 cannot distinguish the operator's filing from an agent's".

pi-extensions has no epic/LD4 branch — the other half of this ticket lands in Homelab/infra-core#600 (targeting epic/LD4) — so this one PR targets main for the TS fix alone.

The defect

draftFor hard-coded actor: { kind: "agent", id: facts.sessionId, sandboxEnv: facts.sandboxEnv }, so the operator's own /friction line looked exactly like an agent session's filing. Two consumers care:

  • R-28/R-30 read the actor to tell the operator's filing from an agent's;
  • R-26's episode leg (LD4.10) accepts a human-actor ld.friction/v1 line on where.surface: skill as a correction source, alongside the in-session operator turn.

The fix

draftFor takes the actor kind:

Surface actor
automatic hook agent session (sessionId + sandboxEnv)
friction tool agent session
/friction command { kind: "human", id: "operator" }

stampDraft derives sandboxEnv: "unbound" for the human actor (the operator is not inside a sandbox) — the same value the Go decode path derives, so unbound work stays visible rather than absent (P-ROUT-006). No schema change: actor.kind already declares human, and the TS validator only requires a sandbox binding for agent.

Verification

  • npx tsc --noEmit clean; npx vitest run → 99 tests passing.
  • The command test now asserts the human actor explicitly (it previously asserted kind: "agent", i.e. the defect as correct).
  • The Go-side fixture (an operator line with no binding decoding cleanly through friction.DecodeLine) is in infra-core #600, so both sides of the wire are asserted.
Part of HOMELAB-1727 (LD4.10), the 4.6 handoff item: *"`/friction` stamps `actor.kind: "agent"`, so R-28/30 cannot distinguish the operator's filing from an agent's"*. `pi-extensions` has no `epic/LD4` branch — the other half of this ticket lands in `Homelab/infra-core#600` (targeting `epic/LD4`) — so this one PR targets `main` for the TS fix alone. ## The defect `draftFor` hard-coded `actor: { kind: "agent", id: facts.sessionId, sandboxEnv: facts.sandboxEnv }`, so the operator's own `/friction` line looked exactly like an agent session's filing. Two consumers care: - **R-28/R-30** read the actor to tell the operator's filing from an agent's; - **R-26's episode leg** (LD4.10) accepts a human-actor `ld.friction/v1` line on `where.surface: skill` as a *correction source*, alongside the in-session operator turn. ## The fix `draftFor` takes the actor kind: | Surface | `actor` | |---|---| | automatic hook | agent session (`sessionId` + `sandboxEnv`) | | `friction` tool | agent session | | `/friction` command | `{ kind: "human", id: "operator" }` | `stampDraft` derives `sandboxEnv: "unbound"` for the human actor (the operator is not inside a sandbox) — the same value the Go decode path derives, so unbound work stays visible rather than absent (P-ROUT-006). No schema change: `actor.kind` already declares `human`, and the TS validator only requires a sandbox binding for `agent`. ## Verification - `npx tsc --noEmit` clean; `npx vitest run` → 99 tests passing. - The command test now asserts the human actor explicitly (it previously asserted `kind: "agent"`, i.e. the defect as correct). - The Go-side fixture (an operator line with no binding decoding cleanly through `friction.DecodeLine`) is in infra-core #600, so both sides of the wire are asserted.
HOMELAB-1727: fix(pi-friction): stamp the /friction command as the HUMAN actor
All checks were successful
CI / pr-title (pull_request) Successful in 1s
CI / pi-otel-telemetry (pull_request) Successful in 39s
CI / forgejo-ci (pull_request) Successful in 1m0s
CI / skill-args (pull_request) Successful in 56s
CI / plane (pull_request) Successful in 1m12s
CI / forgejo (pull_request) Successful in 1m9s
CI / todo (pull_request) Successful in 51s
CI / mcp-bridge (pull_request) Successful in 23s
CI / nats-intercom (pull_request) Successful in 37s
CI / ask-user-question (pull_request) Successful in 55s
CI / refocus (pull_request) Successful in 1m2s
CI / pi-friction (pull_request) Successful in 44s
CI / pi-swarm-suite (pull_request) Successful in 3m58s
ec02ade217
The 4.6 handoff defect: `draftFor` hard-coded `actor.kind: "agent"`, so the
operator's `/friction` line was indistinguishable from an agent session's —
R-28/R-30 could not tell the operator's filing from an agent's, and R-26's
episode leg (LD4.10) cannot read a human-actor line as a correction source.

`draftFor` now takes the actor kind: the automatic hook and the model-callable
tool stay the agent (session id + sandbox binding), and the command path stamps
`{kind: "human", id: "operator"}`. A human actor carries no sandbox binding;
stampDraft derives `sandboxEnv: "unbound"`, which is the same value the Go
decode path stamps, so unbound work stays visible (P-ROUT-006).

The Go half of the same defect (friction.Line accepting an operator line with no
binding) is asserted in infra-core PR #600 — one fixture on each side of the
wire.
Author
Owner

Landing review — PASS (no blocker findings)

Reviewed head ec02ade against primary sources only (diff, types.ts/index.ts at head, the Go wire contract on infra-core@epic/LD4, and an independent re-run).

Verdict: PASS — mergeable on green CI.

Correctness vs the acceptance criterion (actor attribution)

The defect is real and the fix matches it. At head the three surfaces stamp:

Surface actor source
automatic tool_execution_end agent (sessionId + sandboxEnv) autoCaptureDraft, capture.ts:133 (type is agent-only)
friction tool (model-callable) agent draftFor(..., kind="agent" default)
/friction command {kind:"human", id:"operator"} draftFor(..., "human")

The tool and auto-hook are correctly left as agent; only the human path changed. OPERATOR_ACTOR_ID = "operator" matches the schema's declared id (Actor.id doc, validator message) — no new vocabulary invented.

Wire compatibility (checked, not assumed)

{kind:"human", id:"operator", sandboxEnv:"unbound"} decodes on the Go side: ActorKind declares ActorHuman = "human" and Line.Stamp() fills SandboxUnbound for any actor with an empty SandboxEnv, with a comment explicitly pinning byte-for-byte agreement with this TS capture surface. Schema deltas are zero: ACTOR_KINDS already includes human, and the validator only requires sandboxEnv for kind: "agent". Consistent with #600's half.

Test adequacy / independent verification

Not taken on trust — reproduced at head in a detached worktree: npx tsc --noEmit clean, npx vitest run → 99 passed (5 files). The command test asserts the full actor object against the spool bytes (behavior, not implementation) and previously asserted the defect (kind: "agent") as correct; the validator's unbound stamping for a human actor is covered in types.test.ts. Coverage of both the changed path and its derivation is present.

Scope

Diff is confined to pi-friction/{README.md,index.ts,tests/extension.test.ts} — the PR's own area. pi-otel-telemetry and pi-swarm-suite are untouched (verified: no hunks, no shared consumers in-repo; R-28/R-30 live on the Go side). The command's JSON form carries no actor field in ParsedCommand, so the surface-derives-actor property holds with no dead plumbing.

Non-blocking note (does not gate merge)

The new comment in index.ts (echoed in the PR body) says stampDraft derives sandboxEnv: "unbound". In TS, stampDraft passes actor through unchanged — the fill happens in validateLine's output normalization (types.ts:502-507). The behavior and the asserted spool bytes are correct; only the attribution in the prose is imprecise. Suggest a follow-up wording tweak (or fold into a later pi-friction touch), not a re-spin.

Merge on green CI.

## Landing review — PASS (no blocker findings) Reviewed head `ec02ade` against primary sources only (diff, `types.ts`/`index.ts` at head, the Go wire contract on `infra-core@epic/LD4`, and an independent re-run). **Verdict: PASS — mergeable on green CI.** ### Correctness vs the acceptance criterion (actor attribution) The defect is real and the fix matches it. At head the three surfaces stamp: | Surface | actor | source | |---|---|---| | automatic `tool_execution_end` | agent (`sessionId` + `sandboxEnv`) | `autoCaptureDraft`, `capture.ts:133` (type is agent-only) | | `friction` tool (model-callable) | agent | `draftFor(..., kind="agent" default)` | | `/friction` command | `{kind:"human", id:"operator"}` | `draftFor(..., "human")` | The tool and auto-hook are correctly left as agent; only the human path changed. `OPERATOR_ACTOR_ID = "operator"` matches the schema's declared id (`Actor.id` doc, validator message) — no new vocabulary invented. ### Wire compatibility (checked, not assumed) `{kind:"human", id:"operator", sandboxEnv:"unbound"}` decodes on the Go side: `ActorKind` declares `ActorHuman = "human"` and `Line.Stamp()` fills `SandboxUnbound` for *any* actor with an empty `SandboxEnv`, with a comment explicitly pinning byte-for-byte agreement with this TS capture surface. Schema deltas are zero: `ACTOR_KINDS` already includes `human`, and the validator only requires `sandboxEnv` for `kind: "agent"`. Consistent with #600's half. ### Test adequacy / independent verification Not taken on trust — reproduced at head in a detached worktree: `npx tsc --noEmit` clean, `npx vitest run` → **99 passed (5 files)**. The command test asserts the full actor object against the spool bytes (behavior, not implementation) and previously asserted the defect (`kind: "agent"`) as correct; the validator's `unbound` stamping for a human actor is covered in `types.test.ts`. Coverage of both the changed path and its derivation is present. ### Scope Diff is confined to `pi-friction/{README.md,index.ts,tests/extension.test.ts}` — the PR's own area. `pi-otel-telemetry` and `pi-swarm-suite` are untouched (verified: no hunks, no shared consumers in-repo; R-28/R-30 live on the Go side). The command's JSON form carries no `actor` field in `ParsedCommand`, so the surface-derives-actor property holds with no dead plumbing. ### Non-blocking note (does not gate merge) The new comment in `index.ts` (echoed in the PR body) says `stampDraft` derives `sandboxEnv: "unbound"`. In TS, `stampDraft` passes `actor` through unchanged — the fill happens in `validateLine`'s output normalization (`types.ts:502-507`). The behavior and the asserted spool bytes are correct; only the attribution in the prose is imprecise. Suggest a follow-up wording tweak (or fold into a later pi-friction touch), not a re-spin. Merge on green CI.
aaron merged commit e25fa2ed99 into main 2026-10-03 05:45:44 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/pi-extensions!63
No description provided.