pm-slack
v2026.10.31
Published
Slack notifications for pm item lifecycle events
Maintainers
Readme
pm-slack
Slack notifications for pm-cli item lifecycle events.
Fires after create, close, block, cancel, open, start, unblock, and reopen lifecycle transitions and posts a formatted message to a Slack incoming webhook. Optionally maps assignees to Slack @mentions and adds Block Kit action buttons linking to the item / GitHub URL.
Slack posts retry transient delivery failures (429, 5xx, timeout, socket/DNS hiccups) with exponential backoff and honor Slack's Retry-After header, so temporary webhook throttling does not immediately drop project context.
Durable lifecycle notifications
Enable the local outbox explicitly after configuring the webhook (PM CLI/SDK 2026.10.15 or newer is required for verified history recovery):
pm slack outbox --action enable
pm create Task "Check the release" --priority 1
pm slack outbox --action status --limit 20 --json
pm slack outbox --action drain --limit 20 --jsonenable records a durable start time and snapshots PM_SLACK_EVENTS and
PM_SLACK_MIN_PRIORITY in one atomic transaction. A crash or storage refusal
before completion leaves the previous opt-in and policy intact; retry enable
after restoring writable storage. It is idempotent and does not backfill older events.
Changing those environment variables does not change existing recovery eligibility.
Run pm slack outbox --action configure before future lifecycle changes to record
an explicit new filter epoch; older history keeps its original policy. Existing
outboxes without saved filters retain their queued receipts; enable or configure
starts their recovery policy with future history rather than guessing old eligibility.
After successful lifecycle mutations, the hook recovers the affected item's
matching transitions and attempts at most one delivery. Reads never drain.
drain discovers candidate items through the SDK's cursor-paginated mutation
feed, then reads and verifies authoritative history, including changes
whose process exited before its hook ran, and drains queued receipts. Run it after
a restart or periodically from your scheduler. A failed item history is isolated:
healthy histories and already verified queued receipts still drain, while
recovered.failed reports the failure count and the CLI exits 2 with
history_unavailable. Repair and retry without duplicating acknowledged receipts.
The feed is discovery-only: cached event rows never authorize a notification.
SDK readers load each candidate's authoritative history and required current item, and
verifyHistoryEntries checks both chain integrity and agreement with the current
item before replay, eligibility, deduplication or queuing. Missing streams and
valid-prefix truncations are reported as failures; current item identities are
included so a missing history file cannot disappear from discovery. Page cursors
are used only after verification and never saved as a durable acknowledgement.
If the feed is unavailable (for example, for legacy timestamps), discovery falls
back to retained history. Replay preserves historical payloads and authoritative
stream order. A failed item publishes no new intents; healthy queued receipts
can still drain.
Recovery isolates each candidate: an empty, unreadable, malformed or missing item
document never stops healthy neighbors. Identity discovery reads filenames with
the SDK's folder/format mapping, since metadata inventories either omit unreadable
documents or reject the whole read. A missing document fails even when its
remaining history is a valid prefix. A failed item publishes no buffered intents.
When failures occur, recovered.failures lists every candidate's item,
code: history_unavailable and safe typed reason. An unreadable discovery
directory is identified as _inventory. Healthy receipts remain unchanged.
Keep history from the enable time; removing or compacting it can prevent recovery
of unqueued transitions. History, tracker items and committed lifecycle commands
are never rolled back by a delivery failure. Queued messages retain their original
payload and destination fingerprint; recovery uses recorded eligibility epochs
and current routing for new intents.
SQLite stores intents, unique history-revision keys, attempt counts and
acknowledgments in .agents/pm/runtime/slack-outbox/delivery.sqlite using full
synchronous commits and atomic claims across local processes. Keep this ignored
runtime directory on durable local storage and include it in your private backups.
Do not use the same database from different hosts or over a network filesystem.
The directory is private to its owner; webhook URLs are hashed, never persisted.
Message bodies remain private runtime data. There is no automatic receipt deletion:
retaining acknowledgments prevents old events from being delivered again.
Each intent moves from pending to sending before a request starts. HTTP success
becomes acknowledged; explicit 429 refusals remain pending for a later drain;
other 4xx responses become rejected. A timeout, reset, dropped response or 5xx
becomes ambiguous, including a response body that mentions a DNS failure.
Connection refused and DNS errors that occur before the request is transmitted
stay pending for a later drain; classification uses the socket error code, not
the error text. After an ambiguous result, later rows for that destination stay
pending for the remainder of this drain; other destinations can proceed. A retry
stops the current drain. The next drain tries fewer-attempt
intents first, so one refused destination cannot block every other notification.
A crash leaves sending visible. Uncertain states are never automatically resent
because incoming webhooks provide no transactional exactly-once guarantee.
Existing immediate retry/backoff for non-outbox commands is unchanged.
Inspect Slack before reconciling an uncertain receipt:
pm slack outbox --action resolve --key RECEIPT_KEY --decision acknowledged
# Only after verifying absence; explicitly retrying uncertainty can duplicate a post:
pm slack outbox --action resolve --key RECEIPT_KEY --decision retry
pm slack outbox --action drainA pre-request destination_changed parks the row as refused with zero added
attempts and continues past it. Parked rows are never automatically retried. They
do not block later healthy destinations. Restore the original routing and resolve
with --decision retry, or explicitly discard the unsent receipt without claiming
delivery:
pm slack outbox --action resolve --key RECEIPT_KEY --decision skipskip and abandon record skipped, a terminal work state distinct from
acknowledged. They stop retries without proving non-delivery. Even an operator's
acknowledged resolution is a work decision, not a retained Slack response. Live or
reused sender PIDs refuse reconciliation by default. After independently verifying
that no sender is active, use --force-reconcile to override PID reuse, including
PID 1 in containers. Forcing an active sender can duplicate delivery; it is never
automatic. SDK callers use resolve(key, decision, { force: true }) for the same
explicit override.
Global configuration/storage refusals still return typed errors; a global
pre-request failure restores the row to pending without counting an attempt.
Command refusals exit nonzero. SDK callers receive OutboxError with typed code,
remedy and exitCode fields. Row-level routing refusals are reported by the
refused count instead of aborting the drain.
The command returns compact TOON by default or JSON with --json: exact counts
for every work state, separate outcomes totals (delivered, withheld,
uncertain), at most --limit receipts (1–100, default 20), and an explicit
omitted count. Continue inspection with --after <next_cursor> until
next_cursor is null; totals cover the whole outbox on every page. Payloads and
destinations are excluded. A drain inspects history
without truncation, then attempts at most the requested number of pending rows.
recovered.inspected, recovered.queued and recovered.failed describe that scan. A sending receipt
means delivery may have happened; it is not an acknowledgment.
The package root exports SlackOutbox, captureSlackOutbox, slackDestination,
OutboxError, deriveSlackDeliveryOutcome, and their intent/receipt/report/evidence types. The low-level
drain(send, limit) callback must make one attempt and report acknowledged
only on explicit success, retry only on a confirmed refusal, and ambiguous
whenever remote acceptance is uncertain. A retry ends the current drain; call
it again later. See the SDK example.
Raise destination_changed only before starting a remote request; this parks the
intent as refused without counting a send attempt. Other typed pre-request errors
restore pending and rethrow. Enable SDK history recovery with
enable({ events: [...], minPriority: 1 }); configureRecovery(filter) records an
explicit future-only filter change. Low-level enable() without a filter still
supports direct intent enqueue/drain, while recovery requires a saved policy.
Without git
Lifecycle delivery, outbox enable/status/drain/resolve, manual notifications, offline previews and digests work in a plain initialized tracker without git. Outbox recovery uses the SDK mutation feed with retained-history fallback and never runs a git subprocess. The feed may rebuild its derived runtime index; authoritative item and history files stay unchanged. This outbox needs local durable runtime storage; it does not provide a remote database backend. No Slack command requires a git repository.
Installation
pm install github.com/unbraind/pm-slack --globalOr install per-project:
pm install github.com/unbraind/pm-slack --projectSetup
1. Create a Slack Incoming Webhook
- Go to api.slack.com/apps and create a new app (or use an existing one).
- Under Features → Incoming Webhooks, activate incoming webhooks.
- Click Add New Webhook to Workspace and choose a channel.
- Copy the webhook URL into your shell environment.
2. Set environment variables
| Variable | Required | Default | Description |
|---|---|---|---|
| PM_SLACK_WEBHOOK | Usually | — | Slack incoming webhook URL (optional if every routing rule carries its own webhook) |
| PM_SLACK_CHANNEL | No | — | Default channel hint appended to messages (e.g. #pm-alerts) |
| PM_SLACK_MIN_PRIORITY | No | 1 | Minimum priority to notify (1=critical, 2=high, 3=medium, 4=low) |
| PM_SLACK_EVENTS | No | all events | Comma-separated list of events to notify on. Status-name aliases (canceled, in_progress, …) accepted |
| PM_SLACK_FORMAT | No | blockkit | Default message format: blockkit (rich) or text (plain mrkdwn) |
| PM_SLACK_ROUTES | No | — | JSON array of routing rules (see Routing) |
| PM_SLACK_ASSIGNEE_MAP | No | — | Comma list of name=slackId pairs mapping assignees to Slack @mentions, e.g. alice=U123,bob=U456 |
| PM_SLACK_MENTION_MAP | No | — | Same format as PM_SLACK_ASSIGNEE_MAP; overlays it (same-name entries here win). Accepts name=@handle and name=Uxxxx forms |
| PM_SLACK_MENTION_ASSIGNEE | No | auto | Set 0/false to disable assignee mentions even when a map is set (auto-enabled when a mention map is non-empty) |
Export them in your shell profile or .env:
export PM_SLACK_WEBHOOK="<slack-webhook-url>"
export PM_SLACK_CHANNEL="#pm-alerts"
export PM_SLACK_MIN_PRIORITY=2 # only critical + high
export PM_SLACK_EVENTS="create,close" # skip block notifications
export PM_SLACK_FORMAT="text" # plain text instead of Block KitEvent Types
| Event | Trigger commands | Notes |
|---|---|---|
| create | pm add, pm create, pm new | Fires when a new item is created |
| close | pm close, pm done, pm complete | Fires when an item is closed/resolved |
| block | pm update --status blocked | Fires when an item's status changes to blocked |
| cancel | pm update --status canceled | Fires when an item is canceled (reason read from close_reason) |
| open | pm update --status open (e.g. draft → open) | Fires when an item is published/opened |
| start | pm claim, pm start-task, pm update --status in_progress | Fires when work begins on an item |
| unblock | pm update --status open from a blocked state | Distinguished when the result carries the prior status |
| reopen | pm update --status open from a closed/canceled state | Distinguished when the result carries the prior status |
unblock / reopen are refinements of an open transition: when pm's hook
result exposes the previous status, the more specific event is used; otherwise
the transition surfaces as open. Status-name aliases (canceled,
in_progress, reopened, …) are accepted anywhere an event name is.
Message Format
Item created
*[Feature]* Auth system created 🆕
Priority: high • Type: Feature • By: aliceItem closed
*[Issue]* Login redirect bug closed ✅
Reason: Fixed in commit abc123Item blocked
*[Epic]* Dashboard redesign is blocked 🚫
Reason: Waiting on design approvalWhen PM_SLACK_CHANNEL is set, a channel hint is appended to every message:
*[Feature]* Auth system created 🆕
Priority: high • Type: Feature • By: alice
_Channel: #pm-alerts_Filtering
By priority
Only notify for high-priority and above:
PM_SLACK_MIN_PRIORITY=2Priority scale: 1 = critical, 2 = high, 3 = medium, 4 = low.
The filter passes items whose priority number is less than or equal to the minimum (i.e. higher priority).
By event type
Only notify on create and close, not block:
PM_SLACK_EVENTS="create,close"Message Formats
Notifications render as rich Slack Block Kit by default (header, a fields
grid with item id / type / event / priority / status / author, an optional
reason section, and a context footer). Set the format to plain text for
minimal/legacy channels:
export PM_SLACK_FORMAT="text" # plain mrkdwn, no blocksPer-command, the --format blockkit|text flag overrides the env var for
slack notify, slack test, and slack digest.
Routing (by event, type, or status)
PM_SLACK_ROUTES is an optional JSON array of rules that send specific events,
item types, or statuses to a different webhook and/or channel. Routing is purely
additive — with no rules configured, behavior is unchanged.
Each rule is { "match": "<selector>", "webhook"?: "...", "channel"?: "..." }.
Selectors:
| Selector | Matches |
|---|---|
| create / close / block | that lifecycle event |
| type:<itemType> | items of that type (case-insensitive) |
| status:<status> | items in that status (case-insensitive) |
| * / all | everything (use as a catch-all) |
The first matching rule wins; any field a rule omits falls back to the
default PM_SLACK_WEBHOOK / PM_SLACK_CHANNEL.
export PM_SLACK_ROUTES='[
{ "match": "block", "channel": "#urgent" },
{ "match": "type:Bug", "webhook": "https://hooks.slack.com/services/AAA/BBB/CCC" }
]'Assignee @mentions
Map a pm item's assignee to a Slack user (or group) id (or a human @handle)
so notifications @-mention the responsible person. Set PM_SLACK_ASSIGNEE_MAP
(or PM_SLACK_MENTION_MAP) to a comma list of name=id pairs:
export PM_SLACK_ASSIGNEE_MAP="alice=U0123ABC,bob=@bob,team=<!subteam^S0789GHI>"Mention map value forms:
- A raw id (
U0123ABC) is wrapped as<@U0123ABC>. - A plain
@handle(e.g.@bob) is kept verbatim (no double-wrapping). - A pre-wrapped token (e.g.
<!subteam^S0789GHI>for a group) is used verbatim.
Sources & precedence (later wins for the same name):
PM_SLACK_ASSIGNEE_MAP < PM_SLACK_MENTION_MAP < the per-command
--mention-map flag. With no source set at all, output is byte-identical to
before (no mention rendered).
Mentions auto-enable in the hook whenever the (env) map is non-empty; set
PM_SLACK_MENTION_ASSIGNEE=0to force them off.For ad-hoc posts, pass an inline map without touching the environment:
pm slack notify --title 'Auth epic' --on create \ --assignee alice --mention-map 'alice=@alice,bob=U0456DEF' --dry-runThe resolved
@mentionappears in both the plain-text fallback and the Block KitAssigneefield.pm slack notify --assignee alice --mention-assigneealso resolves from the env map. When no mapping exists, the raw assignee name is still shown (blockkit).
Action buttons (item / GitHub links)
When a pm item carries a URL, the Block Kit notification adds an action button
linking to it (View on GitHub for github.com URLs, otherwise Open item). The
URL is read from the first present of these item fields: github_url,
html_url, url, source_url, link (camelCase variants accepted). The
plain-text format is unchanged. For ad-hoc posts:
pm slack notify --title 'Deploy' --on close --url 'https://github.com/unbraind/pm-slack/pull/3' --dry-runCommands
pm slack notify
Manually post a Block Kit (or text) message for an ad-hoc note.
pm slack notify --text 'Release shipped :rocket:' --dry-run
pm slack notify --title 'Deploy done' --on close --format text --channel '#releases'pm slack test
Build and print a sample notification in the chosen format without posting — fully offline, no webhook required. Great for previewing formatting.
pm slack test --format blockkit # rich preview
pm slack test --format text --on close # plain-text preview of a close event
pm slack test --on block --json # machine-readable payload (with global --json)Flags: --format blockkit|text, --on create|close|block, --title,
--channel, --json.
Attention-budgeted lifecycle delivery
pm slack coalesce previews pending lifecycle deliveries as bounded same-item,
same-destination bursts, independently of the tracker-state digest and periodic
pm-slack-standup reports. Enable the durable outbox, set
PM_SLACK_COALESCE=1 on lifecycle writers, and schedule
pm slack coalesce --action drain --budget 5. Routine events wait for their fixed
window to close. Blockers and priority-0/1 events bypass both the window and the
routine notification budget, retaining their original payloads.
The typed SDK exports planSlackCoalescing, event/options/plan/receipt types,
CoalescingError, and SlackOutbox planning, draining and paged evidence methods.
Default TOON and --json output contain bounded evidence pages, exact totals,
omission counts and resume cursors. Every event retains its committed revision
and a coalescing or bypass receipt; over-budget events remain pending. Preview
and receipt inspection need no webhook. All operations work without git.
See the complete CLI and SDK example for opt-in, scheduling, refusals, pagination, transport outcomes and group reconciliation. Public-tracker measurements record read-only validation and behavioral revert proofs.
pm slack digest
Produce a single summary of recent activity (created / closed / blocked /
in-progress) over a time window, as Block Kit or text. Requires pm CLI/SDK
2026.10.15 or newer. Reads the complete tracker through the public SDK, including
configured custom item types and terminal items. Unreadable or malformed records
refuse the digest before posting, rather than silently omitting activity. Nested
extension hooks are disabled during the observational read. Use --dry-run to
preview without posting; it still requires a complete, readable tracker.
pm slack digest --days 7 --dry-run
pm slack digest --since 2026-06-01 --format text --dry-run
pm slack digest --days 1 --format blockkit # real post (needs a webhook)Flags: --since <date>, --days <n> (default 7), --format blockkit|text,
--channel, --webhook, --dry-run, --json.
A real (non---dry-run) slack digest post without a configured webhook fails
with a clear error (exit 1) rather than crashing.
Webhook preflight gate
The Slack-posting commands (slack notify, slack digest) run a fail-fast
preflight that validates webhook configuration before anything is posted:
- The effective webhook is resolved with precedence
--webhookflag →PM_SLACK_WEBHOOK→ a per-rule webhook inPM_SLACK_ROUTES. - If no webhook is configured, or the configured URL is not a valid
http(s)URL, the command aborts immediately with a clear, actionable error and a non-zero exit (2, usage error) — nothing is posted, no network call is made. - On a valid configuration the gate is a silent pass-through.
Validation is purely syntactic/environment-based — no network call, so it is cheap and offline-safe. It does not verify the webhook actually works; a valid but dead webhook still passes the gate and surfaces at the network layer.
What is not gated:
slack test— an offline preview that never posts and works with no webhook.--dry-runonslack notify/slack digest— also an offline preview.
The gate is registered both as a scoped registerPreflight override (for the
documented preflight capability and a visible warning) and — because the pm
runtime treats errors thrown from a preflight override as non-fatal — enforced in
the command handlers themselves, which throw a CommandError that genuinely
aborts the command.
# no webhook configured:
pm slack notify --text 'hi' # → error + exit 2, nothing posted
pm slack notify --text 'hi' --dry-run # → offline preview, exit 0
pm slack test --on close # → offline preview, exit 0Error Handling
The lifecycle hook is best-effort and never breaks your pm command:
- If
PM_SLACK_WEBHOOKis not set, the hook silently skips all notifications and logs a debug message. - If the webhook URL is invalid, the hook logs an error and continues without crashing pm-cli.
- HTTP errors from Slack (non-2xx responses), network failures, and timeouts (10s) are logged but never fail the underlying
pmcommand.
The slack notify and slack digest commands fail fast on misconfiguration
(see Webhook preflight gate):
- A real (non-
--dry-run) post without a configured webhook, or with an invalid webhook URL, fails with a clearCommandErrorand a non-zero exit before any post is attempted. - Use
--dry-runto build and preview with no webhook and no network call. - Once past the gate,
slack notifytreats a network/HTTP failure as best-effort (warns, exits 0);slack digestreports a post failure as an error (exit 1).
Development
npm install
npm run build # compile TypeScript → dist/
npm run dev # watch modeThe extension uses only Node.js built-ins (node:https) — no external runtime dependencies.
Manifest
{
"name": "pm-slack",
"description": "Slack notifications for pm item lifecycle events",
"author": "@unbraind",
"entry": "./dist/index.js",
"priority": 50,
"capabilities": ["commands", "hooks", "schema", "preflight"]
}License
MIT
Release Automation
This package is release-ready for GitHub, npm, and Bun-compatible installs. CI runs type checking, build, production dependency audit, package packing, Bun install verification, and pm-changelog validation. The daily release workflow publishes only when commits exist after the latest release tag and uses pm-changelog to generate CHANGELOG.md and GitHub release notes.
Multi-agent merge safety
This repo tracks its project management in .agents/pm/ and ships a committed .gitattributes
that maps those tracker artifacts to pm-cli's field-aware Git merge drivers, so concurrent-branch
tracker edits merge cleanly instead of hard-conflicting. The driver definitions live in
per-clone Git config; npm install / npm ci wires them automatically via the prepare script, scripts/prepare-merge-driver.ts: the launcher template pm-ops ships, copied unchanged, which a test compares byte for byte with the pinned template. It runs pm-ops's installer, which calls pm merge install when the pm CLI is on PATH and skips with a notice when it is not. A production install of a clone (npm ci --omit=dev) has no pm-ops, so the launcher skips with one notice, while a stale or broken pm-ops fails the install. Registry installs of this package never run prepare. Being Node-based, it behaves identically on POSIX shells and Windows cmd.exe. To (re)run manually: npm run merge:install.
After merging a branch that touched .agents/pm/, reconcile any residual history-hash drift with
pm merge reconcile (pm-cli ≥ 2026.7.22): preview with pm merge reconcile --dry-run, apply with
pm merge reconcile --message "post-merge reconcile", then confirm with pm validate, which scans the
whole tracker and flags remaining history drift across every affected item (pm merge reconcile
itself lists each affected stream in its output; pm history --verify <id> spot-checks one item). The field-aware driver already unions every author's
content, so reconcile only re-greens the hash chain (no data loss) — see the authoritative
pm-cli merge-safety guide. The
older blunt pm history-repair --all remains available as a lower-level primitive.
Certification tracker GitHub preview
Install the managed extension in a fresh clone before running its read-only preview:
npx pm package install npm:[email protected] --project
npx pm github sync --repo unbraind/pm-slack --dry-runThe tracked managed manifest records the extension; installed files are ignored. This preview does not enable scheduled sync. See docs/certification-2026.10.4.md for package gates and packed real-tracker command evidence.
Notification routing explanations
Use pm slack explain --item <id> --event <event> --revision <after_hash>
for a single offline diagnosis. Add --json for JSON; the default TOON output
contains the same bounded evidence. Discover exact identities with the SDK
mutation feed (listMutationEvents): select the lifecycle event's item and
committed history after_hash (full: true includes the entry). Pass the feed's
one-based version as --event-version when a state hash repeats. The SDK identity
accepts the same optional version field. Without it, a repeated hash produces
event_ambiguous; with it, history is diagnosed but a reused hash cannot safely
attribute legacy delivery receipts and remains explicit uncertainty.
The typed SDK export explainSlackRouting(pmRoot, { item, event, revision })
verifies the complete item history against the authoritative SDK document,
replays the requested revision through the SDK, and uses the tracker's configured
status registry roles to classify the transition. It reports event and priority
filters (including exact priority operands), every ordered route rule (indices only), first-match shadowing, attention
policy, and exact-revision delivery evidence. Blockers and priority 0/1 events
name their budget/window bypass policy; bypass does not override eligibility
filters. Titles, bodies, selectors, channels and webhook URLs are never returned.
Retained eligibility epochs and receipts are historical evidence. Route evaluation
uses current configuration, which was not historically snapshotted. Coalescing
receipts omit the historical budget/window parameters; this uncertainty is explicit.
They are retained observations and may be stale after retries; absent receipts
never prove delivery or non-delivery. One reducer, deriveSlackDeliveryOutcome,
uses immutable attempt_started / terminal record pairs and a durable
request_dispatched marker written immediately before entering the network API.
Terminal results are confirmed, not_sent, rejected, or unknown_after_send.
Open attempts are derived on read. Recovery closes dead senders as not_sent
before dispatch and unknown_after_send after dispatch; live retired senders
can still append their actual terminal result. Counters do not establish proof.
Any confirmed attempt proves delivered. Otherwise an unknown/open dispatched
attempt gives uncertain; otherwise the outcome is withheld. Operator retry,
skip, abandon and acknowledgment do not change past attempt records. A confirmed
retry can therefore prove delivery after an earlier uncertain attempt.
A retained eligibility exclusion also proves withholding when no receipt exists.
Legacy attempted receipts without a complete journal remain uncertain.
classifySlackTransition is the exported classifier used by capture, recovery,
explanation and digest buckets. Each uses SDK alias normalization and the status
registry loaded from tracker settings;
configured roles override literal names (including done configured as blocked).
Literal status names are a fallback inside that classifier only. Status roles use
the current registry, and this historical limitation is explicit in every result.
Output has six stages plus at most 100 indexed routing-rule decisions. Identity
validation accepts only item, event, revision and the optional positive safe integer version, a bounded safe item id, a
canonical event and a lowercase 64-character SDK history hash. Routing rules are
strictly bounded to 100 entries and 250000 characters; selectors to 160 and overrides
to 2048 characters. Priority configuration must be an integer 1..4. Invalid input,
invalid routing configuration, unknown/non-lifecycle or ambiguous repeated state hashes, unreadable history
and unreadable storage return typed nonzero refusals with remedies. Foreign
receipt/policy records are skipped with foreign_evidence_skipped uncertainty.
The command reads one item's retained history and opens existing outbox storage read-only in a single SQLite snapshot. It never creates an outbox, captures events, drains, sends, or changes tracker files. History verification reads the full selected item stream; work scales with that stream, while output stays bounded. See the SDK/CLI example and public-tracker measurement.
Destination migration plans
pm slack migrate previews a route change between two explicitly named aliases.
Bind each alias to default or an existing PM_SLACK_ROUTES selector, then review
the returned binding_revision and plan_revision before applying:
pm slack migrate --action bindings --json
pm slack migrate --action bind --alias old --route default --binding-revision 0
pm slack migrate --action bind --alias new --route close --binding-revision 1
pm slack migrate --from old --to new --json
pm slack migrate --action apply --from old --to new \
--binding-revision 2 --plan-revision <reviewed-preview-hash>Both preview and apply post nothing. Apply moves each eligible existing receipt atomically; the regular outbox drain uses its new route afterward. Only pending events with proven non-delivery can move. Sending, ambiguous, previously uncertain and terminal receipts retain their original route and evidence, even after an operator retries an uncertain send. The target binding replaces the channel override, including clearing it when the target has no channel override.
Alias revisions and queue generations grow monotonically. A configuration or
queue change invalidates an old preview, including change-and-change-back edits.
binding_changed and plan_changed refusals explain how to preview again. Replaying
an applied plan reads the current targets and returns already_applied or
superseded, moving zero events. Replaying original lifecycle inputs keeps the
same queue key. Automatic recovery continues using the existing outbox.
Read-only behavior includes the complete CLI command, renderer and hook lifecycle.
Migration previews and binding inventories, coalescing previews and receipts, and
outbox status never recover dead owners, upgrade schemas, create runtime files or
change permissions. Dry-run and non-lifecycle hooks do not open writable storage.
SDK inspection uses new SlackOutbox(pmRoot, { readOnly: true }); an absent store
returns an empty status without creating a database. Explicit writable operations
retain durable recovery. Legacy receipt schemas remain readable without upgrades.
Plain and coalesced drains both re-read destinations and check ambiguity holds inside the shared claim transaction. A route changed after candidate discovery is deferred to the next drain; an in-flight ambiguous target cannot receive a second event through migration. Sending and hold accounting use the claimed destination, and a deferred receipt gets no attempt or state change.
JSON and default compact TOON report exact totals, safe receipt keys, skipped
foreign records, omitted, and next_cursor. Use --limit 1..100 and --after
to page the evidence; applying always considers the complete plan. Limits are
100 aliases, 10000 matching events per atomic plan, 64-character lowercase alias
names and 256-character channel overrides. Existing payloads and target mappings
over 1 MiB of UTF-8 JSON, malformed/foreign records, nonfinite JSON numbers and
nesting over 64 levels stay untouched with a foreign receipt. Events with
exhausted revisions also remain in place; exhausted global revision capacity
returns the same typed refusal in preview and apply. Alias bindings
contain only a route fingerprint and optional channel in private runtime storage;
public output includes neither webhooks, channels nor message bodies. Keep the
target route configured: ordinary delivery refuses if its fingerprint disappears.
Bindings govern the explicitly reviewed queued transition, while routing rules
still select destinations for future events.
The typed SDK exports migrateSlackDestinations(pmRoot, input),
DestinationMigrationInput, DestinationMigrationReport, DestinationMigrationRow
and DestinationBinding. It uses the same strict input schema and atomic evaluator
as the CLI. No tracker item, relationship, status or native field is rewritten.
See the complete example and
measured public-data validation.
Without git
Notification previews, digests, routing explanations, and local durable outbox inspection and destination migration work on plain initialized trackers without a git executable. Slack posting needs an explicitly configured webhook and network connectivity. The explanation command needs neither. It makes zero network calls.
Retained delivery proof
Each claim appends an immutable started record and token under the same SQLite writer transaction as its work claim. Completion records that token's actual transport result before releasing work. A late completion can settle its own evidence after operator reconciliation without overwriting a peer's work state. Operator decisions are journaled separately. Open attempts are derived from started records without a terminal fact; unknown/foreign results and incomplete legacy history remain uncertain.
status, plain/coalesced drain reports, coalescing manifests, and explanations all
call deriveSlackDeliveryOutcome. Each report row has an outcome; aggregate
outcomes counts are separate from compatibility counts, which describe work
states only. Digest counts describe tracker activity and are unrelated to delivery.
Neither a skipped nor an operator-acknowledged count proves a delivery result.
Dispatch-aware SDK transports accept a third OutboxSendContext argument and
call context.requestDispatched() immediately before their network API and
context.responseConfirmed() immediately after a confirmed successful response.
The package transport does both automatically, including through asynchronous
wrappers that do not forward the context. A typed or untyped exception after
dispatch is uncertain unless a response was confirmed; an error code never proves
not_sent. Confirmation is retained before the caller resumes, so later callback
errors cannot hide a successful delivery. Existing
two-argument transports are conservatively marked before callback invocation.
A clean exit always closes its attempt, including cancellation and late refusal;
a process crash leaves recovery to the durable dispatch marker.
The attempt journal contains keys, attempt tokens, result codes and operator
decisions, never webhook URLs or message bodies. The explanation path still opens
existing storage read-only and never creates or migrates it.
Loss-aware Slack payload compilation
pm slack compile --item task-1 --link https://example.com/items/task-1 compiles an SDK item offline and reports exact clipping/omission ranges with stable recovery links. Identity, status, priority and relationships remain protected; content that cannot fit refuses with a typed remedy. JSON and TOON return a compact paginated ledger by default; add --payload for the wire data. --input accepts an unclipped static Slack source envelope.
The SDK exports compileSlackPayload, compileSlackItem, validateSlackWirePayload and SlackCompilationError. See the schema, bounds and runnable example. All webhook paths validate before opening a request. Existing renderer truncation does not become a loss receipt retroactively.
Without git: payload compilation
Item and file compilation work in a plain SDK tracker without git. Both are read-only and require no Slack credentials. No new operation needs a git repository.
Compilation work is bounded as well as wire size: source-field passes are linear,
and byte budgeting encodes each block once. Archive probes refuse linked entries
before changing permissions and never chmod link targets during cleanup. Outbox
write paths refuse linked storage/sidecars with storage_unavailable; existing
read-only observations remain compatible. See the class-wide regression and
revert evidence.
