fix(sdk,cli): terminal-state no-ops, bounded pm next receipts, lean refusals with typed claim holders

Closed
UN
2d ago
+1532-98

Three GitHub-reported agent-UX and SDK defects, each reproduced on current source, fixed with a measured before/after and a negative control, and closed in the tracker with escape class and gate evidence.

What changes

  • Identical terminal updates keep their closure clocks (GH-1628, pm-gh1628). update routed any payload naming the close status to the closure writer without checking whether the item was already closed, so a forced identical closure re-stamped closed_at and updated_at on every retry while the same payload on an open item was a no-op. routeCloseStatusUpdate now reads the stored item first: when the requested close status, trimmed reason and completed_at already describe it, only the remaining fields take the ordinary skip-no-op update path and the result reports terminal_state_unchanged; a bare identical update returns no changed fields without --force. A different reason is still a repeat closure behind the existing force gate. commitItemMutations reuses update(), so transactions inherit the fix. Helpers live in src/sdk/lifecycle/repeated-terminal-state.ts to keep update.ts under the line ceiling.
  • pm next discloses the whole response under its ceiling and encodes tags losslessly (GH-1627, pm-gh1627). filters.token_budget reported 640 while nothing enforced it, and --token-budget replaced the universal default ceiling instead of bounding the selection beside it, so the complete response carried no cost receipt. The flag is no longer a read-output cost alias; a requested selection budget now audits the complete envelope (read_output with budget_source: default, budget_tokens, estimated_tokens beside truncation.ready_budget) unless an enforced intent receipt already discloses it, and filters.token_budget echoes the request or null. The TOON codec records omitted null columns as null_column_index instead of repeating the full column_order list (decoders still accept the legacy field), so the compact table wins for ready and containers and every section list-encodes tags. On the real tracker: 9,148 → 8,718 bytes, 182 → 76 escaped-quote sequences.
  • Lean refusals drop constant boilerplate and the claim holder is typed (GH-1610, pm-gh1610). The SDK declares the constant refusal boilerplate once (PM_GENERIC_REFUSAL_GUIDANCE, PM_RECOVERY_ECHO_FIELDS, isGenericRefusal*, projectLeanRecoveryPayload) and both lean projections consume it: lean text keeps the headline, next steps, specific requirement/rationale, verifier findings and specific recovery; lean JSON drops generic required/examples and the invocation echo on top of title/why. A single-line message is no longer repeated under What happened in any mode. already_claimed_by carries item_id, claimed_by, claimed_at and claim_source through PmCliErrorContext, the JSON envelope and the classification; text renders Held by: <holder> (<source> since <time>). Claim conflict: text 798 → 719 bytes, lean text 813 → 494, lean JSON 968 → 648. A property test over six refusal codes asserts lean is never larger than default and shares no constant line.

Contracts and docs

  • SDK surface snapshot acknowledges NextResult.filters.token_budget (number | null), PmTableCellEncoding.null_column_index, the PmCliErrorContext holder fields and the new exports (resolveItemOwnershipConflict, the lean boilerplate helpers, CloseRouteContext).
  • Contracts snapshot drops the stale --output-budget alias declaration on next.
  • docs/COMMANDS.md, docs/AGENT_OUTPUT_ENCODINGS.md, docs/AGENT_UX_CONTRACTS.md and docs/AGENT_GUIDE.md describe the terminal-state receipt, the two next budget scopes, the lean refusal contract and the holder fields.

Validation

  • node scripts/run-tests.mjs test: full fast suite green on the merged branch (see the last commit's evidence in the tracker).
  • Negative controls: with the relevant source files stashed, 3 of 4 GH-1628 tests, 3 of 3 GH-1627 tests and the GH-1610 size property fail.
  • Gates run locally: static quality (complexity, line ceiling, docstrings), token-budget, context-intent calibration, agent-task-token, refusal-closure, docs-skills, defect-evidence, graph-composition (prose-edge-gaps at its ceiling), record-integrity, tracker-context-quality, flag-lexicon, flag-invocation-parity, command-grammar, surface-replication.
  • Per-item measurements, linked tests and gate evidence are recorded on pm-gh1628, pm-gh1627 and pm-gh1610.

Closes #1628
Closes #1627
Closes #1610

Summary by Sourcery

Fix terminal-state update retries, clarify and audit pm next budgets, and streamline refusal diagnostics with structured claim ownership data.

New Features:

  • Expose structured ownership-holder details in claim conflict errors and SDK APIs.
  • Add compact, lossless TOON encoding for omitted null columns and typed lean-refusal projections.

Bug Fixes:

  • Preserve closure timestamps and history when an update repeats an item's existing terminal state.
  • Make pm next treat --token-budget as a selection limit while auditing the complete response under the universal output ceiling.
  • Reduce refusal output duplication and retain actionable recovery and holder information in lean text and JSON modes.

Enhancements:

  • Document terminal-state no-op behavior, pm next budget scopes, lean refusal output, and typed claim-holder fields.
  • Extend public SDK and output contracts for ownership conflicts, refusal helpers, next budget metadata, and table encoding.

Documentation:

  • Update command and agent UX documentation for terminal-state receipts, budget accounting, lossless table encoding, lean refusals, and claim ownership details.

Tests:

  • Add coverage for repeated terminal updates, transactional retries, bounded pm next receipts, compact table round-tripping, typed claim conflicts, and lean refusal size guarantees.

Chores:

  • Record the three defect fixes in the changelog and tracker history.