Fleet map
Where every pm package and hosted service lives on this host, what tracks it, and how the pieces relate. This is the index to reach for when you need to find a thing rather than read about it.
Verified against pm-cli 2026.8.6; package delivery and pm-rust status refreshed 2026-08-06.
Packages
Package repos are independent git repositories whose sole canonical public-package
checkout is /home/steve/GITHUB_RELEASE/<package>. Each is public on GitHub under
unbraind and published to npm. Each tracks its own
work in its own .agents/pm tracker — that tracker is authoritative for the
package, and this companion repo only records cross-package state.
| Package | Local path | npm | GitHub | Tracked items |
|---|---|---|---|---|
| pm-beads | /home/steve/GITHUB_RELEASE/pm-beads |
pm-beads |
repo | 51 |
| pm-brief | /home/steve/GITHUB_RELEASE/pm-brief |
pm-brief |
repo | 45 |
| pm-changelog | /home/steve/GITHUB_RELEASE/pm-changelog |
pm-changelog |
repo | 122 |
| pm-context | /home/steve/GITHUB_RELEASE/pm-context |
pm-context |
repo | 38 |
| pm-csv | /home/steve/GITHUB_RELEASE/pm-csv |
pm-csv |
repo | 58 |
| pm-gantt-chart | /home/steve/GITHUB_RELEASE/pm-gantt-chart |
pm-gantt-chart |
repo | 55 |
| pm-github | /home/steve/GITHUB_RELEASE/pm-github |
pm-github |
repo | 70 |
| pm-graph | /home/steve/GITHUB_RELEASE/pm-graph |
pm-graph |
repo | 55 |
| pm-jira | /home/steve/GITHUB_RELEASE/pm-jira |
pm-jira |
repo | 64 |
| pm-linear | /home/steve/GITHUB_RELEASE/pm-linear |
pm-linear |
repo | 55 |
| pm-ops | /home/steve/GITHUB_RELEASE/pm-ops |
pm-ops |
repo | 44 |
| pm-presets | /home/steve/GITHUB_RELEASE/pm-presets |
pm-presets |
repo | 38 |
| pm-rl | /home/steve/GITHUB_RELEASE/pm-rl |
unpublished (gated) | repo | 26 |
| pm-rust | /home/steve/GITHUB_RELEASE/pm-rust |
unpublished (gated) | repo | 2 |
| pm-slack | /home/steve/GITHUB_RELEASE/pm-slack |
pm-slack |
repo | 45 |
| pm-slack-standup | /home/steve/GITHUB_RELEASE/pm-slack-standup |
pm-slack-standup |
repo | 49 |
| pm-starter | /home/steve/GITHUB_RELEASE/pm-starter |
pm-starter |
repo | 38 |
| pm-todos | /home/steve/GITHUB_RELEASE/pm-todos |
pm-todos |
repo | 53 |
| pm-ts-starter | /home/steve/GITHUB_RELEASE/pm-ts-starter |
pm-ts-starter |
repo | 40 |
| pm-vcs | /home/steve/GITHUB_RELEASE/pm-vcs |
unpublished (gated) | repo | 70 |
| pm-web | /home/steve/GITHUB_RELEASE/pm-web |
@unbrained/pm-web |
repo | 81 |
Delivery status — 2026-08-06
Twenty package branches resolve pm CLI 2026.8.6 and are strict-health green. All twenty delivery PRs merged on 2026-08-06 after their exact heads passed package-owned release gates, GitHub Actions, pm-changelog checks, privacy scans, and the available external reviews. Each workflow now runs the strict durable workspace-health gate. The gate detects checkout-visible tracker corruption and stale work; it does not attest that every concurrent scalar edit survived a merge because merge receipts are clone-local and a later repair can erase the remaining drift signal. Upstream #921 and #922 track the two proven failure shapes.
| Package | Package item | Pull request | Exact-head state |
|---|---|---|---|
| pm-beads | pm-beads-bzpz |
#62 | merged; exact-head checks green |
| pm-brief | pm-brief-3zb6, pm-brief-nkfr |
#56 | merged; attribution repair and durable health gate green |
| pm-changelog | pmc-vm22 |
#125 | merged; exact-head checks green |
| pm-context | pm-context-5925, pm-context-k6aa, pm-context-6ywm, pm-context-dmsf |
#52 | merged; 195 tests at exact 100/100/100; DeepScan clean |
| pm-csv | pm-csv-c8j4 |
#58 | merged; exact-head checks green |
| pm-gantt-chart | pm-gantt-chart-th16 |
#50 | merged; exact-head checks green |
| pm-github | pm-github-eh1h |
#29 | merged; exact-head checks green |
| pm-graph | pm-graph-9qk5, pm-graph-y2ax |
#56 | merged; Node 22/26 and external gates green |
| pm-jira | pm-jira-zyst |
#55 | merged; exact-head checks green |
| pm-linear | pm-linear-9848 |
#57 | merged; exact-head checks green |
| pm-ops | ops-whzj, ops-54ed |
#38 | merged; attribution repair and durable health gate green |
| pm-presets | pm-un5v, pm-ruq3 |
#45 | merged; attribution repair and durable health gate green |
| pm-rl | pm-rl-d3ve, pm-rl-e0vc, pm-rl-tqym, pm-rl-mxe0, pm-rl-q7xw |
#5 | merged; 23 tests at exact 100/100/100; changelog contract repaired |
| pm-slack | pm-slack-k8yl |
#53 | merged; exact-head checks green |
| pm-slack-standup | pm-slack-standup-2f0f |
#28 | merged; exact-head checks green |
| pm-starter | pm-starter-5bvm, pm-starter-64jd, pm-starter-cskq, pm-starter-3a6b |
#53 | merged; 142 tests at exact 100/100/100 |
| pm-todos | pm-todos-qpyp |
#42 | merged; exact-head checks green |
| pm-ts-starter | pm-ts-starter-kksc, pm-ts-starter-gddt, pm-ts-starter-q6xq, pm-ts-starter-cpm7 |
#55 | merged; 118 tests, zero skips, exact 100/100/100 |
| pm-vcs | pm-vcs-kwlz |
#12 | merged; 497 tests at exact 100/100/100 |
| pm-web | pm-web-yiym, pm-web-3u1v |
#81 | merged; 260 tests against disposable PostgreSQL |
GitHub's critical Actions incident initially prevented exact-head runs; delivery
remained blocked until webhook throughput recovered and every package check
completed successfully. Fleet-wide exact coverage is still open as
pm-cli-website-ktip. pm-context, pm-rl, pm-starter,
pm-ts-starter, and pm-vcs meet 100/100/100 in this delivery; the other packages
pass their current ratchet gates without an exact-coverage claim.
Fleet security APIs reported zero open Dependabot alerts and zero open
secret-scanning alerts in all twenty public repositories. Code-scanning returned
no analysis found for every repository, so that surface is unavailable rather
than proven clean. HTTPS smoke checks returned 200 with valid TLS for
pm-cli.unbrained.dev, pm-web.unbrained.dev, pm-mcp.unbrained.dev,
pm-gpt.unbrained.dev, and pm-search.unbrained.dev; these probes did not read or
modify hosted user data.
pm-starter and pm-ts-starter are authoring templates, copied by anyone
scaffolding a new extension — not user-facing packages. They are deliberately
excluded from the pm-web package catalog.
pm-vcs is a version control system, not a git helper
This is the one package in the fleet that is not an integration or a reporter.
It is a version control system, written from scratch, and it is the sharpest
expression of project management = context management anywhere in the
ecosystem. Read this before touching it, because its first release
(2026.7.30) was a git helper and the distinction matters.
Git's merge is a merge of lines. A tracker item is a record — status, priority, a set of tags, an append-only history — and two agents on two branches routinely touch two different fields of one item. Whether git can merge that depends on how many lines apart the fields happen to be serialized, which is an accident of the file format rather than a property of the change. The ecosystem's existing answer is merge drivers bolted onto git; that works, and pm-cli ships it, but it patches a mismatch rather than removing it.
pm-vcs removes it. record is a first-class object kind alongside blob, tree
and commit, and merging two revisions of a record compares fields. There is no
line merge in the pipeline for tracker data, so there is nothing to go wrong.
What is actually in /home/steve/GITHUB_RELEASE/pm-vcs/engine/ (≈3,600 lines, zero shell-outs to
git, nothing git-compatible on disk):
| module | what it is |
|---|---|
objects.ts |
SHA-256 content addressing over <type> <len>\0<payload>; zlib loose objects; reads re-hash rather than trusting the filename |
model.ts |
canonical tree / commit / record encodings; trees sort by byte order, never locale collation |
refs.ts |
branches, tags, HEAD, compare-and-swap under an exclusive lock |
diff.ts |
Myers O(ND) line diff, hunks, unified rendering |
merge.ts |
commit-DAG reachability, minimal merge bases (plural — criss-cross histories), diff3 |
records.ts |
per-field record merge; append-only log union |
worktree.ts |
index, working-tree scan, materialization, status |
ignore.ts |
an always-ignored set plus .pmvcsignore |
config.ts |
which paths hold records and how their fields merge, held per repository |
oplog.ts |
append-only operation log; undo |
bundle.ts |
export / import, verifying every object against its own id |
repo.ts |
the porcelain behind 15 pm vcs verbs |
Lineage, so the design intent is not lost: content addressing and the commit DAG
from git; the operation log and undo as a first-class verb from
Jujutsu; the conviction that project metadata belongs inside the VCS from
Fossil/Forgejo — taken further, since here it is a native object kind
rather than a table in an attached database. Subversion contributes nothing
structural beyond a caution about central state.
The shipped git-interop surface still exists and still has value; it moved to
pm vcs git preflight / preview / items and is the only part that touches
git.
The scope is the whole system, and the phases are tracked
Corrected on the maintainer's instruction, 2026-07-31: pm-vcs is not scoped as a merge engine that happens to have commands. It is a full version control system and forge, covering the capability space of git, jujutsu, subversion, Forgejo and lore, written from scratch on the pm SDK. The engine above is Phase 1 of six.
/home/steve/GITHUB_RELEASE/pm-vcs/ARCHITECTURE.md is the design document and is the file to read
before changing anything: it gives each layer's reasoning, states what is
deliberately refused, and carries a per-capability table that says outright which
rows are shipped and which belong to a phase. A document that presents intentions
as behaviour is worse than none, so that table names the phase instead.
| phase | what it adds | epic in unbraind/pm-vcs |
|---|---|---|
| 1 | the engine above, 15 verbs, 100/100/100 | closed features under pm-vcs-tr2a |
| 2 | change identities stable across rewrite; describe, rebase, squash, split, cherry-pick, revert, reset, restore; automatic descendant rebase |
pm-vcs-ijj7 |
| 3 | named remotes, remote-tracking refs, clone/fetch/push with reachability-based negotiation |
pm-vcs-wm40 |
| 4 | pm-vcs versions its own source, CI-gated so the tracked history must match the source tree byte for byte | pm-vcs-390t |
| 5 | the forge: a patch series as an object kind, review state as records, a served repository | pm-vcs-5h6j |
| 6 | scale: packed storage, reachability index, shallow/partial history, oplog-bounded gc | pm-vcs-b7cb |
pm-vcs-ljkx records the decision not to be git-compatible and why the
alternative was rejected: a git-compatible object format cannot hold a record as a
first-class kind, so per-field merge would go back to being a driver over
serialized text — the exact mismatch pm-vcs exists to remove.
Phase 4 is merged (unbraind/pm-vcs#10,
pm-vcs-390t, closed 2026-08-04): the repository versions its own git-tracked
source as a pm-vcs history in a committed selfhost.bundle, gated on every
commit.
The gate proved itself immediately, on its own author. The very next PR
(#11) closed the Phase 4 items and
regenerated the changelog — both changes to tracked source — and CI refused it
because the committed bundle no longer attested to them. One mechanical note that
costs a CI round if missed: the verifier compares an extraction of HEAD against
the bundle read via git cat-file blob HEAD:selfhost.bundle, so npm run self-host:write alone never clears it. The regenerated bundle must be
committed before re-running the gate.
That PR is worth reading as evidence rather than as a feature. It passed CI, passed its own 100% coverage gate, and had two clean bot reviews on the day it opened — while containing a gate that could not detect a truncated bundle, a history-loss path in the writer, two verification inputs read from the dirty working tree it advertised that it ignored, and 471 lines that no coverage gate had ever measured. Nine review rounds surfaced seven distinct defect classes, including four consecutive security Majors in the write path, each an escalation of the previous fix. Green CI was not evidence of correctness here, and the blind spot that hid the coverage half is fleet-wide (see below).
The gate's shape is the interesting part, and it was verified by exercising it rather than by reading it:
accept:self-hostis verify-only and never writes;self-host:writeis the separate developer action. A gate that regenerates the bundle and compares would pass trivially from any tree.- It reads the committed bundle and the committed source straight out of
HEAD, so a dirty working tree cannot move the verdict in either direction — confirmed by appending toledger.tsuncommitted and watching the gate still pass, then committing the same change and watching it fail with exit 1, naming the file and both tree ids. - The bundle is untrusted input: every object id is recomputed from its own bytes, so the bundle's header index is never taken on trust.
- Byte-exactness rides on the root tree id, and canonical trees hash name, mode
and blob id together — so an executable-bit change is caught, not just content.
test/self-host.test.tscovers exactly that, plus a tampered object id. - The exclusion set lives in
self-host.jsonas reviewable data. A tracked path in neither the bundle nor the exclusion set fails the gate closed.
Coverage determinism was measured, not assumed — ten consecutive npm run coverage runs all reported 24 source file(s) reported, thresholds met at
100/100/100, which matters because this package has had nondeterministic branch
coverage under a hard gate before.
Phase 3 landed as unbraind/pm-vcs#8
on 2026-08-03, after eight review rounds and 26 resolved threads. Fetch publishes
tracking refs itself rather than importing the sender's ref names; negotiation
filters the client's offered haves on the serving side; the fast-forward refusal
is answered by the receiver under compare-and-swap; and clone adopts the source's
record configuration before writing an object, so two repositories cannot share
commit ids while disagreeing about what those commits contain.
What green checks did not catch, and a ten-minute walk did. Driving the merged
surface through the installed CLI against two disposable repositories found two
gaps that 100/100/100 coverage, three bot review rounds and a green release gate all
missed. resolve never searched refs/remotes/, so the origin/main shorthand did
not resolve; and nothing printed the full spelling either, because branch listed
only local branches and fetch reports refs solely when they change. The push
refusal's own remediation — "fetch and merge first" — was therefore impossible to
carry out. Both are invisible to a test suite, because every test needing a tracking
ref already held its full name from the code that wrote it, and invisible to a
reviewer, because each command is individually correct. Fixed in
#9 (pm-vcs-dh19, companion
pm-cli-website-kipu): the shorthand resolves with local branches keeping
precedence, pm vcs branch --remotes lists tracking branches with ahead/behind
against HEAD, and the refusal names a spelling that works. The lesson generalises
to the fleet: a surface can be correct command-by-command and unusable end-to-end.
Phase 1 landed as unbraind/pm-vcs#2
(branch vcs-engine-object-store-refs-diff-merge). Two defects fell out of
deleting four inert c8 ignore pragmas — Node's coverage does not honour c8
pragmas, so they never suppressed anything and branch coverage read 99.65%.
ObjectStore.read had folded every filesystem errno into object_not_found, so a
permission denial was reported as history having lost objects and pm vcs verify
would tell an operator to re-import from a bundle when nothing was wrong; and
three call sites sorted by UTF-16 code unit while encodeTree hashed by UTF-8
byte order, so two names could order one way in a tree and the other way in a ref
listing. Sourcery cannot review this PR: it exceeds the 150 000-diff-character
limit. Later phase PRs are scoped to one engine module plus its commands and
tests for that reason.
Release is still gated. Its Daily Release workflow is disabled pending
maintainer approval, so npm view pm-vcs is a 404. Since 2026-07-31 the
website store renders that honestly as an unreleased state rather than
advertising a command that fails. Do not re-enable the workflow or add catalog
entries without approval.
Namespace hazard. pm-cli bundles a private @unbrained/pm-vcs exemplar
that claims the vcs alias and registers vcs log and vcs merge. Enabling
both in one workspace is unsupported; filed upstream as
pm-cli#832.
pm-rl: an RL programme as tracked project data
Started 2026-07-31. The canonical public repository is
unbraind/pm-rl, checked out only at
/home/steve/GITHUB_RELEASE/pm-rl; package-local items track the work under pm-rl-e20d.
Publication remains separately gated by maintainer approval, so the package is not
on npm and its Daily Release job stays disabled through PM_RELEASE_APPROVED.
The substrate argument is the reason it is a pm package rather than yet another experiment tracker, and both halves were checked rather than assumed:
- Metric series are append-only, and so is pm's repeatable
notescollection.pm-rl/2stores canonical NDJSON in compressed notes capped at 48 KiB decoded and 65 KiB serialized;pm-rl/1event notes remain readable. Identity is the accepted note occurrence, so equal payloads are distinct under explicit at-least-once semantics. PR #2 strengthened the real-branch test to append byte-equal payloads on both branches and prove both survive the merge. - The corollary, with a named counterexample:
pm appendwrites an item's body, which is a scalar field, so two concurrent writers conflict on every write and a conflicted body is unparseable. Metrics never go to a body. This is decisionpm-rl-mpd9and it constrains every command that records a metric. - Provenance is a graph pm already stores. A run depends on an environment version and a base checkpoint; an eval result on a run and a benchmark version; a transfer measurement on two environments. So "which of my results does this reward-spec change invalidate" is reachability, not a feature to build.
Two refusals are the point of the package, both exiting non-zero rather than
warning, because an ignored warning manufactures confidence: ranking across
environment versions (which launders a version change into an apparent
improvement), and ranking on a benchmark whose tasks contaminate the training
environment (pm-rl-p401). Sim-to-real is first class — a Transfer records the
measured per-metric gap between a simulator and its target, which is the number
that decides whether more sim training is worth anything and the one least likely
to be written down anywhere.
The first production slab landed in PR #1; bounded sustained ingestion landed in PR #2. The representative 40-mutation test retains 10,000 events in 99,526 history bytes from 736,650 input bytes (an observed workload-specific 13.51%), with exact 100/100/100 source coverage.
Not in scope, deliberately: orchestration (run log reads NDJSON on stdin so any
trainer pipes in), a separate metric store (the history stream is the store),
and any relationship to core's pm eval, which measures pm's own search relevance
and shares a word with this and nothing else.
One public checkout per package is canonical. A container/pm-* directory is
a hosted service, never a package checkout. Do not create a second checkout to
restore an old fleet/ path: verify the current location and update this map.
A vendored copy of a package inside another package was removed in this wave
(see "Retired duplication" below).
pm-rust: first native read slice merged
Tracked by companion item pm-cli-website-204t (priority 1), with package work
tracked by pm-rust-o2yr.
The sole local public checkout is /home/steve/GITHUB_RELEASE/pm-rust, and the
canonical public repository is unbraind/pm-rust.
PR #1 merged the first vertical
slice after exact-head Linux, macOS, Windows, coverage, security, changelog, and
pm health gates passed. Package publication and automated releases remain
disabled behind the privacy and explicit maintainer-approval gate.
It is the one sanctioned exception to the ecosystem TypeScript rule: production source, CLI, SDK, persistence, parsing, merge drivers, tests, benchmarks and build tooling must all be Rust, and the distributed binaries must have no Node, Bun or TypeScript runtime dependency. Generated interoperability fixtures may be language-neutral JSON, TOON and schema data.
The compatibility boundary is the part worth remembering: pm-rust must reproduce public pm behaviour from first principles, and must not wrap, embed, shell out to, or require the TypeScript pm CLI at runtime. Equivalence is therefore defined by shared black-box conformance vectors rather than shared implementation code, which is also what makes it useful — a second implementation is what exposes accidental TypeScript coupling in contracts the fleet currently treats as language-neutral.
The current slice discovers canonical workspaces, strictly decodes TOON item
documents, preserves package fields, rejects merge markers and duplicate IDs,
and exposes deterministic JSON list and get operations. It exactly matched
the official pm 2026.8.6 public projection across all 609 companion items. Its
15 filesystem/property/CLI tests reach 100% line, function, region, and branch
coverage, and tracked-tree plus raw-history privacy scans are clean.
Still deliberately absent: mutation, history replay, transactions, locking, merge drivers, extensions, search, packaging, and releases. Those remain on the canonical feature acceptance criteria and must land as separately proven native Rust slices rather than being inferred from the read-only milestone.
The fleet is green package-by-package and not green together
Found 2026-08-04 by installing all 17 published packages into one fresh workspace
with seeded items — the state a user reaches by installing the catalog from
pm-web. Tracked as companion epic pm-cli-website-u0tm.
Nothing is broken: every package installs and activates, load_failure_count and
activation_failure_count are both 0, and 111 top-level commands register
together. But pm health returns ok: false with 20 warnings, because
packages that are each correct alone contend for the same shared surfaces:
| collision | contenders |
|---|---|
parser override on list |
pm-starter ↔ pm-ts-starter |
preflight override |
pm-starter ↔ pm-gantt-chart, pm-github, pm-jira, pm-linear, pm-slack, pm-slack-standup, pm-todos, pm-ts-starter |
json renderer |
pm-context ↔ pm-changelog, pm-ops, pm-ts-starter, pm-starter, pm-brief |
toon renderer |
pm-context ↔ pm-changelog, pm-ops, pm-starter, pm-brief |
| pending no-op migration | pm-starter, pm-ts-starter |
Two reasons this is not merely untidy. First, per-package CI can never see it — each repo is green in isolation and the contention only exists in the union, so the gate that would catch it does not exist in any single repository. Second, the warnings are unresolvable as written: they name both contenders but never the winner, and pm-changelog/pm-ops sit at priority 50 while pm-context and pm-brief declare no priority at all, so the tie-break is discovery order rather than a contract. Filed upstream as pm-cli#890.
pm-ts-starter additionally still emits
extension_output_format_payload_echo_deprecated on stderr — the payload-echo
output_format override that core now tolerates for compatibility but wants
migrated to declineServiceOverride(). That is companion item
pm-cli-website-vj6z, still open.
Hosted services
All hosted services run under docker compose on this host, behind Caddy, with state on host bind mounts so no data lives in a container layer.
| Service | URL | Container | Built from | Data |
|---|---|---|---|---|
| Website | pm-cli.unbrained.dev | pm-cli-website |
website-server/ (this repo) |
— |
| Web UI | pm-web.unbrained.dev | pm-cli-web |
/home/steve/GITHUB_RELEASE/pm-web |
/home/steve/pm-hosted-data/pm-web-projects |
| GPT / MCP | pm-gpt.unbrained.dev, pm-mcp.unbrained.dev | pm-gpt |
/home/steve/container/pm-gpt |
shared with pm-web |
| Search | pm-search.unbrained.dev | pm-search |
pm-search/ (this repo) |
/home/steve/pm-hosted-data/pm-search |
| Graph store | internal | pm-graph-neo4j |
upstream image | /home/steve/pm-hosted-data/neo4j-* |
pm-mcp.unbrained.dev is the remote MCP endpoint for the hosted services and
is served by the pm-gpt container. It is not a second copy of the local
pm-mcp that pm-cli itself ships.
One dataset, several front ends. pm-web, pm-gpt, and pm-mcp all read and write
the same workspaces under /home/steve/pm-hosted-data/pm-web-projects, which is
what makes an edit in ChatGPT show up live in the web UI. Treat that directory as
production user data: never copy it into a repo, never point a test at it.
The website can serve frozen data while reporting healthy
The website refreshes itself every 5 minutes: npm install -g @unbrained/pm-cli@latest,
then regenerate contract data from the new binary. On 2026-08-03 it was found serving
2026.8.2 data that was eighteen hours old, with the container healthy throughout. Three
defects composed, and each is worth knowing separately because each can recur alone.
A killed install wedges the refresh permanently. npm publishes by renaming the installed directory aside to a sibling
.pm-cli-<suffix>. A timeout firing between that rename and the move leaves the staging directory behind, and npm derives the suffix deterministically — so every later install renames onto the same non-empty directory and failsENOTEMPTY, forever. Retries cannot help: the failure is on-disk state, not anything transient. The refresh now sweeps staging siblings before each attempt./healthzcomputed staleness and returned 200 anyway. The compose healthcheck iswget || exit 1, which reads only the status line, so the one consumer able to act onsyncStaleignored it. It now answers 503 when data has stopped advancing — which makesstart_periodload-bearing, since a cold boot is stale by definition. A boot test measured the first sync at ~300s with regeneration disabled; the live service enables it, so the grace period is 900s, matching the staleness bound.A documentation-free payload published as success. With the binary broken every
pm helplookup failed, and the generator wrote the payload and exited zero — overwriting a complete payload with an empty one. It now refuses above a quarter failures. Baseline is 5 unresolved of 115, becausepm contractsenumerates command pathspm helprejects (pm-cli#878).
Health is two axes, not one. The doc sync and the pm-data regeneration succeed and fail
independently, and lastSyncAt only ever represented the first. Reading staleness from it
alone meant a refused payload still looked healthy — the same blindfold, reintroduced by the
fix for it. /healthz now reports syncStale and regenerationStale against the same
bound, plus lastRegenerationError, so a 503 says which half is stale.
The freshness signal to trust is /api/pm-data.version and generatedAt, not process
liveness.
Public package vs hosted service
pm-web is the one package that is also a hosted service. The code is public
and the image builds from /home/steve/GITHUB_RELEASE/pm-web; the data is host-mounted and never
enters the repo. That separation is the invariant — not a private fork of the
code.
SDK surface adoption
pm-cli publishes nine SDK subpaths. Import counts across the fleet, excluding
installed copies under .agents/ (those are vendored duplicates of the same
package and would double-count):
| Subpath | Fleet imports | Notes |
|---|---|---|
| Counted 2026-07-31 by consuming package, which is the number that says whether a | ||
| surface is adopted; the earlier file counts mostly measured how many modules a single | ||
| package split itself into. The upstream pm-cli source repository is excluded — it is | ||
| the SDK's own repository, not a consumer. |
| Subpath | Packages | Which |
|---|---|---|
…/sdk/testing |
19 | everything except pm-vcs's newest module — createExtensionTestHarness |
@unbrained/pm-cli/sdk (root) |
17 | the default entry for most extensions |
…/sdk/authoring |
17 | defineExtension, ExtensionApi, flag/manifest types |
…/sdk/merge |
3 | pm-brief, pm-ops, pm-vcs — receipts, multi-agent reconciliation |
…/sdk/query |
2 | pm-context, pm-web |
…/sdk/graph |
2 | pm-github, pm-graph |
…/sdk/core |
1 | pm-context |
…/sdk/governance |
1 | pm-brief |
…/sdk/runtime |
1 | pm-beads |
…/sdk/contracts |
1 | pm-changelog; see below |
The four single-consumer subpaths are the standing adoption gap. sdk/governance and
sdk/contracts in particular encode host behaviour that packages currently re-derive by
hand, which is how the 2026-07-27 flag-collision wave happened in the first place.
sdk/contracts was the only subpath with zero fleet consumers until
2026-07-28. It exports 147 symbols, of which the important pair for us is
GLOBAL_FLAG_CONTRACTS and SUBCOMMAND_GLOBAL_FLAG_CONTRACTS: the host's own
machine-readable list of the flags it reserves, with aliases and value names.
Why that matters. When pm-cli 2026.7.27 claimed those flags, nine of eighteen
packages declared a colliding flag, and each collision quarantined the entire
extension rather than the offending command. 2026.7.28 turned this into a hard
loader failure — but only at activation, meaning after publish and after a
user installs. Upstream pm-cli#784
records that the SDK's own lintExtensionBlueprint and assertExtensionPreflight
still accept a blueprint the loader rejects, so no static check exists.
pm-changelog now carries test/host-flag-contracts.test.ts, which activates
through the real loader and asserts no declared flag collides, reading the owned
list from the SDK rather than a hardcoded copy so it tracks the host
automatically. Audited 2026-07-28: zero packages currently declare a
host-owned flag, so the gate is preventive, not remedial. Rolling it out to the
remaining packages is tracked as a hub item.
If you ever see a hardcoded host-flag list in one of these tests, that is a defect — it silently stops tracking the host.
Release and coverage gates
Every package releases on a daily schedule (cron in release.yml), publishes to npm, tags,
and cuts a GitHub release — but only if something changed since the last one, so there is
never more than one auto-release per day. release:check gates the publish: typecheck, build,
coverage, audit:prod, pack:dry-run, changelog:check.
Four things about this arrangement have bitten and will bite again.
The changelog heading date depends on the clock, twice over
Two independent axes, and fixing one does not fix the other.
Timezone. pm-changelog derived the release-heading date in local time until
2026.8.2. A host at a positive UTC offset generating late in the evening produced
tomorrow's heading while a UTC runner produced today's. Every package's lockfile is now
resolved to 2026.8.3 or newer. The declared ranges already admitted the fix, so no
dependency update was ever proposed — that is why it sat unnoticed, and why a green
Dependabot does not mean a current lockfile.
Clock. With no release tag, --date falls back to the current date, so the committed
heading records the day it was generated and changelog:check fails every day after. This
hits release-gated packages by construction, because gating means no tags. It bit pm-rl
and pm-vcs on the same day.
The only workaround today is --date <fixed>, which is an unconditional override, not a
fallback — so it will win over a real tag date the moment one exists. Removing the pins is
part of lifting a release gate, not optional cleanup. Both gated packages carry that on
their tracker item.
The pin has to cover seven sites per package: four package.json scripts (changelog,
changelog:full, changelog:check, release:notes) and three npx pm-changelog calls
spelled out again inside release.yml. The workflow does not invoke the npm scripts, so
pinning the scripts alone leaves the workflow generating a different date than the gate
validates. Two review rounds were needed to find all seven. The durable fix is for the
workflow to call the scripts instead of restating them.
Asking for a fallback-shaped flag upstream: pm-changelog#120.
Timezone, again, in the release job itself. RELEASE_TIMEZONE is Europe/Vienna and the
version is derived from a date in that zone, while the changelog heading falls back to the
current UTC date because the new tag does not exist yet when the changelog step runs.
Between 22:00 and 24:00 UTC those disagree — Vienna is already on the next day — so a
release publishes a version numbered for tomorrow with a heading dated today. The cron fires
at 03:23/03:41 UTC and never enters that window; workflow_dispatch does, and it is used
routinely (2–5 of the last 30 runs per package).
pm-vcs and pm-rl are fixed: Decide release exports a release_iso_date derived from the
year, month and day the tag itself encodes, and the changelog calls consume that instead of
reading a clock. The other seventeen still read the clock — tracked as pm-cli-website-kdr8.
Verifying a fix here needs three timezones, not two. TZ=Etc/GMT+12 is on the previous
UTC date only while the UTC time of day is before 12:00, so an afternoon run compares two
zones sharing a calendar date and proves nothing. TZ=Etc/GMT-14 is on the next UTC date
from 10:00. Their union covers the whole day, so the check crosses a boundary at any hour.
The release version used must also be untagged, since a tagged one takes its date from
the tag and cannot exhibit the defect at all.
Committed dist/ is load-bearing, and nothing used to keep it honest
Twelve packages track dist/ in git on purpose. pm install github.com/unbraind/<pkg>
copies the repository as-is and never runs a build, so the extension entry file has to
already be in the tree. The npm tarball is unaffected — prepack builds — so this only
governs the git-URL install path, which is the one every README documents.
The release job rebuilt that output and then discarded it: its commit step staged the
manifest, lockfile, sources and changelog, and in four packages never dist. Measured
2026-07-30, before the fix:
| Package | manifest | committed dist/ |
|---|---|---|
| pm-context | 2026.7.29 | 2026.7.28 |
| pm-gantt-chart | 2026.7.29 | 2026.7.28 |
| pm-graph | 2026.7.30 | 2026.7.28 |
| pm-web | 2026.7.30 | 2026.7.29 |
The other eight already listed dist in the staged paths and measured clean — a useful
cross-check that the drift measurement and the workflow reading agree.
Both halves are now closed. dist is staged behind git ls-files --error-unmatch 'dist/'
in every release commit, and CI runs rm -rf dist && npm run build followed by:
git status --porcelain=v1 --untracked-files=all --ignored=matching -- 'dist/'
Every flag in that line is there because a weaker form was defeated in review:
git status, notgit diff—git diff --quietreports only modifications to tracked files, so a build emitting a new file passed over an incomplete tree.--ignored=matching— two packages carrieddist/in.gitignorewhile tracking files inside it. That is legal (ignore rules never apply to already-tracked paths) and it made new artifacts invisible to plaingit statusas well, so a one-line.gitignoreedit was enough to silence the gate permanently. Those two entries are gone, but the flag stays: it is what stops the gate being switched off later.rm -rf distfirst —tscnever deletes output for a source that was removed or renamed, so an orphaned artifact survived every rebuild byte-identical and shipped.the trailing slash — a readability choice, and recorded here as a correction. An earlier revision of this section claimed
-- distalso matchesdist-test/, and that thedist-test/ignore entry showed up as phantom drift once--ignoredwas on. It does not. CodeRabbit challenged the claim on pm-web#76 and testing settled it: git pathspecs match whole path components, sodistnames thedistdirectory and nothing else. Verified with both directories tracked, withdist-test/gitignored, and with the gate's full command (--untracked-files=all --ignored=matching) — in every case a baredistreports nothing underdist-test/. The slash stays because it states the intent at a glance, not because it fixes anything.Worth keeping as a note about method: the other four findings above were each reproduced before being written down. This one was not, and it survived a full review round and two merged commits before a bot asked for the receipt.
Coverage thresholds are pinned to measured floors, not to the mandate
All 18 packages carry a coverageGate block in package.json. Those thresholds were pinned to
each package's measured value, so a green coverage run means "no regression below a low
bar", not "covered". As of 2026-07-30:
| Package | lines/branches/functions | Package | lines/branches/functions |
|---|---|---|---|
| pm-beads | 91/77/93 | pm-linear | 70/80/80 |
| pm-brief | 91/76/95 | pm-ops | 93/71/93 |
| pm-changelog | 89/78/90 | pm-presets | 75/83/64 |
| pm-context | 91/81/96 | pm-slack | 77/85/79 |
| pm-csv | 81/74/88 | pm-slack-standup | 83/87/87 |
| pm-gantt-chart | 88/87/90 | pm-starter | 74/58/56 |
| pm-github | 88/79/89 | pm-todos | 68/85/74 |
| pm-graph | 81/77/88 | pm-ts-starter | 90/69/74 |
| pm-jira | 82/81/82 | pm-web | 60/80/57 |
Because the coverage gate sits inside release:check, a threshold pinned within a fraction
of a percent of the measured value turns any environmental variance into a lost release day.
That is not hypothetical: pm-graph's 2026-07-29 release failed measuring 75.95% branches
against a 76% threshold, with every test passing, which is why pm-graph sat at 2026.7.28 on npm
while the rest of the fleet was on 2026.7.29.
That figure could not be reproduced. Seven independent measurements of the same commit — three local, two local with the release job's version bump applied, and both CI jobs on Node 22 and 26 — all report 76.41%. Branch and function coverage are stable across repeated local runs; line coverage moves by about 0.03%. When raising a threshold, leave real margin.
The release job rewrites source, and the pattern matters
release.yml step "Update release version" rewrites the version into manifest.json and into
index.ts/src/index.ts. The source rewrite uses two regexes: one for a manifest-object
version: property and one for an EXTENSION_VERSION constant.
The first was unanchored and non-global, so it rewrote the first version: "..." string
anywhere in the file. In pm-graph that was a nested help-output description 2700 lines in, which
every release silently restamped with a version number. Fixed there by anchoring to
/^ version:.../m (a two-space-indented manifest property).
Audited 2026-07-30: pm-graph was the only package where this misfired — in the other 15
packages carrying an index.ts, the first match is the intended manifest literal. The anchor is
a tightening, so it is safe to propagate, and propagating it is worthwhile because the failure
is invisible: the rewritten string still looks like a plausible version.
Companion tracker
This repo (/home/steve/container/pm-cli) is the hub. Its .agents/pm tracker
holds only what does not belong to a single package:
- session milestones spanning the fleet
- upstream
pm-cliissue trackers, mirroring what was filed and why - architecture decisions that cross package boundaries
Package-specific work belongs in that package's own tracker. When a companion item concerns one package, link it rather than restating it.
Retired duplication
pm-web/extensions/pm-graph/ used to hold a vendored copy of pm-graph,
tracked at version 0.1.4 and pinned to pm-cli ^2026.7.5 while the real package
was at 2026.7.27. pm-web now installs npm:pm-graph through the same catalog
path as every other package, and the fork is gone. If a vendored package copy ever
reappears, it is a bug.
Upstream
unbraind/pm-cli is upstream. Nothing is ever pushed there — no branches, no
PRs. The only contribution channel is GitHub issues, filed from here.