pm-ops
v2026.9.28
Published
Multi-repo fleet operations for pm-cli
Maintainers
Readme
pm-ops
Multi-repo fleet operations for pm-cli.
pm-ops gives coding agents one command surface for operating across many pm-* repositories: audit release readiness, enforce naming/workflow policies, run a release-gate matrix, and emit concise fleet reports. Its optional lint and duplication toolchains stay out of the extension's runtime dependencies.
Philosophy: project management = context management, applied to a fleet of repos.
Installation
pm install github.com/unbraind/pm-ops --projectOr install globally:
pm install github.com/unbraind/pm-ops --globalCommands
pm ops scan
Scan a set of repos and produce a per-repo release-readiness snapshot.
pm ops scan
pm ops scan --repos ./pm-csv ./pm-github
pm ops scan --repos ./pm-csv,./pm-github --json
pm ops scan --format markdown
pm ops scan --repos ~/container/pm-* --format markdown --output FLEET.mdFor each repo scan checks:
package.jsonpresent (name, version)tsconfig.jsonwithstrict: trueCHANGELOG.mdpresent.github/workflows/release.ymlandci.ymlpresent.agents/pmworkspace + open/in_progress item counts (pm list --json)pm-changelogdeclared in dependencies/devDependencies, or explicitly self-hosted by the generator packagenpm outdatedcountnpm audit --omit=devcritical/high counts- open PRs/issues via
gh(when the repo is a GitHub repo)
A repo is ready when it has a package.json, strict TS, a changelog, both CI/release workflows, pm-changelog wired, and a successful audit with zero critical vulnerabilities. An unavailable or malformed online audit is reported and blocks readiness instead of being mistaken for a clean result. In explicit offline mode, network checks are skipped and do not gate file-based readiness.
The JSON field has_pm_changelog records a declared dependency. self_hosts_pm_changelog separately identifies the generator package named pm-changelog, with bin pm-changelog: dist/cli.js, a changelog:full script starting with node dist/cli.js, and changelog:check set to npm run changelog:full -- --check. Scan and status accept either dependency wiring or this self-hosted configuration. These are structural checks; ops verify-release runs the actual release checks.
Flags
| Flag | Type | Default | Description |
|---|---|---|---|
| --repos <paths> | string[] | current dir | Repo paths to scan (comma-separated or repeatable) |
| --json | boolean | false | Emit clean JSON to stdout (progress on stderr) |
| --format <toon\|json\|markdown> | string | toon | Output format |
| --output <file> | string | — | Write the rendered output to a file instead of stdout |
pm ops policy
Validate a policy bundle against repos. The default policy (no file needed) checks:
- naming — repo name matches
^pm-[a-z][a-z0-9-]*$(nopm-ext-/pm-preset-prefixes) - required-scripts —
package.jsonhastypecheck,test,build,release:check,changelog,changelog:check; the explicit self-hosted configuration above useschangelog:fullin place ofchangelog(custom policy requirements remain exact) - required-workflows —
ci.yml+release.ymlpresent - private-no-runners — private repos must NOT use
runs-on: github-hosted/macos-/windows-/ubuntu-(skipped for public repos) - pm-duplicate-titles — no two OPEN pm items share the same title
- pm-changelog-wired —
pm-changelogin dependencies/devDependencies AND achangelogscript exists, or the explicit self-hosted configuration above
pm ops policy
pm ops policy --repos ./pm-csv ./pm-github
pm ops policy --policy ./fleet-policy.json --strict
pm ops policy --format markdown--policy <file> loads a JSON bundle overriding the defaults:
{
"checks": [
{ "id": "naming", "severity": "error" },
{ "id": "required-scripts", "severity": "error", "repo_filter": "pm-csv",
"params": { "scripts": ["typecheck", "test", "build"] } }
]
}--strict exits non-zero on any failed check (any severity).
Flags
| Flag | Type | Default | Description |
|---|---|---|---|
| --repos <paths> | string[] | current dir | Repo paths to check |
| --policy <file> | string | built-in | JSON policy bundle |
| --json | boolean | false | Emit clean JSON to stdout |
| --format <toon\|json\|markdown> | string | toon | Output format |
| --strict | boolean | false | Exit non-zero on any failure |
| --output <file> | string | — | Write the rendered output to a file |
pm ops verify-release
Run the release gate matrix per repo: executes npm run release:check (or the individual typecheck / build / test / audit:prod / pack:dry-run / changelog:check steps when release:check is missing) and reports pass/fail with per-step timing. Does NOT publish. Exits non-zero if any repo fails.
pm ops verify-release
pm ops verify-release --repos ./pm-csv ./pm-github
pm ops verify-release --jsonFlags
| Flag | Type | Default | Description |
|---|---|---|---|
| --repos <paths> | string[] | current dir | Repo paths to verify |
| --json | boolean | false | Emit clean JSON to stdout |
| --format <toon\|json\|markdown> | string | toon | Output format |
| --output <file> | string | — | Write the rendered output to a file instead of stdout |
pm ops report
Emit a concise fleet report combining scan + policy results (and optionally verify-release). The markdown format includes a timestamp header and sectioned tables.
pm ops report
pm ops report --repos ./pm-csv ./pm-github --format markdown
pm ops report --format markdown --output FLEET.md
pm ops report --format markdown --include-release --output FLEET.md
pm ops report --jsonFlags
| Flag | Type | Default | Description |
|---|---|---|---|
| --repos <paths> | string[] | current dir | Repo paths to report on |
| --json | boolean | false | Emit clean JSON to stdout |
| --format <toon\|json\|markdown> | string | toon | Output format |
| --output <file> | string | — | Write the report to a file instead of stdout |
| --include-release | boolean | false | Also run verify-release and include results |
pm ops status
Quick fleet status overview — faster than scan because it skips GitHub PR/issue probes. For each repo shows name, version, ready/not-ready, open pm items, outdated deps, and critical/high vulnerabilities, plus a concise list of issues.
pm ops status
pm ops status --repos ./pm-csv ./pm-github
pm ops status --format markdownFlags
| Flag | Type | Default | Description |
|---|---|---|---|
| --repos <paths> | string[] | current dir | Repo paths |
| --json | boolean | false | Emit clean JSON to stdout |
| --format <toon\|json\|markdown> | string | toon | Output format |
| --output <file> | string | — | Write the rendered output to a file |
pm ops outdated
Check outdated dependencies across repos. Runs npm outdated --json per repo and summarizes packages with newer versions available.
pm ops outdated
pm ops outdated --repos ./pm-csv ./pm-github
pm ops outdated --format markdownFlags
| Flag | Type | Default | Description |
|---|---|---|---|
| --repos <paths> | string[] | current dir | Repo paths |
| --json | boolean | false | Emit clean JSON to stdout |
| --format <toon\|json\|markdown> | string | toon | Output format |
| --output <file> | string | — | Write the rendered output to a file |
pm ops audit
Security vulnerability audit across repos. Runs npm audit --omit=dev --json per repo and summarizes critical/high/moderate/low counts.
pm ops audit
pm ops audit --repos ./pm-csv ./pm-github
pm ops audit --format markdownFlags
| Flag | Type | Default | Description |
|---|---|---|---|
| --repos <paths> | string[] | current dir | Repo paths |
| --json | boolean | false | Emit clean JSON to stdout |
| --format <toon\|json\|markdown> | string | toon | Output format |
| --output <file> | string | — | Write the rendered output to a file |
pm ops metrics
Export pm workspace health as Prometheus text-format gauges so a Prometheus/Grafana stack can scrape fleet project-management signals — turning project management = context management into a dashboard. Reads each repo's items via the canonical pm CLI forms (pm list --all, pm list --status blocked) and derives counts, throughput, and cycle-time in-process (the same closed_at methodology pm-brief momentum uses).
Every pm item-list read explicitly uses --output-include full, --output-budget unbounded, and --output-limit unbounded and is accepted only when the CLI's pagination, completeness, omission, and row-count receipts all prove that every item was returned. If any signal disagrees, pm-ops marks that repository as unavailable (pm_workspace_available 0) instead of publishing plausible but incomplete throughput, cycle-time, status-count, or blocked-item data.
pm ops metrics # Prometheus exposition for the current repo
pm ops metrics --repos ~/container/pm-* # fleet-wide, one series set per repo
pm ops metrics --output /var/lib/node_exporter/pm.prom # node_exporter textfile collector
pm ops metrics --stale-days 7 --format json # structured payload instead of expositionExported metrics (all gauges, labelled by repo):
| Metric | Labels | Meaning |
|---|---|---|
| pm_items | status | Item count by lifecycle status |
| pm_active_items_by_type | type | Active (non-closed/canceled/draft) items by type |
| pm_active_items_by_priority | priority | Active items by priority (0..4, or none) |
| pm_blocked_items | — | Open items blocked by unresolved dependencies (pm list --status blocked) |
| pm_stale_items | — | Active items not updated within --stale-days (default 14) |
| pm_throughput_items | window (7d,30d) | Items closed within the trailing window |
| pm_cycle_time_seconds | quantile (0.5,0.9) | closed_at − created_at of closed items |
| pm_backlog_age_seconds | quantile (0.5,0.9) | now − created_at of active items |
| pm_workspace_available | — | 1 if the repo exposed a readable pm workspace, else 0 |
| pm_repos_scanned | — | Number of repos with a readable pm workspace |
| pm_scrape_duration_seconds | — | Collection time for this scrape |
Fleet totals are intentionally not pre-aggregated — expose per-repo series and let Prometheus roll them up (sum(pm_items{status="open"}), avg(pm_cycle_time_seconds{quantile="0.5"})).
Flags
| Flag | Type | Default | Description |
|---|---|---|---|
| --repos <paths> | string[] | current dir | Repo paths |
| --stale-days <days> | number | 14 | Age after which an active item counts as stale |
| --json | boolean | false | Emit the structured JSON payload instead of exposition |
| --format <prometheus\|json\|toon> | string | prometheus | Output format |
| --output <file> | string | — | Write output to a file (e.g. a node_exporter .prom textfile) |
This command is read-only and derives metrics purely from pm item state. It does not touch, and is distinct from, the ecosystem's core telemetry/observability stack.
Agent usage
pm-ops is designed for coding agents operating across a fleet of pm-* repos:
- Deterministic JSON. Every command supports
--jsonfor strict parsing; human-readable progress goes to stderr so stdout stays clean. - Stable ordering. Repo results are emitted in the order passed on
--repos. - Failure diagnostics.
verify-releasewrites the full per-check matrix to stdout then throws a non-zero exit on failure, so agents get both the diagnostics and the exit code. - Offline mode. Set
PM_OPS_OFFLINE=1to skipnpm outdated/npm audit/ghcalls (useful in air-gapped CI); file-based checks still run. - No shell injection. All subprocess calls (
pm,npm,gh) pass args as arrays viaspawnSync— never through a shell. - Small runtime surface. The extension keeps only its main command dependencies in
dependencies; optional lint and duplication tooling is supplied by consumers that use those exports.
Output formats
- toon (default) — compact, host-rendered TOON of the structured result; easy to read in a terminal.
- json —
JSON.stringify(result, null, 2); the same object shape for every command. - markdown — GitHub-flavoured tables suitable for pasting into PRs, issues, or a
FLEET.md.
Result shapes
scan → { repos: RepoScan[], summary: { total, ready, not_ready } }
policy → { repos: RepoPolicy[], summary: { total, passed, failed, by_severity } }
verify-release → { repos: RepoRelease[], summary: { total, passed, failed } }
report → { generated_at, scan: ScanResult, policy: PolicyResult, release?: VerifyReleaseResult }
status → { repos: RepoStatus[], summary: { total, ready, not_ready, total_issues } }
outdated → { repos: RepoOutdated[], summary: { total, repos_with_outdated, total_outdated } }
audit → { repos: RepoAudit[], summary: { total, clean, with_vulns, total_critical, total_high } }
Canonical merge-driver install (pm-ops/merge-driver)
Git never clones .git/config, so every fleet package has to run pm merge install from its
npm prepare script or concurrent agents conflict on tracker files. This package is the one
canonical implementation.
Consumers copy templates/prepare-merge-driver.ts unchanged to
scripts/prepare-merge-driver.ts and set "prepare": "node scripts/prepare-merge-driver.ts". The
template imports nothing from pm-ops. pm-ops is a devDependency, so a checkout installed with
npm install --omit=dev (scripts enabled) does not have it, and a static
import … from "pm-ops/merge-driver" would fail at module load, before any fallback could run.
Dynamic import() is forbidden by the fleet lint gate. Instead the template resolves
pm-ops/merge-driver/prepare from the package root and runs it in a child process:
- pm-ops not installed (
--omit=dev): exits0after exactly one notice line - pm-ops too old to export the entry, or its entry file missing: fails the install loudly, and never skips
- a broken install (
node_modules/pm-opsleft as a directory without itspackage.json, or as a dangling link): fails the install loudly; resolution fails there with the sameMODULE_NOT_FOUNDan omit-dev install produces, so the template also looks for that entry before skipping - pm-ops installed: runs
runPrepareMergeDriver(below) and propagates its exit status
Fixture tests in test/merge-driver-launcher.test.ts execute the shipped template against each of
those consumer layouts with a stub pm on PATH.
runPrepareMergeDriver (also exported from pm-ops/merge-driver) resolves the real pm launcher on the supplied PATH/platform, then:
- missing
pm: exits0and prints one notice line - present
pmwhosepm merge installfails: non-zero exit with that command's output - Windows: honours quoted PATH entries and PATHEXT shims, and sets
shell: trueonly onwin32so.cmdlaunchers run; the POSIX path is never faked
This repository's own prepare script is that launcher (with an isMainInvocation guard so the suite can import it). To (re)run manually: npm run merge:install.
Canonical code-quality exports
The package publishes one strict ESLint flat-config factory and one programmatic jscpd gate. Both are TypeScript-only and work with the fleet's TypeScript 5 and TypeScript 7 consumers because parsing is supplied by Babel, not typescript-eslint.
import { fleetEslintConfig } from "pm-ops/eslint";
export default fleetEslintConfig({ ignores: ["generated/**"] });The lint launcher a consumer can add is:
import { runLintGate } from "pm-ops/eslint";
process.exitCode = await runLintGate();runLintGate uses the canonical policy, prints stylish diagnostics to stderr, and
returns 0 or 1 for use as the process exit code.
The duplication export reads package.json, scans **/*.ts by default (including
root sources, scripts/, and tests), reports every clone pair with both file
line ranges, and fails when the measured percentage is above the configured
threshold. globs and minTokens can be overridden for direct analysis;
the gate uses minTokens: 50 when the field is omitted.
Consumers
The quality exports use optional peer dependencies so installing pm-ops as a
pm extension does not download tooling that the extension surface does not
run. A repository importing pm-ops/eslint adds these exact devDependencies:
@babel/eslint-parser, @babel/plugin-syntax-typescript, and eslint. A
repository importing pm-ops/duplication adds fast-glob and jscpd (either
jscpd 4.3.0 or 5.2.1 — both majors are supported). A repository using both
exports adds all five packages, using the version ranges shown in
package.json.
import { analyzeDuplication, runDuplicationGate } from "pm-ops/duplication";
const report = await analyzeDuplication({ globs: ["src/**/*.ts", "test/**/*.ts"] });
await runDuplicationGate();A consumer adds these package fields (the launcher paths may be named otherwise, but must remain thin imports of the canonical exports):
{
"scripts": {
"lint": "node scripts/lint.ts",
"duplication": "node scripts/duplication-gate.ts",
"release:check": "npm run lint && npm run duplication && ..."
},
"devDependencies": {
"@babel/eslint-parser": "^8.0.5",
"@babel/plugin-syntax-typescript": "^8.0.3",
"eslint": "^10.10.0",
"fast-glob": "^3.3.3",
"jscpd": ">=4.3.0 <6.0.0",
"pm-ops": "<current pm-ops version>"
},
"duplicationGate": {
"threshold": 0,
"minTokens": 50
}
}The canonical ESLint factory enforces the eight forbidden syntax selectors
(TSAnyKeyword, ImportExpression, TSImportType, TSParameterProperty,
TSEnumDeclaration, TSModuleDeclaration, TSImportEqualsDeclaration, and
TSExportAssignment) plus the fleet's correctness rules. It ignores generated
and dependency output by default; pass ignores to add project-specific paths.
License
MIT © unbrained
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, a thin launcher over pm-ops/merge-driver). 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.
