From 50c9aad65bde63982b24a23f30fb497363faaceb Mon Sep 17 00:00:00 2001 From: Stefan Preu Date: Tue, 14 Jul 2026 22:21:00 +0200 Subject: [PATCH 1/3] feat(sdk): make schema ownership SDK-first and modernize init prefixes Move the complete schema inspection and mutation engine into the public SDK while retaining a thin CLI compatibility adapter. Burn 17 private-core import edges and tighten the ESLint suppression ratchet from 174 to 170. Add canonical pm init --id-prefix support with --prefix alias, deterministic positional conflict handling, shell completions, runtime contracts, docs, tracker evidence, and packed-consumer coverage. Verified 5,701 tests at 100/100/100/100 coverage, strict static/docstring/package/token gates, contracts, security/version checks, packed npm smoke, 88-command dogfood, and fresh temporary-workspace SDK/CLI acceptance. --- .agents/pm/chores/pm-2a9n.toon | 5 +- .agents/pm/epics/pm-qwoy.toon | 5 +- .agents/pm/features/pm-3mna.toon | 23 +- .agents/pm/history/pm-2a9n.jsonl | 1 + .agents/pm/history/pm-3mna.jsonl | 17 + .agents/pm/history/pm-9x6e.jsonl | 13 + .agents/pm/history/pm-nmzx.jsonl | 22 + .agents/pm/history/pm-qwoy.jsonl | 1 + .agents/pm/issues/pm-nmzx.toon | 33 +- .agents/pm/tasks/pm-9x6e.toon | 17 +- CHANGELOG.md | 1 + docs/COMMANDS.md | 88 +- docs/SDK.md | 14 +- eslint-suppressions.json | 11 - .../release/sdk-import-boundary-baseline.json | 22 - scripts/release/static-quality-gate.mjs | 2 +- src/cli/commands/completion.ts | 5 + src/cli/commands/init.ts | 42 +- src/cli/commands/schema.ts | 2324 +--------------- src/cli/register-setup.ts | 6 + src/sdk/cli-contracts/flag-contracts.ts | 3 +- src/sdk/runtime.ts | 76 +- src/sdk/schema.ts | 2344 +++++++++++++++++ tests/fixtures/contracts/full.json | 6 + .../init-path-guard.integration.spec.ts | 275 +- .../unit/commands/completion-command.spec.ts | 390 ++- tests/unit/commands/init-command.spec.ts | 1100 ++++++-- .../mcp/mcp-server-branch-residual.spec.ts | 14 + 28 files changed, 4009 insertions(+), 2851 deletions(-) create mode 100644 src/sdk/schema.ts diff --git a/.agents/pm/chores/pm-2a9n.toon b/.agents/pm/chores/pm-2a9n.toon index ee3b9d7d8..b8256b4e5 100644 --- a/.agents/pm/chores/pm-2a9n.toon +++ b/.agents/pm/chores/pm-2a9n.toon @@ -6,7 +6,7 @@ status: closed priority: 2 tags[4]: codeql,dependencies,github-actions,typescript created_at: "2026-07-10T09:40:10.713Z" -updated_at: "2026-07-10T10:14:23.217Z" +updated_at: "2026-07-14T20:01:09.326Z" closed_at: "2026-07-10T10:14:21.780Z" author: codex-root resolution: completed @@ -14,10 +14,11 @@ expected_result: "Keep CodeQL init/analyze pinned in lockstep to official v4.37. actual_result: CodeQL v4.37.0 resolves to commit 99df26d4f13ea111d4ec1a7dddef6063f76b97e9; @types/node updates safely from 22.20.0 to 22.20.1 while the incompatible 26.x major is rejected. Typecheck and workflow contracts pass after the correction. dependencies[1]{id,kind,created_at,author,source_kind}: pm-2czc,related_to,"2026-07-10T09:40:20.682Z",codex-root,canonical_lineage -comments[3]{created_at,author,text}: +comments[4]{created_at,author,text}: "2026-07-10T09:40:10.713Z",codex-root,"Duplicate check: pm search --status all for @types/node 26.1.0/26.1.1, CodeQL 4.37.0, Dependabot #462/#465 found canonical historical unblocking lineage pm-2czc but no item for the current version refresh. This concrete batch links to that closed precedent and supersedes the two stale bot branches." "2026-07-10T09:58:01.513Z",codex-root,"Evidence: npm registry latest @types/node is 26.1.1 and lockfile integrity matches the registry. Official github/codeql-action v4.37.0 annotated tag peels to immutable commit 99df26d4f13ea111d4ec1a7dddef6063f76b97e9. Frozen install, TypeScript 6 typecheck, CI workflow contract suite, production audit, contract snapshot, ESLint, jscpd 0 clones, static/docstring gate, token budget, docs/skills, secret scan, and 88-command package-first dogfood all pass. Full coverage: 270 files, 5,506 tests, statements 34380/34380, branches 24588/24588, functions 6882/6882, lines 33039/33039." "2026-07-10T10:07:14.948Z",codex-root,"Pre-push review correction: Greptile c3c227b6-2938-4f6a-b779-27fe90419cdc correctly identified that @types/node 26 would weaken the Node >=22.18.0 compile-time compatibility guard. Accepted: moved to current Node-22 patch 22.20.1 instead, retained engines.node, re-ran typecheck and workflow contracts, and removed the security tag so pm-changelog routes the maintenance entry accurately." + "2026-07-14T20:01:09.326Z",codex-root,"Live follow-up 2026-07-14: Dependabot reopened the rejected major as PR #540 (@types/node 26.1.1); build foundation and Docs fail while downstream CI is skipped. The closed decision remains current: preserve the Node 22 compile-time compatibility guard and do not merge the Node 26 declarations without an intentional runtime-floor migration. This SDK lane does not claim or modify that dependency branch." notes[1]{created_at,author,text}: "2026-07-10T09:58:02.248Z",codex-root,"GitHub consolidation: this batch supersedes stale Dependabot PR #462 (@types/node 26.1.0, while registry latest is 26.1.1) and behind-main PR #465 (CodeQL v4.37.0). Close both only after this combined current-version PR merges." learnings[1]{created_at,author,text}: diff --git a/.agents/pm/epics/pm-qwoy.toon b/.agents/pm/epics/pm-qwoy.toon index 1cf42dd82..6d621cfa5 100644 --- a/.agents/pm/epics/pm-qwoy.toon +++ b/.agents/pm/epics/pm-qwoy.toon @@ -6,14 +6,15 @@ status: open priority: 2 tags[5]: capability,code-scanning,dependabot,security,semgrep created_at: "2026-07-06T21:07:27.273Z" -updated_at: "2026-07-14T10:18:55.307Z" +updated_at: "2026-07-14T20:01:08.602Z" author: maintainer-agent estimated_minutes: 2400 acceptance_criteria: All code-scanning findings (Semgrep/CodeQL/dependabot/secrets) are tracked as pm issues under this epic with source links and triage state; recurring scanner imports parent their batch tasks here; epic stays OPEN as a living backlog even at 0 open children. parent: pm-doxj risk: high confidence: high -comments[2]{created_at,author,text}: +comments[3]{created_at,author,text}: "2026-07-13T19:42:42.258Z",codex-root,"Live GitHub security/dependency sweep 2026-07-13: Dependabot alert API contains 39 fixed and 0 open alerts; code-scanning contains 10 fixed and 0 open alerts; secret-scanning contains 0 alerts; pnpm audit reports 0 vulnerabilities at every severity. Five dependency-update PRs are open and remain isolated for their own review: #537 dev-dependencies group, #538 @sentry/node 10.64.0, #539 TypeScript 7.0.2, #540 @types/node 26.1.1, #541 eslint-plugin-unicorn 71.1.0. No duplicate pm item created and none were claimed in this context implementation lane." "2026-07-14T10:18:55.307Z",codex-gpt5,"Live GitHub security/dependency sweep 2026-07-14: Dependabot, code-scanning, and secret-scanning APIs each return zero open alerts; pnpm audit --audit-level high reports no known vulnerabilities. Open dependency PRs are #552 grouped dev dependencies, #541 eslint-plugin-unicorn 71.1.0, #540 @types/node 26.1.1, #539 TypeScript 7.0.2, and #538 @sentry/node 10.65.0. They remain isolated/unclaimed for their own compatibility review." + "2026-07-14T20:01:08.602Z",codex-root,"Live GitHub security/dependency reconciliation 2026-07-14 22:00 CEST: Dependabot, code-scanning, and secret-scanning APIs each report 0 open alerts. Open dependency PRs remain isolated and unclaimed: #552 and #541 are fully green; #538 is fully green; #539 TypeScript 7.0.2 fails static, coverage-shard, and Windows compatibility gates; #540 @types/node 26.1.1 fails build/docs and is incompatible with the repository's Node >=22.18 declaration contract already decided in pm-2a9n. No duplicate security or dependency item created by this SDK lane." body: "# Capability: Security & code scanning (living backlog)\n\nOwns repository security posture work that previously had no capability home:\n- Semgrep Code finding triage batches (first import: pm-oz8u, 40 findings from 2026-07-06 CSV — 6 High spawn-shell-true, 34 Medium prototype-pollution/non-literal-RegExp/dependabot-cooldown).\n- Dependabot dependency hygiene (pm-2czc closed the 2026-07-06 unblock: @types/node 26, pnpm cooldown, codeql-action lockstep group).\n- Secret guardrails and history-redact usage policy (see 2026-05-22 history-repair learning: never quote secret-pattern literals into pm text).\n- Future CodeQL / scanner integrations.\n\nConvention: each scanner import creates one batch Task parented here, with one Issue per platform finding (Semgrep ID as issue code) so findings stay individually triageable. Sibling epic pm-92if owns CodeFactor (maintainability) findings; this epic owns security-class findings." diff --git a/.agents/pm/features/pm-3mna.toon b/.agents/pm/features/pm-3mna.toon index b5c532591..97e25a5cb 100644 --- a/.agents/pm/features/pm-3mna.toon +++ b/.agents/pm/features/pm-3mna.toon @@ -6,7 +6,7 @@ status: open priority: 2 tags[4]: architecture,"area:config","area:schema","area:sdk" created_at: "2026-07-05T04:54:11.971Z" -updated_at: "2026-07-12T09:50:13.504Z" +updated_at: "2026-07-14T20:19:54.802Z" author: maintainer-agent estimated_minutes: 360 acceptance_criteria: SDK exports typed schema/config/profile/init primitives with the same validation + never-block errors as the CLI; corresponding commands consume only these primitives; docstrings + linked tests + SDK reference docs; zero behavior change @@ -17,7 +17,7 @@ dependencies[3]{id,kind,created_at,author,source_kind}: pm-lodl,blocked_by,"2026-07-05T04:55:07.462Z",null,null pm-muhw,blocked_by,"2026-07-05T04:55:08.057Z",null,null pm-69nl,related,"2026-07-10T17:32:56.925Z",null,null -comments[9]{created_at,author,text}: +comments[12]{created_at,author,text}: "2026-07-07T20:44:47.549Z",codex-agent,"Implementation start: bundled with pm-zwpp, pm-oxrw, pm-m4iu, pm-cbwg, pm-w89s, and pm-usfg. Live duplicate/context check found no closed schema/config/profile SDK primitive completion; prior read/lifecycle/package primitives remain separate shipped or in-progress domains." "2026-07-07T22:45:27.701Z",claude-code-agent,"Evidence: post-review docs/API cleanup keeps package authors on the correct context-management primitive shapes: linked file/doc adds use string arrays and top-level note, while SchemaResult documents the generic schema customization result union. Verified through docs gate, typecheck, focused SDK tests, and packed installed SDK proof." "2026-07-07T23:52:21.378Z",claude-code-agent,"Review follow-up evidence: the generic SDK schema() return type now explicitly includes all concrete inspect results as well as schema mutation results, preserving compile-time safety for customization primitive consumers. Focused SDK test and full coverage passed." @@ -27,16 +27,26 @@ comments[9]{created_at,author,text}: "2026-07-11T23:15:37.998Z",codex-root,"Progress evidence: promoted workspace cache scaffolding into a typed public SDK primitive consumed by pm init, documented and packed-consumer tested. The broader schema/config/profile/init command-to-core extraction remains open; this PR does not claim full pm-3mna completion." "2026-07-12T08:31:32.782Z",codex-root,"Implementation start: active wide SDK bundle promotes schema mutation orchestration behind typed public primitives after the runtime dispatch decomposition; live blockers pm-lodl and pm-muhw are closed with shipped evidence." "2026-07-12T09:50:10.298Z",codex-root,"Scope reconciliation: no schema mutation primitive implementation landed in this branch; item returned to open so only actively delivered annotation work remains completed/in review." + "2026-07-14T19:24:55.099Z",codex-gpt5,"Implementation start 2026-07-14: live full-tracker/all-status and GitHub reconciliation selected the schema domain extraction as the broad SDK-first lane. This run will move schema primitive/types ownership out of src/cli, make CLI registration a presentation adapter, update public SDK docs/tests, and preserve behavior." + "2026-07-14T19:40:59.373Z",codex-gpt5,"Progress evidence: schema inspection/mutation ownership now lives in src/sdk/schema.ts; the CLI module is a compatibility re-export and SDK runtime imports schema primitives directly. Focused schema/SDK tests, 100% docstring static gate, package boundary, token budget, docs/skills, and governed contract checks pass." + "2026-07-14T20:19:47.964Z",codex-root,"Final branch evidence: schema inspection/mutation ownership moved from the CLI into src/sdk/schema.ts; the CLI is now a compatibility re-export and SDK runtime/MCP consume the SDK-owned engine. The import-boundary ratchet removed 17 schema edges, stale suppressions were deleted, and the hard suppression budget fell 174 -> 170. Exact verification on the final source: 290 files / 5,701 tests; 36,848/36,848 statements, 26,463/26,463 branches, 7,438/7,438 functions, 35,963/35,963 lines; typecheck, strict lint, zero-clone, 100% module/export/member docstrings, package boundary, token budget, docs, contracts, security, version, packed npm smoke, 88-command dogfood, and fresh external PmClient schema/custom-type lifecycle all pass. Broader config/profile/init implementation ownership remains for this feature, so it returns to open." notes[3]{created_at,author,text}: "2026-07-10T17:32:56.449Z",maintainer-agent,"Implementation slicing 2026-07-10 (grounded in current src state). Already public — do NOT redo: profile read-side (presets/plan/describe/lint exported via src/sdk/index.ts), schema runtime registries (resolveRuntimeFieldRegistry/resolveRuntimeStatusRegistry via src/sdk/runtime.ts), and generic PmClient.runTyped wrappers for the schema/config/profile/init actions. The actual gap: domain logic lives in CLI command modules — src/cli/commands/schema.ts (1973 lines), config.ts (1576), init.ts (955) + init-agent-guidance.ts (566), ~5.1k lines total — so SDK consumers reach these domains only through generic action dispatch, not typed primitives. Suggested slices: S1 schema mutation primitives (~150m) — extract add-type/add-field/status-def write paths into src/core/schema (fields-file/item-types-file/status-defs-file already own the file formats); command becomes parse+render. S2 config/settings primitives (~90m) — extract read/write/validate onto src/core/config/nested-settings; preserve the settings validator's unknown-key-stripping semantics (PR#314). S3 init + workspace-discovery primitive (~90m) — single source of truth for discovery; this is where the GH-501/pm-69nl class (init target invisible to discovery) gets fixed at the primitive level — coordinate with pm-69nl, do not duplicate its fix. S4 typed PmClient facades + SDK reference docs/tests (~60m) — replace generic payloads with typed inputs/outputs; each primitive must match the equivalent runAction result shape (semantics oracle, pm-usfg 07-06 note). Order: S1 first (schema gates the pm-w89s universality story); S2/S3 independent; S4 last." "2026-07-12T06:11:44.315Z",maintainer-agent,"Adjacent ship, scope intact: PR#520 (2026-07-12) promoted the FIRST workspace-customization primitive through the public SDK (ensurePmGitignore/getPmGitignoreBlock in src/sdk/workspace.ts) and made init consume it — proof the init-adjacent SDK boundary works end-to-end (packed-tarball external-consumer proof in PR verification). This does NOT deliver S1: schema/config/profile/init command families are still CLI-owned. Use src/sdk/workspace.ts as the pattern exemplar when starting S1." "2026-07-12T08:43:59.620Z",codex-root,"Execution rule from user escalation (23rd recurrence): this active delivery lane must not stop at partial implementation, local verification, commit, PR creation, pending checks, or pending bot review. Continue through the requested green reviewed PR, merge, merged-head/release/Sentry/telemetry proof, tracker closure, claim release, and return to main; only a genuine exhausted blocker permits stopping." -files[4]{path,scope,note}: +learnings[1]{created_at,author,text}: + "2026-07-14T20:19:48.566Z",codex-root,"Moving a CLI command engine into the SDK must also migrate its lint baseline, residual test mocks, and runtime imports; keeping a tiny CLI re-export preserves compatibility while the SDK becomes the single implementation owner." +files[9]{path,scope,note}: + eslint-suppressions.json,project,Remove stale CLI schema suppressions after SDK extraction and lint cleanup + scripts/release/sdk-import-boundary-baseline.json,project,schema CLI private-core edges removed from ratchet baseline + scripts/release/static-quality-gate.mjs,project,Ratchet ESLint suppressions budget from 174 to 170 after schema CLI suppressions are eliminated + src/cli/commands/schema.ts,project,presentation compatibility re-export over SDK schema engine src/sdk/index.ts,project,SDK barrel exports customization primitive contracts src/sdk/runtime.ts,project,SDK runtime exposes init/config/schema/profile customization primitives + src/sdk/schema.ts,project,SDK-owned schema inspection and mutation engine src/sdk/workspace.ts,project,Public workspace customization primitive tests/unit/sdk/sdk-context-primitives.spec.ts,project,SDK customization primitive coverage -tests[2]: +tests[3]: - command: node scripts/run-tests.mjs test -- tests/unit/core/workspace/gitignore.spec.ts tests/unit/sdk/sdk-index.spec.ts scope: project timeout_seconds: 240 @@ -44,6 +54,11 @@ tests[2]: scope: project timeout_seconds: 120 pm_context_mode: tracker + - command: node scripts/run-tests.mjs test -- tests/unit/sdk/sdk-context-primitives.spec.ts tests/unit/commands/schema-command.spec.ts + scope: project + timeout_seconds: 240 +test_runs[1]{run_id,kind,status,started_at,finished_at,recorded_at,passed,failed,skipped}: + test-local-mrl3gn11-idibzy,test,passed,"2026-07-14T20:18:03.384Z","2026-07-14T20:18:58.069Z","2026-07-14T20:18:58.069Z",3,0,0 docs[1]{path,scope,note}: docs/SDK.md,project,SDK customization primitive reference docs body: "" diff --git a/.agents/pm/history/pm-2a9n.jsonl b/.agents/pm/history/pm-2a9n.jsonl index 9264fc465..ceb3446a4 100644 --- a/.agents/pm/history/pm-2a9n.jsonl +++ b/.agents/pm/history/pm-2a9n.jsonl @@ -36,3 +36,4 @@ {"ts":"2026-07-10T10:14:19.973Z","author":"codex-root","op":"comment_delete","patch":[{"op":"remove","path":"/metadata/comments/3"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-10T10:14:19.973Z"}],"before_hash":"520be207369003d212765a45b048e343582142cf0a511db895f721659d611250","after_hash":"aaf7b46a42990c0bebd673812d4e6c3f83e0ea23cae406c7695d3e13b2d79fe3"} {"ts":"2026-07-10T10:14:21.781Z","author":"codex-root","op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-10T10:14:21.781Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-10T10:14:21.780Z"},{"op":"add","path":"/metadata/close_reason","value":"Updated CodeQL to v4.37.0 and @types/node to compatible 22.20.1; rejected Node 26, preserved Node 22 type safety, and removed duplicate tracker evidence identified in review."}],"before_hash":"aaf7b46a42990c0bebd673812d4e6c3f83e0ea23cae406c7695d3e13b2d79fe3","after_hash":"f4c4cbe7198b97e3200773a2f67704b299ccecd24f130db48cad5b8e11d47de6"} {"ts":"2026-07-10T10:14:23.217Z","author":"codex-root","op":"release","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-10T10:14:23.217Z"}],"before_hash":"f4c4cbe7198b97e3200773a2f67704b299ccecd24f130db48cad5b8e11d47de6","after_hash":"b42c20b80a0e1c20c059fc1777dd758fdf4560674c525ae121f34a55aad0dfe3"} +{"ts":"2026-07-14T20:01:09.326Z","author":"codex-root","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/3","value":{"created_at":"2026-07-14T20:01:09.326Z","author":"codex-root","text":"Live follow-up 2026-07-14: Dependabot reopened the rejected major as PR #540 (@types/node 26.1.1); build foundation and Docs fail while downstream CI is skipped. The closed decision remains current: preserve the Node 22 compile-time compatibility guard and do not merge the Node 26 declarations without an intentional runtime-floor migration. This SDK lane does not claim or modify that dependency branch."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:01:09.326Z"}],"before_hash":"b42c20b80a0e1c20c059fc1777dd758fdf4560674c525ae121f34a55aad0dfe3","after_hash":"c911e5688a7760d5ce4a619fde89ee8654469154fcfd91f84992debab8253984"} diff --git a/.agents/pm/history/pm-3mna.jsonl b/.agents/pm/history/pm-3mna.jsonl index 361255d5d..7386b5a41 100644 --- a/.agents/pm/history/pm-3mna.jsonl +++ b/.agents/pm/history/pm-3mna.jsonl @@ -36,3 +36,20 @@ {"ts":"2026-07-12T09:50:10.299Z","author":"codex-root","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/8","value":{"created_at":"2026-07-12T09:50:10.298Z","author":"codex-root","text":"Scope reconciliation: no schema mutation primitive implementation landed in this branch; item returned to open so only actively delivered annotation work remains completed/in review."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-12T09:50:10.299Z"}],"before_hash":"b59e2d1c3542f6028eaa0786b372f0aee155cfd69baea213bae03eeb98a9c7b6","after_hash":"46d553fcbc0139be8b8710cc6e7d28a213e80317c16226869005415c24ff0276"} {"ts":"2026-07-12T09:50:12.024Z","author":"codex-root","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-12T09:50:12.024Z"},{"op":"replace","path":"/metadata/status","value":"open"}],"before_hash":"46d553fcbc0139be8b8710cc6e7d28a213e80317c16226869005415c24ff0276","after_hash":"c453ff39a9c337746c7bbc663aa1243f1ce6ac51865f862e96068000ad9a53aa","message":"No schema implementation in final branch scope"} {"ts":"2026-07-12T09:50:13.504Z","author":"codex-root","op":"release","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-12T09:50:13.504Z"}],"before_hash":"c453ff39a9c337746c7bbc663aa1243f1ce6ac51865f862e96068000ad9a53aa","after_hash":"4f2285f9b35a717d82f1d02a3b59056d96c04dae85ce91c27b1614ddbe7ebcac"} +{"ts":"2026-07-14T19:24:53.282Z","author":"codex-gpt5","op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:24:53.282Z"},{"op":"add","path":"/metadata/assignee","value":"codex-gpt5"}],"before_hash":"4f2285f9b35a717d82f1d02a3b59056d96c04dae85ce91c27b1614ddbe7ebcac","after_hash":"8fea5fbe11e109bccfa2ecdbf9b9ce6353462023a26e222035036217d630ac59"} +{"ts":"2026-07-14T19:24:53.923Z","author":"codex-gpt5","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:24:53.923Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"8fea5fbe11e109bccfa2ecdbf9b9ce6353462023a26e222035036217d630ac59","after_hash":"b8d977b7de52f1ca73b86d44a58be7c8e9a187a5588001f923f79657b163d4ff","message":"Start broad schema SDK ownership extraction"} +{"ts":"2026-07-14T19:24:55.099Z","author":"codex-gpt5","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/9","value":{"created_at":"2026-07-14T19:24:55.099Z","author":"codex-gpt5","text":"Implementation start 2026-07-14: live full-tracker/all-status and GitHub reconciliation selected the schema domain extraction as the broad SDK-first lane. This run will move schema primitive/types ownership out of src/cli, make CLI registration a presentation adapter, update public SDK docs/tests, and preserve behavior."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:24:55.099Z"}],"before_hash":"b8d977b7de52f1ca73b86d44a58be7c8e9a187a5588001f923f79657b163d4ff","after_hash":"381cd08a71de3851d23f9aad7a82ab640c26452537acc0cd70ae16088d530689"} +{"ts":"2026-07-14T19:40:39.581Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"replace","path":"/metadata/files/3/note","value":"Public workspace customization primitive"},{"op":"replace","path":"/metadata/files/3/path","value":"src/sdk/workspace.ts"},{"op":"replace","path":"/metadata/files/2/note","value":"SDK-owned schema inspection and mutation engine"},{"op":"replace","path":"/metadata/files/2/path","value":"src/sdk/schema.ts"},{"op":"add","path":"/metadata/files/4","value":{"path":"tests/unit/sdk/sdk-context-primitives.spec.ts","scope":"project","note":"SDK customization primitive coverage"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:39.581Z"}],"before_hash":"381cd08a71de3851d23f9aad7a82ab640c26452537acc0cd70ae16088d530689","after_hash":"2136aa1d60cf78c709fbf6db6919e75adbe1e34db8ef5a3796e4aca775d26108"} +{"ts":"2026-07-14T19:40:40.431Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"replace","path":"/metadata/files/4/note","value":"Public workspace customization primitive"},{"op":"replace","path":"/metadata/files/4/path","value":"src/sdk/workspace.ts"},{"op":"replace","path":"/metadata/files/3/note","value":"SDK-owned schema inspection and mutation engine"},{"op":"replace","path":"/metadata/files/3/path","value":"src/sdk/schema.ts"},{"op":"replace","path":"/metadata/files/2/note","value":"SDK runtime exposes init/config/schema/profile customization primitives"},{"op":"replace","path":"/metadata/files/2/path","value":"src/sdk/runtime.ts"},{"op":"replace","path":"/metadata/files/1/note","value":"SDK barrel exports customization primitive contracts"},{"op":"replace","path":"/metadata/files/1/path","value":"src/sdk/index.ts"},{"op":"replace","path":"/metadata/files/0/note","value":"presentation compatibility re-export over SDK schema engine"},{"op":"replace","path":"/metadata/files/0/path","value":"src/cli/commands/schema.ts"},{"op":"add","path":"/metadata/files/5","value":{"path":"tests/unit/sdk/sdk-context-primitives.spec.ts","scope":"project","note":"SDK customization primitive coverage"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:40.431Z"}],"before_hash":"2136aa1d60cf78c709fbf6db6919e75adbe1e34db8ef5a3796e4aca775d26108","after_hash":"e81d67a27a1c7cf40bd719c62c238abd62e8c3b16ba3ac0971afdf91c9e9bdc3"} +{"ts":"2026-07-14T19:40:41.308Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:41.308Z"}],"before_hash":"e81d67a27a1c7cf40bd719c62c238abd62e8c3b16ba3ac0971afdf91c9e9bdc3","after_hash":"3abd0d52ef31690241874496fcbed195764d69e72e39de06289527526b0d093a"} +{"ts":"2026-07-14T19:40:43.023Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"replace","path":"/metadata/files/5/note","value":"Public workspace customization primitive"},{"op":"replace","path":"/metadata/files/5/path","value":"src/sdk/workspace.ts"},{"op":"replace","path":"/metadata/files/4/note","value":"SDK-owned schema inspection and mutation engine"},{"op":"replace","path":"/metadata/files/4/path","value":"src/sdk/schema.ts"},{"op":"replace","path":"/metadata/files/3/note","value":"SDK runtime exposes init/config/schema/profile customization primitives"},{"op":"replace","path":"/metadata/files/3/path","value":"src/sdk/runtime.ts"},{"op":"replace","path":"/metadata/files/2/note","value":"SDK barrel exports customization primitive contracts"},{"op":"replace","path":"/metadata/files/2/path","value":"src/sdk/index.ts"},{"op":"replace","path":"/metadata/files/1/note","value":"presentation compatibility re-export over SDK schema engine"},{"op":"replace","path":"/metadata/files/1/path","value":"src/cli/commands/schema.ts"},{"op":"replace","path":"/metadata/files/0/note","value":"schema CLI private-core edges removed from ratchet baseline"},{"op":"replace","path":"/metadata/files/0/path","value":"scripts/release/sdk-import-boundary-baseline.json"},{"op":"add","path":"/metadata/files/6","value":{"path":"tests/unit/sdk/sdk-context-primitives.spec.ts","scope":"project","note":"SDK customization primitive coverage"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:43.023Z"}],"before_hash":"3abd0d52ef31690241874496fcbed195764d69e72e39de06289527526b0d093a","after_hash":"4c342a85365833e2138a1c7b1085d4a720aae300d1e40fdcf6b1391dc50bc3db"} +{"ts":"2026-07-14T19:40:44.009Z","author":"codex-gpt5","op":"docs_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:44.009Z"}],"before_hash":"4c342a85365833e2138a1c7b1085d4a720aae300d1e40fdcf6b1391dc50bc3db","after_hash":"047c87857ccb57a5bb2d02a9c3761735e2df7ceaf9f4af3d692d03380372e856"} +{"ts":"2026-07-14T19:40:44.991Z","author":"codex-gpt5","op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/2","value":{"command":"node scripts/run-tests.mjs test -- tests/unit/sdk/sdk-context-primitives.spec.ts tests/unit/commands/schema-command.spec.ts","scope":"project","timeout_seconds":240}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:44.991Z"}],"before_hash":"047c87857ccb57a5bb2d02a9c3761735e2df7ceaf9f4af3d692d03380372e856","after_hash":"d71e8c028b384d47f12d6635b1f5cce78547b415eb9fe935eb587f641beaf7b5"} +{"ts":"2026-07-14T19:40:59.375Z","author":"codex-gpt5","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/10","value":{"created_at":"2026-07-14T19:40:59.373Z","author":"codex-gpt5","text":"Progress evidence: schema inspection/mutation ownership now lives in src/sdk/schema.ts; the CLI module is a compatibility re-export and SDK runtime imports schema primitives directly. Focused schema/SDK tests, 100% docstring static gate, package boundary, token budget, docs/skills, and governed contract checks pass."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:59.375Z"}],"before_hash":"d71e8c028b384d47f12d6635b1f5cce78547b415eb9fe935eb587f641beaf7b5","after_hash":"9372d333e36f0cbb5ce9050e62742317a8089ab173bb6adbe54ec6afba8e22d4"} +{"ts":"2026-07-14T20:05:57.373Z","author":"codex-root","op":"files_add","patch":[{"op":"replace","path":"/metadata/files/6/note","value":"Public workspace customization primitive"},{"op":"replace","path":"/metadata/files/6/path","value":"src/sdk/workspace.ts"},{"op":"replace","path":"/metadata/files/5/note","value":"SDK-owned schema inspection and mutation engine"},{"op":"replace","path":"/metadata/files/5/path","value":"src/sdk/schema.ts"},{"op":"replace","path":"/metadata/files/4/note","value":"SDK runtime exposes init/config/schema/profile customization primitives"},{"op":"replace","path":"/metadata/files/4/path","value":"src/sdk/runtime.ts"},{"op":"replace","path":"/metadata/files/3/note","value":"SDK barrel exports customization primitive contracts"},{"op":"replace","path":"/metadata/files/3/path","value":"src/sdk/index.ts"},{"op":"replace","path":"/metadata/files/2/note","value":"presentation compatibility re-export over SDK schema engine"},{"op":"replace","path":"/metadata/files/2/path","value":"src/cli/commands/schema.ts"},{"op":"replace","path":"/metadata/files/1/note","value":"Ratchet ESLint suppressions budget from 174 to 170 after schema CLI suppressions are eliminated"},{"op":"replace","path":"/metadata/files/1/path","value":"scripts/release/static-quality-gate.mjs"},{"op":"add","path":"/metadata/files/7","value":{"path":"tests/unit/sdk/sdk-context-primitives.spec.ts","scope":"project","note":"SDK customization primitive coverage"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:05:57.373Z"}],"before_hash":"9372d333e36f0cbb5ce9050e62742317a8089ab173bb6adbe54ec6afba8e22d4","after_hash":"cd0477fdd1a30e5cd875e396ac4a1d5db2af31c54c8c9b24b23e5b521de31a0e"} +{"ts":"2026-07-14T20:05:58.054Z","author":"codex-root","op":"files_add","patch":[{"op":"replace","path":"/metadata/files/7/note","value":"Public workspace customization primitive"},{"op":"replace","path":"/metadata/files/7/path","value":"src/sdk/workspace.ts"},{"op":"replace","path":"/metadata/files/6/note","value":"SDK-owned schema inspection and mutation engine"},{"op":"replace","path":"/metadata/files/6/path","value":"src/sdk/schema.ts"},{"op":"replace","path":"/metadata/files/5/note","value":"SDK runtime exposes init/config/schema/profile customization primitives"},{"op":"replace","path":"/metadata/files/5/path","value":"src/sdk/runtime.ts"},{"op":"replace","path":"/metadata/files/4/note","value":"SDK barrel exports customization primitive contracts"},{"op":"replace","path":"/metadata/files/4/path","value":"src/sdk/index.ts"},{"op":"replace","path":"/metadata/files/3/note","value":"presentation compatibility re-export over SDK schema engine"},{"op":"replace","path":"/metadata/files/3/path","value":"src/cli/commands/schema.ts"},{"op":"replace","path":"/metadata/files/2/note","value":"Ratchet ESLint suppressions budget from 174 to 170 after schema CLI suppressions are eliminated"},{"op":"replace","path":"/metadata/files/2/path","value":"scripts/release/static-quality-gate.mjs"},{"op":"replace","path":"/metadata/files/1/note","value":"schema CLI private-core edges removed from ratchet baseline"},{"op":"replace","path":"/metadata/files/1/path","value":"scripts/release/sdk-import-boundary-baseline.json"},{"op":"replace","path":"/metadata/files/0/note","value":"Remove stale CLI schema suppressions after SDK extraction and lint cleanup"},{"op":"replace","path":"/metadata/files/0/path","value":"eslint-suppressions.json"},{"op":"add","path":"/metadata/files/8","value":{"path":"tests/unit/sdk/sdk-context-primitives.spec.ts","scope":"project","note":"SDK customization primitive coverage"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:05:58.054Z"}],"before_hash":"cd0477fdd1a30e5cd875e396ac4a1d5db2af31c54c8c9b24b23e5b521de31a0e","after_hash":"bfb12b4677a2c971317ef9cb48c8f3bbf51490fbe70b6b0153de32dcc25a5837"} +{"ts":"2026-07-14T20:18:58.081Z","author":"codex-root","op":"test_run_track","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:18:58.081Z"},{"op":"add","path":"/metadata/test_runs","value":[{"run_id":"test-local-mrl3gn11-idibzy","kind":"test","status":"passed","started_at":"2026-07-14T20:18:03.384Z","finished_at":"2026-07-14T20:18:58.069Z","recorded_at":"2026-07-14T20:18:58.069Z","passed":3,"failed":0,"skipped":0}]}],"before_hash":"bfb12b4677a2c971317ef9cb48c8f3bbf51490fbe70b6b0153de32dcc25a5837","after_hash":"5703693bf179a93a3aa2f001d4c654f36896be6f9b9d0c2ab908ed711207bb86","message":"Track test run summary (test-local-mrl3gn11-idibzy)"} +{"ts":"2026-07-14T20:19:47.964Z","author":"codex-root","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/11","value":{"created_at":"2026-07-14T20:19:47.964Z","author":"codex-root","text":"Final branch evidence: schema inspection/mutation ownership moved from the CLI into src/sdk/schema.ts; the CLI is now a compatibility re-export and SDK runtime/MCP consume the SDK-owned engine. The import-boundary ratchet removed 17 schema edges, stale suppressions were deleted, and the hard suppression budget fell 174 -> 170. Exact verification on the final source: 290 files / 5,701 tests; 36,848/36,848 statements, 26,463/26,463 branches, 7,438/7,438 functions, 35,963/35,963 lines; typecheck, strict lint, zero-clone, 100% module/export/member docstrings, package boundary, token budget, docs, contracts, security, version, packed npm smoke, 88-command dogfood, and fresh external PmClient schema/custom-type lifecycle all pass. Broader config/profile/init implementation ownership remains for this feature, so it returns to open."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:47.964Z"}],"before_hash":"5703693bf179a93a3aa2f001d4c654f36896be6f9b9d0c2ab908ed711207bb86","after_hash":"d8b4a70eea24de3d4cbea22e5ba5fd8bff7e42f8a9d9dfdeaf87c50554df82ab"} +{"ts":"2026-07-14T20:19:48.566Z","author":"codex-root","op":"learning_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:48.566Z"},{"op":"add","path":"/metadata/learnings","value":[{"created_at":"2026-07-14T20:19:48.566Z","author":"codex-root","text":"Moving a CLI command engine into the SDK must also migrate its lint baseline, residual test mocks, and runtime imports; keeping a tiny CLI re-export preserves compatibility while the SDK becomes the single implementation owner."}]}],"before_hash":"d8b4a70eea24de3d4cbea22e5ba5fd8bff7e42f8a9d9dfdeaf87c50554df82ab","after_hash":"b4510c4f99b1485d35e84f31075e2070b6afcdb25cea7add57119b7cc3a86e25"} +{"ts":"2026-07-14T20:19:53.955Z","author":"codex-root","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:53.955Z"},{"op":"replace","path":"/metadata/status","value":"open"}],"before_hash":"b4510c4f99b1485d35e84f31075e2070b6afcdb25cea7add57119b7cc3a86e25","after_hash":"dd533a0a2fa4edb96b0bed3d205bae59ecdc4b34cea56cf9882904a54b733032","message":"Schema ownership slice complete; return broader config/profile/init SDK program to open"} +{"ts":"2026-07-14T20:19:54.802Z","author":"codex-root","op":"release","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:54.802Z"}],"before_hash":"dd533a0a2fa4edb96b0bed3d205bae59ecdc4b34cea56cf9882904a54b733032","after_hash":"1e2724c12e269e3e70d7087175b01d9fd6ebd2f681f9e4680d7135728bf20dcc","message":"Release after verified schema ownership slice"} diff --git a/.agents/pm/history/pm-9x6e.jsonl b/.agents/pm/history/pm-9x6e.jsonl index 5a9be5a34..48e6f13f3 100644 --- a/.agents/pm/history/pm-9x6e.jsonl +++ b/.agents/pm/history/pm-9x6e.jsonl @@ -1,2 +1,15 @@ {"ts":"2026-07-06T13:13:21.203Z","author":"maintainer-agent","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-9x6e"},{"op":"add","path":"/metadata/title","value":"Capstone: zero the SDK import-boundary baseline (91 entries) — flip remaining CLI/MCP private-core imports to SDK primitives and harden the ratchet into a hard boundary gate"},{"op":"add","path":"/metadata/description","value":"pm-8778 shipped the ratchet gate; scripts/release/sdk-import-boundary-baseline.json currently allows 91 private src/core imports from src/cli + src/mcp. Each SDK promotion item (pm-rjqr/98cz/zwpp/je50/4a7m/3mna/oxrw/oslr) flips its own domain's CLI handlers, but no child owns the terminal state: (1) residual imports not covered by any single domain promotion, (2) the MCP-layer flip — promotion ACs name CLI handlers only while the epic AC requires src/mcp to shrink to SDK-only too, (3) emptying + deleting the baseline file, (4) flipping static-quality-gate from ratchet mode to unconditional hard-deny so the boundary can never regress. Duplicate check 2026-07-06: pm-8778 (closed) built the gate mechanism; the 8 promotion items shrink the baseline domain-by-domain; searched 'boundary ratchet zero sdk-only imports capstone' — no existing item owns baseline-to-zero + gate hardening."},{"op":"add","path":"/metadata/type","value":"Task"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":["architecture","area:cli","area:mcp","area:sdk","ci"]},{"op":"add","path":"/metadata/created_at","value":"2026-07-06T13:13:21.203Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-07-06T13:13:21.203Z"},{"op":"add","path":"/metadata/author","value":"maintainer-agent"},{"op":"add","path":"/metadata/estimated_minutes","value":480},{"op":"add","path":"/metadata/acceptance_criteria","value":"sdk-import-boundary-baseline.json reaches zero entries and is deleted; static-quality-gate hard-denies any private src/core import from src/cli/** and src/mcp/** with no baseline escape hatch; src/mcp handlers consume only src/sdk primitives; full test suite + 100% coverage gate green with zero CLI/MCP behavior change"},{"op":"add","path":"/metadata/parent","value":"pm-usfg"},{"op":"add","path":"/metadata/risk","value":"medium"},{"op":"add","path":"/metadata/confidence","value":"high"},{"op":"add","path":"/metadata/dependencies","value":[{"id":"pm-3mna","kind":"related","created_at":"2026-07-06T13:13:21.203Z"},{"id":"pm-4a7m","kind":"related","created_at":"2026-07-06T13:13:21.203Z"},{"id":"pm-98cz","kind":"related","created_at":"2026-07-06T13:13:21.203Z"},{"id":"pm-je50","kind":"related","created_at":"2026-07-06T13:13:21.203Z"},{"id":"pm-oslr","kind":"related","created_at":"2026-07-06T13:13:21.203Z"},{"id":"pm-oxrw","kind":"related","created_at":"2026-07-06T13:13:21.203Z"},{"id":"pm-rjqr","kind":"related","created_at":"2026-07-06T13:13:21.203Z"},{"id":"pm-zwpp","kind":"related","created_at":"2026-07-06T13:13:21.203Z"}]}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"eec6ed30f58c8626c7caa6978170ddb85bf338c82098a3ebb88e6cb21f4007cb","message":"Audit #10 2026-07-06: gap found while mapping pm-usfg children to the epic AC — nothing owned the terminal boundary flip."} {"ts":"2026-07-06T13:13:37.523Z","author":"maintainer-agent","op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-06T13:13:37.523Z"},{"op":"add","path":"/metadata/files","value":[{"path":"scripts/release/sdk-import-boundary-baseline.json","scope":"project","note":"the baseline this task drives to zero and deletes"},{"path":"scripts/release/static-quality-gate.mjs","scope":"project","note":"ratchet implementation to flip to hard-deny"}]}],"before_hash":"eec6ed30f58c8626c7caa6978170ddb85bf338c82098a3ebb88e6cb21f4007cb","after_hash":"c496b1a29450894c80dfc2a4092e870b1798463d8f6ba84b384aef7a14733d04"} +{"ts":"2026-07-14T19:24:57.620Z","author":"codex-gpt5","op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:24:57.620Z"},{"op":"add","path":"/metadata/assignee","value":"codex-gpt5"}],"before_hash":"c496b1a29450894c80dfc2a4092e870b1798463d8f6ba84b384aef7a14733d04","after_hash":"be43ae467d39d53784edd0e37344b34334ae11fa2de860840085165b0b249831"} +{"ts":"2026-07-14T19:24:58.595Z","author":"codex-gpt5","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:24:58.595Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"be43ae467d39d53784edd0e37344b34334ae11fa2de860840085165b0b249831","after_hash":"94704888b77888d7cea9ddd66e936274b015dd2bce77e56a469741c7076c54b3","message":"Shrink private CLI-to-core boundary through schema SDK promotion"} +{"ts":"2026-07-14T19:24:59.263Z","author":"codex-gpt5","op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:24:59.263Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-07-14T19:24:59.262Z","author":"codex-gpt5","text":"Implementation start 2026-07-14: actively shrinking the enforced CLI/MCP private-core baseline through the pm-3mna schema ownership extraction. This run will not claim the capstone complete unless the baseline truly reaches zero."}]}],"before_hash":"94704888b77888d7cea9ddd66e936274b015dd2bce77e56a469741c7076c54b3","after_hash":"a45aa34238ab8a9ba09ac8400a409a864fcf9699941e266f51e41c9245aff033"} +{"ts":"2026-07-14T19:40:46.152Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"add","path":"/metadata/files/2","value":{"path":"src/sdk/schema.ts","scope":"project","note":"promoted domain implementation eliminates CLI private-core imports"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:46.152Z"}],"before_hash":"a45aa34238ab8a9ba09ac8400a409a864fcf9699941e266f51e41c9245aff033","after_hash":"89ad12bb671923032aee36c9c5794517398d3c1cea4a9716df248360ce451627"} +{"ts":"2026-07-14T19:40:47.126Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"replace","path":"/metadata/files/2/note","value":"thin SDK re-export replaces CLI-owned implementation"},{"op":"replace","path":"/metadata/files/2/path","value":"src/cli/commands/schema.ts"},{"op":"add","path":"/metadata/files/3","value":{"path":"src/sdk/schema.ts","scope":"project","note":"promoted domain implementation eliminates CLI private-core imports"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:47.126Z"}],"before_hash":"89ad12bb671923032aee36c9c5794517398d3c1cea4a9716df248360ce451627","after_hash":"b348b5fffedd37fb7b0a457d8967323388b815744702e8d452dd42f5fcffb330"} +{"ts":"2026-07-14T19:40:47.903Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"replace","path":"/metadata/files/3/note","value":"SDK runtime imports schema primitives directly"},{"op":"replace","path":"/metadata/files/3/path","value":"src/sdk/runtime.ts"},{"op":"add","path":"/metadata/files/4","value":{"path":"src/sdk/schema.ts","scope":"project","note":"promoted domain implementation eliminates CLI private-core imports"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:47.903Z"}],"before_hash":"b348b5fffedd37fb7b0a457d8967323388b815744702e8d452dd42f5fcffb330","after_hash":"681f63b073042fa1807a1ed73a8be3078b41724ae978a143385e9cf69fabef33"} +{"ts":"2026-07-14T19:40:48.875Z","author":"codex-gpt5","op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:48.875Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"pnpm quality:static","scope":"project","timeout_seconds":300}]}],"before_hash":"681f63b073042fa1807a1ed73a8be3078b41724ae978a143385e9cf69fabef33","after_hash":"8fdcad89eeb7058fb9e67c193648b5190fe330e88234e55f954442a148ad4eb7"} +{"ts":"2026-07-14T19:41:00.328Z","author":"codex-gpt5","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-07-14T19:41:00.327Z","author":"codex-gpt5","text":"Progress evidence: removed all 17 schema-domain private-core edges previously attributed to src/cli/commands/schema.ts. The boundary ratchet passes with zero new imports; the broader baseline remains intentionally open and is not claimed complete."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:41:00.328Z"}],"before_hash":"8fdcad89eeb7058fb9e67c193648b5190fe330e88234e55f954442a148ad4eb7","after_hash":"937394a110d94e29a5d75db8fa9a8054a6750df66ef2ee2aa64e5bad00df0559"} +{"ts":"2026-07-14T20:19:21.527Z","author":"codex-root","op":"test_run_track","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:21.527Z"},{"op":"add","path":"/metadata/test_runs","value":[{"run_id":"test-local-mrl3h543-8h8nz8","kind":"test","status":"passed","started_at":"2026-07-14T20:18:58.822Z","finished_at":"2026-07-14T20:19:21.507Z","recorded_at":"2026-07-14T20:19:21.507Z","passed":1,"failed":0,"skipped":0}]}],"before_hash":"937394a110d94e29a5d75db8fa9a8054a6750df66ef2ee2aa64e5bad00df0559","after_hash":"dd2b39436e86bf28ef878bd5c550d8bd6f3aec8034c9e41d7a5c8a2158532d35","message":"Track test run summary (test-local-mrl3h543-8h8nz8)"} +{"ts":"2026-07-14T20:19:49.227Z","author":"codex-root","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-07-14T20:19:49.227Z","author":"codex-root","text":"Final branch evidence: removed all 17 private-core imports formerly owned by src/cli/commands/schema.ts and deleted that stale baseline block. pnpm quality:static passes with no new private imports, no stale baseline entries, zero unsupported dynamic imports, 100% docstrings, and token budgets green. The repository-wide boundary baseline is not yet zero, so this capstone returns to open for the remaining command families."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:49.227Z"}],"before_hash":"dd2b39436e86bf28ef878bd5c550d8bd6f3aec8034c9e41d7a5c8a2158532d35","after_hash":"f8f79f61e50dcb26d017135ef75e15bbe5085bbd0d225d306a1fe558077dca5b"} +{"ts":"2026-07-14T20:19:49.945Z","author":"codex-root","op":"learning_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:49.945Z"},{"op":"add","path":"/metadata/learnings","value":[{"created_at":"2026-07-14T20:19:49.944Z","author":"codex-root","text":"Boundary burn-down is strongest when the implementation itself moves to an SDK-owned module; deleting a baseline block without changing ownership would only hide the dependency direction."}]}],"before_hash":"f8f79f61e50dcb26d017135ef75e15bbe5085bbd0d225d306a1fe558077dca5b","after_hash":"ac7559f93e1b0dc2841e78da6c098e06957f6d87e7cb7c2f625e80e4c87bb6d7"} +{"ts":"2026-07-14T20:19:55.559Z","author":"codex-root","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:55.559Z"},{"op":"replace","path":"/metadata/status","value":"open"}],"before_hash":"ac7559f93e1b0dc2841e78da6c098e06957f6d87e7cb7c2f625e80e4c87bb6d7","after_hash":"4facb6acaa1b20fdd7fef18ee9046168023107c7837a53a2c32fd5b898f10c50","message":"Seventeen schema boundary edges removed; return remaining zero-boundary capstone to open"} +{"ts":"2026-07-14T20:19:56.318Z","author":"codex-root","op":"release","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:56.318Z"}],"before_hash":"4facb6acaa1b20fdd7fef18ee9046168023107c7837a53a2c32fd5b898f10c50","after_hash":"4dff35ff5506ee61634bf8f0144420853692f6ddbbb639c1b2ed7bd93949ceb4","message":"Release after verified boundary burn-down slice"} diff --git a/.agents/pm/history/pm-nmzx.jsonl b/.agents/pm/history/pm-nmzx.jsonl index 05e9200b0..3507cd4a7 100644 --- a/.agents/pm/history/pm-nmzx.jsonl +++ b/.agents/pm/history/pm-nmzx.jsonl @@ -1 +1,23 @@ {"ts":"2026-07-14T19:04:33.488Z","author":"maintainer-agent","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-nmzx"},{"op":"add","path":"/metadata/title","value":"GH-562: pm init rejects --id-prefix/--prefix flag though id prefix is only positional"},{"op":"add","path":"/metadata/description","value":"GH-562: the workspace id prefix is configured by the field literally named id_prefix, so agents naturally try 'pm init --id-prefix opstest' and hit Unknown option (exit 2). Every other init setup value is flag-based (--author, --preset, ...), making the positional-only prefix the odd one out. Fix = add --id-prefix (and optionally --prefix) as a flag alias; wire through the full new-flag checklist. Related: pm-kl19 owns RENAMING an existing prefix (GH-548); this item is creation-time input ergonomics only. pm-3mna's init SDK slice will carry the same option surface — land this before or with that slice to avoid contract churn."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":["agent-ux","cli","github-issue","init"]},{"op":"add","path":"/metadata/created_at","value":"2026-07-14T19:04:33.488Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-07-14T19:04:33.488Z"},{"op":"add","path":"/metadata/author","value":"maintainer-agent"},{"op":"add","path":"/metadata/estimated_minutes","value":60},{"op":"add","path":"/metadata/acceptance_criteria","value":"pm init accepts --id-prefix as an alias for the positional prefix argument (positional form stays supported); conflicting positional + flag values fail fast with a clear message; help text, completion (bash+zsh+fish), contracts (cli-contracts.ts flag contracts + MCP schema), and docs updated per the new-flag wiring checklist; regression spec covers flag form, positional form, and the conflict case"},{"op":"add","path":"/metadata/parent","value":"pm-mpbb"},{"op":"add","path":"/metadata/risk","value":"low"},{"op":"add","path":"/metadata/confidence","value":"high"},{"op":"add","path":"/metadata/dependencies","value":[{"id":"pm-3mna","kind":"related","created_at":"2026-07-14T19:04:33.488Z"},{"id":"pm-kl19","kind":"related","created_at":"2026-07-14T19:04:33.488Z"}]}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"bb6dfd04415cc8d202da4f7c2573108dc2ad4103c9884e8211b10f9bf228448c","message":""} +{"ts":"2026-07-14T19:25:00.058Z","author":"codex-gpt5","op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:25:00.058Z"},{"op":"add","path":"/metadata/assignee","value":"codex-gpt5"}],"before_hash":"bb6dfd04415cc8d202da4f7c2573108dc2ad4103c9884e8211b10f9bf228448c","after_hash":"f19907414bb3f210992fe0586b690f7df35bd93c69580814990029a75326f17e"} +{"ts":"2026-07-14T19:25:00.833Z","author":"codex-gpt5","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:25:00.833Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"f19907414bb3f210992fe0586b690f7df35bd93c69580814990029a75326f17e","after_hash":"2063fc5b63cf84a2eb4c7a5bbf52bba08c45e74fc00c56f3111cd56a85c4c193","message":"Implement init id-prefix flag and alias parity"} +{"ts":"2026-07-14T19:25:01.766Z","author":"codex-gpt5","op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:25:01.766Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-07-14T19:25:01.765Z","author":"codex-gpt5","text":"Implementation start 2026-07-14: bundled with pm-3mna because init prefix input is part of the same public workspace-customization contract. Scope includes conflict validation, help/contracts/completions/docs, CLI regression tests, and installed temp-workspace proof."}]}],"before_hash":"2063fc5b63cf84a2eb4c7a5bbf52bba08c45e74fc00c56f3111cd56a85c4c193","after_hash":"e3bab496f16ad7b7d85b145aa84c5f033a6a2a6be3f9570bd8e9bc8466283239"} +{"ts":"2026-07-14T19:40:49.698Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:49.698Z"},{"op":"add","path":"/metadata/files","value":[{"path":"src/cli/commands/init.ts","scope":"project","note":"normalizes flag and positional prefixes with explicit conflict errors"}]}],"before_hash":"e3bab496f16ad7b7d85b145aa84c5f033a6a2a6be3f9570bd8e9bc8466283239","after_hash":"1bf9ea61f9a7d4772d5f03518c56d065564769a2e8ad248e12527947aa39ffa6"} +{"ts":"2026-07-14T19:40:51.300Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"add","path":"/metadata/files/1","value":{"path":"src/cli/register-setup.ts","scope":"project","note":"Commander registration and typed option forwarding"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:51.300Z"}],"before_hash":"1bf9ea61f9a7d4772d5f03518c56d065564769a2e8ad248e12527947aa39ffa6","after_hash":"b003d3ff541fca4c7dd5cdd6e894a871eae0d87ef2139df77086f1619bd71316"} +{"ts":"2026-07-14T19:40:51.970Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"add","path":"/metadata/files/2","value":{"path":"src/sdk/cli-contracts/flag-contracts.ts","scope":"project","note":"canonical id-prefix flag contract and prefix alias"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:51.970Z"}],"before_hash":"b003d3ff541fca4c7dd5cdd6e894a871eae0d87ef2139df77086f1619bd71316","after_hash":"d79c6621f30c463db23b3a909029dfae148e95277d71f0e2fa33e698b54b9237"} +{"ts":"2026-07-14T19:40:52.717Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"replace","path":"/metadata/files/2/note","value":"Commander registration and typed option forwarding"},{"op":"replace","path":"/metadata/files/2/path","value":"src/cli/register-setup.ts"},{"op":"replace","path":"/metadata/files/1/note","value":"normalizes flag and positional prefixes with explicit conflict errors"},{"op":"replace","path":"/metadata/files/1/path","value":"src/cli/commands/init.ts"},{"op":"replace","path":"/metadata/files/0/note","value":"bash zsh and fish completion exposure"},{"op":"replace","path":"/metadata/files/0/path","value":"src/cli/commands/completion.ts"},{"op":"add","path":"/metadata/files/3","value":{"path":"src/sdk/cli-contracts/flag-contracts.ts","scope":"project","note":"canonical id-prefix flag contract and prefix alias"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:52.717Z"}],"before_hash":"d79c6621f30c463db23b3a909029dfae148e95277d71f0e2fa33e698b54b9237","after_hash":"4cc579047b4bca70f2d3e3ba13009b6d38537de5bbfe124cf00282a14508c4a2"} +{"ts":"2026-07-14T19:40:53.445Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"add","path":"/metadata/files/4","value":{"path":"tests/fixtures/contracts/full.json","scope":"project","note":"governed command contract snapshot"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:53.445Z"}],"before_hash":"4cc579047b4bca70f2d3e3ba13009b6d38537de5bbfe124cf00282a14508c4a2","after_hash":"6609158d50ab6ef5a2ac881311568593ee230b402411ce1d63a22cb62010c873"} +{"ts":"2026-07-14T19:40:54.274Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"add","path":"/metadata/files/5","value":{"path":"tests/integration/init-path-guard.integration.spec.ts","scope":"project","note":"flag positional equivalence empty input and conflict regression coverage"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:54.274Z"}],"before_hash":"6609158d50ab6ef5a2ac881311568593ee230b402411ce1d63a22cb62010c873","after_hash":"a97359dd9a48814123726c243a1007286fefd0d3fddc033c51a7f0a3ed4fd805"} +{"ts":"2026-07-14T19:40:54.999Z","author":"codex-gpt5","op":"files_add","patch":[{"op":"add","path":"/metadata/files/6","value":{"path":"tests/unit/commands/completion-command.spec.ts","scope":"project","note":"completion contract assertions for every shell"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:54.999Z"}],"before_hash":"a97359dd9a48814123726c243a1007286fefd0d3fddc033c51a7f0a3ed4fd805","after_hash":"b7f2245df035b775fc6ef3ab82654950d19ac13bf87a969e202653e31b91e047"} +{"ts":"2026-07-14T19:40:56.084Z","author":"codex-gpt5","op":"docs_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:56.084Z"},{"op":"add","path":"/metadata/docs","value":[{"path":"docs/COMMANDS.md","scope":"project","note":"documents canonical flag alias compatibility and conflict behavior"}]}],"before_hash":"b7f2245df035b775fc6ef3ab82654950d19ac13bf87a969e202653e31b91e047","after_hash":"ddfcc812df4aba06516d580c45381d80f8da555ff63d16d5334f81fae5fb8b46"} +{"ts":"2026-07-14T19:40:56.962Z","author":"codex-gpt5","op":"docs_add","patch":[{"op":"add","path":"/metadata/docs/1","value":{"path":"docs/SDK.md","scope":"project","note":"documents shared customization primitive ownership"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:56.962Z"}],"before_hash":"ddfcc812df4aba06516d580c45381d80f8da555ff63d16d5334f81fae5fb8b46","after_hash":"2b218bd6bf5eab26acb8fc85c3b9e8a029176609d82f955acb1e1c9220e05616"} +{"ts":"2026-07-14T19:40:57.709Z","author":"codex-gpt5","op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:57.709Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"node scripts/run-tests.mjs test -- tests/integration/init-path-guard.integration.spec.ts tests/unit/commands/completion-command.spec.ts","scope":"project","timeout_seconds":240}]}],"before_hash":"2b218bd6bf5eab26acb8fc85c3b9e8a029176609d82f955acb1e1c9220e05616","after_hash":"c4d00065e7cf1d504aabbda45931abb6c10482e365c6b840f21c51ee9e74644f"} +{"ts":"2026-07-14T19:40:58.400Z","author":"codex-gpt5","op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"pnpm contracts:check","scope":"project","timeout_seconds":120}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:40:58.400Z"}],"before_hash":"c4d00065e7cf1d504aabbda45931abb6c10482e365c6b840f21c51ee9e74644f","after_hash":"fa15dc6854eabd75f9b1f49ba27e4d57c16dbe66b5aad98eccba8c8d577b875c"} +{"ts":"2026-07-14T19:41:01.717Z","author":"codex-gpt5","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-07-14T19:41:01.717Z","author":"codex-gpt5","text":"Progress evidence: init now accepts --id-prefix with --prefix alias, preserves positional compatibility, accepts normalized equivalent dual inputs, and rejects empty/conflicting values deterministically. Help/contracts/completions/docs and focused regression coverage are green."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T19:41:01.717Z"}],"before_hash":"fa15dc6854eabd75f9b1f49ba27e4d57c16dbe66b5aad98eccba8c8d577b875c","after_hash":"d4a54daa07b59f1037ee7db97abd534c675c9f6672d27eedf473f4ad11e0159b"} +{"ts":"2026-07-14T20:17:06.614Z","author":"codex-root","op":"test_run_track","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:17:06.614Z"},{"op":"add","path":"/metadata/test_runs","value":[{"run_id":"test-local-mrl3e90h-30v3ii","kind":"test","status":"failed","started_at":"2026-07-14T20:16:48.338Z","finished_at":"2026-07-14T20:17:06.593Z","recorded_at":"2026-07-14T20:17:06.593Z","passed":1,"failed":1,"skipped":0}]}],"before_hash":"d4a54daa07b59f1037ee7db97abd534c675c9f6672d27eedf473f4ad11e0159b","after_hash":"7fa1c2a779a0223f630f43b687da4e0b1ff707486ff599daafefca8219610723","message":"Track test run summary (test-local-mrl3e90h-30v3ii)"} +{"ts":"2026-07-14T20:17:42.518Z","author":"codex-root","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-07-14T20:17:42.518Z","author":"codex-root","text":"Linked-test diagnostic: the first acceptance run passed 102 init/completion tests but the contract snapshot check failed only because pm test correctly sandboxed PM_PATH with zero tracker items/extensions, while contracts:check intentionally snapshots the real project package surface. Direct pnpm contracts:check on the real tracker is current. Re-running the linked set with explicit tracker context records the intended package-aware acceptance evidence."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:17:42.518Z"}],"before_hash":"7fa1c2a779a0223f630f43b687da4e0b1ff707486ff599daafefca8219610723","after_hash":"e3a6f6220e9fe65a7d3abf16b29c086a944076d5a65630504d06180f4a8e70ca"} +{"ts":"2026-07-14T20:18:02.765Z","author":"codex-root","op":"test_run_track","patch":[{"op":"add","path":"/metadata/test_runs/1","value":{"run_id":"test-local-mrl3fgcc-myd5up","kind":"test","status":"passed","started_at":"2026-07-14T20:17:43.222Z","finished_at":"2026-07-14T20:18:02.748Z","recorded_at":"2026-07-14T20:18:02.748Z","passed":2,"failed":0,"skipped":0}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:18:02.765Z"}],"before_hash":"e3a6f6220e9fe65a7d3abf16b29c086a944076d5a65630504d06180f4a8e70ca","after_hash":"7e9b823f9e9d0ba2da08476fa3dc188393dd0c0db32264e19c10af209221e537","message":"Track test run summary (test-local-mrl3fgcc-myd5up)"} +{"ts":"2026-07-14T20:19:50.622Z","author":"codex-root","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/3","value":{"created_at":"2026-07-14T20:19:50.622Z","author":"codex-root","text":"Final evidence: canonical --id-prefix and --prefix alias work with flag-only, positional-only, normalized equivalent dual input, empty-input refusal, and deterministic conflict guidance. Bash/zsh/fish completions, runtime contracts, docs, direct registration forwarding, integration coverage, and packed installed-CLI temp-workspace proof pass. Linked acceptance rerun is green (102 init/completion tests plus current project contract snapshot). Full repository verification is 5,701 tests at literal 100/100/100/100 coverage."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:50.622Z"}],"before_hash":"7e9b823f9e9d0ba2da08476fa3dc188393dd0c0db32264e19c10af209221e537","after_hash":"fe51796aaba8f8b5e0c273533acf8d282b462a27fc8edfc1c5c278af790f2e83"} +{"ts":"2026-07-14T20:19:51.329Z","author":"codex-root","op":"learning_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:51.329Z"},{"op":"add","path":"/metadata/learnings","value":[{"created_at":"2026-07-14T20:19:51.329Z","author":"codex-root","text":"Commander stores the canonical property from the long flag declaration, so --prefix, --id-prefix forwards through options.idPrefix; resolving positional and flag forms in one normalizer keeps compatibility and conflict behavior deterministic."}]}],"before_hash":"fe51796aaba8f8b5e0c273533acf8d282b462a27fc8edfc1c5c278af790f2e83","after_hash":"d7a1250f9c080b202b3a65e14f78099ead54457bbc391e53add53ad744b57b30"} +{"ts":"2026-07-14T20:19:52.247Z","author":"codex-root","op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:52.247Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-14T20:19:52.246Z"},{"op":"add","path":"/metadata/resolution","value":"completed"},{"op":"add","path":"/metadata/expected_result","value":"pm init accepts a discoverable id-prefix flag without breaking positional compatibility and rejects ambiguous dual inputs."},{"op":"add","path":"/metadata/actual_result","value":"Both --id-prefix and --prefix initialize normalized prefixes, equivalent positional/flag values compose, conflicts fail with code 2 and guidance, and all focused/full/package proofs pass."},{"op":"add","path":"/metadata/close_reason","value":"Implemented GH-562 end to end with canonical --id-prefix, --prefix alias, conflict validation, completions, contracts, docs, and installed temp-workspace proof."}],"before_hash":"d7a1250f9c080b202b3a65e14f78099ead54457bbc391e53add53ad744b57b30","after_hash":"81c2e6c2f3eade7e60628ec90382b7cad2e41f1b8a642f93706d10c51b07b86f"} +{"ts":"2026-07-14T20:19:53.171Z","author":"codex-root","op":"release","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:53.171Z"}],"before_hash":"81c2e6c2f3eade7e60628ec90382b7cad2e41f1b8a642f93706d10c51b07b86f","after_hash":"871c32bef96a628ae9b2fb47f0fa64e159c3c6d243e29ef8eb0eaab780ae96e4","message":"Release after verified closure"} diff --git a/.agents/pm/history/pm-qwoy.jsonl b/.agents/pm/history/pm-qwoy.jsonl index 49846d30f..4e8aabd71 100644 --- a/.agents/pm/history/pm-qwoy.jsonl +++ b/.agents/pm/history/pm-qwoy.jsonl @@ -2,3 +2,4 @@ {"ts":"2026-07-07T14:10:40.093Z","author":"maintainer-agent","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-07T14:10:40.093Z"},{"op":"replace","path":"/metadata/description","value":"Living backlog capability epic for repository security posture: Semgrep Code finding triage, spawn/shell and RegExp injection hardening, dependabot hygiene, CodeQL, secret guardrails. Created in ecosystem review #12 after the 2026-07-06 Semgrep import (pm-oz8u, 40 findings) and the dependabot chore pm-2czc had no fitting capability epic. Duplicate check: pm search 'security code scanning' / epic list — no existing security epic."}],"before_hash":"c6902ffd007604492e5543b564ebc5c166085e0fdb7407be3a4c0b70a150d659","after_hash":"39a3e925bc788ec9a37d8a6b7e9e1db4c79e022e30e2beac347d760f778b856e"} {"ts":"2026-07-13T19:42:42.258Z","author":"codex-root","op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-13T19:42:42.258Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-07-13T19:42:42.258Z","author":"codex-root","text":"Live GitHub security/dependency sweep 2026-07-13: Dependabot alert API contains 39 fixed and 0 open alerts; code-scanning contains 10 fixed and 0 open alerts; secret-scanning contains 0 alerts; pnpm audit reports 0 vulnerabilities at every severity. Five dependency-update PRs are open and remain isolated for their own review: #537 dev-dependencies group, #538 @sentry/node 10.64.0, #539 TypeScript 7.0.2, #540 @types/node 26.1.1, #541 eslint-plugin-unicorn 71.1.0. No duplicate pm item created and none were claimed in this context implementation lane."}]}],"before_hash":"39a3e925bc788ec9a37d8a6b7e9e1db4c79e022e30e2beac347d760f778b856e","after_hash":"1e5b86798939a57a9f455f243f151a63ceaaf4c020cfae0a9fc2a3186d919bf7"} {"ts":"2026-07-14T10:18:55.307Z","author":"codex-gpt5","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-07-14T10:18:55.307Z","author":"codex-gpt5","text":"Live GitHub security/dependency sweep 2026-07-14: Dependabot, code-scanning, and secret-scanning APIs each return zero open alerts; pnpm audit --audit-level high reports no known vulnerabilities. Open dependency PRs are #552 grouped dev dependencies, #541 eslint-plugin-unicorn 71.1.0, #540 @types/node 26.1.1, #539 TypeScript 7.0.2, and #538 @sentry/node 10.65.0. They remain isolated/unclaimed for their own compatibility review."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T10:18:55.307Z"}],"before_hash":"1e5b86798939a57a9f455f243f151a63ceaaf4c020cfae0a9fc2a3186d919bf7","after_hash":"a1c1a35dcfc2f9695f01d62eaa9a36ccd7bb499551803b567209017f97a7f212"} +{"ts":"2026-07-14T20:01:08.602Z","author":"codex-root","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-07-14T20:01:08.602Z","author":"codex-root","text":"Live GitHub security/dependency reconciliation 2026-07-14 22:00 CEST: Dependabot, code-scanning, and secret-scanning APIs each report 0 open alerts. Open dependency PRs remain isolated and unclaimed: #552 and #541 are fully green; #538 is fully green; #539 TypeScript 7.0.2 fails static, coverage-shard, and Windows compatibility gates; #540 @types/node 26.1.1 fails build/docs and is incompatible with the repository's Node >=22.18 declaration contract already decided in pm-2a9n. No duplicate security or dependency item created by this SDK lane."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:01:08.602Z"}],"before_hash":"a1c1a35dcfc2f9695f01d62eaa9a36ccd7bb499551803b567209017f97a7f212","after_hash":"53d91739f854e54e6a41671dc59f25ebf572c75f07716f3fd41a90d5092da634"} diff --git a/.agents/pm/issues/pm-nmzx.toon b/.agents/pm/issues/pm-nmzx.toon index 6aed1b0e1..693beb382 100644 --- a/.agents/pm/issues/pm-nmzx.toon +++ b/.agents/pm/issues/pm-nmzx.toon @@ -2,18 +2,47 @@ id: pm-nmzx title: "GH-562: pm init rejects --id-prefix/--prefix flag though id prefix is only positional" description: "GH-562: the workspace id prefix is configured by the field literally named id_prefix, so agents naturally try 'pm init --id-prefix opstest' and hit Unknown option (exit 2). Every other init setup value is flag-based (--author, --preset, ...), making the positional-only prefix the odd one out. Fix = add --id-prefix (and optionally --prefix) as a flag alias; wire through the full new-flag checklist. Related: pm-kl19 owns RENAMING an existing prefix (GH-548); this item is creation-time input ergonomics only. pm-3mna's init SDK slice will carry the same option surface — land this before or with that slice to avoid contract churn." type: Issue -status: open +status: closed priority: 2 tags[4]: agent-ux,cli,github-issue,init created_at: "2026-07-14T19:04:33.488Z" -updated_at: "2026-07-14T19:04:33.488Z" +updated_at: "2026-07-14T20:19:53.171Z" +closed_at: "2026-07-14T20:19:52.246Z" author: maintainer-agent estimated_minutes: 60 acceptance_criteria: "pm init accepts --id-prefix as an alias for the positional prefix argument (positional form stays supported); conflicting positional + flag values fail fast with a clear message; help text, completion (bash+zsh+fish), contracts (cli-contracts.ts flag contracts + MCP schema), and docs updated per the new-flag wiring checklist; regression spec covers flag form, positional form, and the conflict case" parent: pm-mpbb risk: low confidence: high +resolution: completed +expected_result: pm init accepts a discoverable id-prefix flag without breaking positional compatibility and rejects ambiguous dual inputs. +actual_result: "Both --id-prefix and --prefix initialize normalized prefixes, equivalent positional/flag values compose, conflicts fail with code 2 and guidance, and all focused/full/package proofs pass." dependencies[2]{id,kind,created_at,author,source_kind}: pm-3mna,related,"2026-07-14T19:04:33.488Z",null,null pm-kl19,related,"2026-07-14T19:04:33.488Z",null,null +comments[4]{created_at,author,text}: + "2026-07-14T19:25:01.765Z",codex-gpt5,"Implementation start 2026-07-14: bundled with pm-3mna because init prefix input is part of the same public workspace-customization contract. Scope includes conflict validation, help/contracts/completions/docs, CLI regression tests, and installed temp-workspace proof." + "2026-07-14T19:41:01.717Z",codex-gpt5,"Progress evidence: init now accepts --id-prefix with --prefix alias, preserves positional compatibility, accepts normalized equivalent dual inputs, and rejects empty/conflicting values deterministically. Help/contracts/completions/docs and focused regression coverage are green." + "2026-07-14T20:17:42.518Z",codex-root,"Linked-test diagnostic: the first acceptance run passed 102 init/completion tests but the contract snapshot check failed only because pm test correctly sandboxed PM_PATH with zero tracker items/extensions, while contracts:check intentionally snapshots the real project package surface. Direct pnpm contracts:check on the real tracker is current. Re-running the linked set with explicit tracker context records the intended package-aware acceptance evidence." + "2026-07-14T20:19:50.622Z",codex-root,"Final evidence: canonical --id-prefix and --prefix alias work with flag-only, positional-only, normalized equivalent dual input, empty-input refusal, and deterministic conflict guidance. Bash/zsh/fish completions, runtime contracts, docs, direct registration forwarding, integration coverage, and packed installed-CLI temp-workspace proof pass. Linked acceptance rerun is green (102 init/completion tests plus current project contract snapshot). Full repository verification is 5,701 tests at literal 100/100/100/100 coverage." +learnings[1]{created_at,author,text}: + "2026-07-14T20:19:51.329Z",codex-root,"Commander stores the canonical property from the long flag declaration, so --prefix, --id-prefix forwards through options.idPrefix; resolving positional and flag forms in one normalizer keeps compatibility and conflict behavior deterministic." +files[7]{path,scope,note}: + src/cli/commands/completion.ts,project,bash zsh and fish completion exposure + src/cli/commands/init.ts,project,normalizes flag and positional prefixes with explicit conflict errors + src/cli/register-setup.ts,project,Commander registration and typed option forwarding + src/sdk/cli-contracts/flag-contracts.ts,project,canonical id-prefix flag contract and prefix alias + tests/fixtures/contracts/full.json,project,governed command contract snapshot + tests/integration/init-path-guard.integration.spec.ts,project,flag positional equivalence empty input and conflict regression coverage + tests/unit/commands/completion-command.spec.ts,project,completion contract assertions for every shell +tests[2]{command,scope,timeout_seconds}: + node scripts/run-tests.mjs test -- tests/integration/init-path-guard.integration.spec.ts tests/unit/commands/completion-command.spec.ts,project,240 + "pnpm contracts:check",project,120 +test_runs[2]{run_id,kind,status,started_at,finished_at,recorded_at,passed,failed,skipped}: + test-local-mrl3e90h-30v3ii,test,failed,"2026-07-14T20:16:48.338Z","2026-07-14T20:17:06.593Z","2026-07-14T20:17:06.593Z",1,1,0 + test-local-mrl3fgcc-myd5up,test,passed,"2026-07-14T20:17:43.222Z","2026-07-14T20:18:02.748Z","2026-07-14T20:18:02.748Z",2,0,0 +docs[2]{path,scope,note}: + docs/COMMANDS.md,project,documents canonical flag alias compatibility and conflict behavior + docs/SDK.md,project,documents shared customization primitive ownership +close_reason: "Implemented GH-562 end to end with canonical --id-prefix, --prefix alias, conflict validation, completions, contracts, docs, and installed temp-workspace proof." body: "" diff --git a/.agents/pm/tasks/pm-9x6e.toon b/.agents/pm/tasks/pm-9x6e.toon index 3200c2c3f..27f163413 100644 --- a/.agents/pm/tasks/pm-9x6e.toon +++ b/.agents/pm/tasks/pm-9x6e.toon @@ -6,7 +6,7 @@ status: open priority: 2 tags[5]: architecture,"area:cli","area:mcp","area:sdk",ci created_at: "2026-07-06T13:13:21.203Z" -updated_at: "2026-07-06T13:13:37.523Z" +updated_at: "2026-07-14T20:19:56.318Z" author: maintainer-agent estimated_minutes: 480 acceptance_criteria: sdk-import-boundary-baseline.json reaches zero entries and is deleted; static-quality-gate hard-denies any private src/core import from src/cli/** and src/mcp/** with no baseline escape hatch; src/mcp handlers consume only src/sdk primitives; full test suite + 100% coverage gate green with zero CLI/MCP behavior change @@ -22,7 +22,20 @@ dependencies[8]{id,kind,created_at,author,source_kind}: pm-oxrw,related,"2026-07-06T13:13:21.203Z",null,null pm-rjqr,related,"2026-07-06T13:13:21.203Z",null,null pm-zwpp,related,"2026-07-06T13:13:21.203Z",null,null -files[2]{path,scope,note}: +comments[3]{created_at,author,text}: + "2026-07-14T19:24:59.262Z",codex-gpt5,"Implementation start 2026-07-14: actively shrinking the enforced CLI/MCP private-core baseline through the pm-3mna schema ownership extraction. This run will not claim the capstone complete unless the baseline truly reaches zero." + "2026-07-14T19:41:00.327Z",codex-gpt5,"Progress evidence: removed all 17 schema-domain private-core edges previously attributed to src/cli/commands/schema.ts. The boundary ratchet passes with zero new imports; the broader baseline remains intentionally open and is not claimed complete." + "2026-07-14T20:19:49.227Z",codex-root,"Final branch evidence: removed all 17 private-core imports formerly owned by src/cli/commands/schema.ts and deleted that stale baseline block. pnpm quality:static passes with no new private imports, no stale baseline entries, zero unsupported dynamic imports, 100% docstrings, and token budgets green. The repository-wide boundary baseline is not yet zero, so this capstone returns to open for the remaining command families." +learnings[1]{created_at,author,text}: + "2026-07-14T20:19:49.944Z",codex-root,Boundary burn-down is strongest when the implementation itself moves to an SDK-owned module; deleting a baseline block without changing ownership would only hide the dependency direction. +files[5]{path,scope,note}: scripts/release/sdk-import-boundary-baseline.json,project,the baseline this task drives to zero and deletes scripts/release/static-quality-gate.mjs,project,ratchet implementation to flip to hard-deny + src/cli/commands/schema.ts,project,thin SDK re-export replaces CLI-owned implementation + src/sdk/runtime.ts,project,SDK runtime imports schema primitives directly + src/sdk/schema.ts,project,promoted domain implementation eliminates CLI private-core imports +tests[1]{command,scope,timeout_seconds}: + "pnpm quality:static",project,300 +test_runs[1]{run_id,kind,status,started_at,finished_at,recorded_at,passed,failed,skipped}: + test-local-mrl3h543-8h8nz8,test,passed,"2026-07-14T20:18:58.822Z","2026-07-14T20:19:21.507Z","2026-07-14T20:19:21.507Z",1,0,0 body: "" diff --git a/CHANGELOG.md b/CHANGELOG.md index 1813e8de8..f27570216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- GH-562: pm init rejects --id-prefix/--prefix flag though id prefix is only positional ([pm-nmzx](https://github.com/unbraind/pm-cli/blob/main/.agents/pm/issues/pm-nmzx.toon)) - GH-560: extension renderer overrides diverge between SDK harness and real CLI output ([pm-as4a](https://github.com/unbraind/pm-cli/blob/main/.agents/pm/issues/pm-as4a.toon)) - GH-557: contract layer intercepts -h/--help before variadic-positional handlers, blocking legitimate positional content ([pm-albl](https://github.com/unbraind/pm-cli/blob/main/.agents/pm/issues/pm-albl.toon)) - GH-547: SDK exporters and renderers cannot suppress host rendering of handled output ([pm-f38n](https://github.com/unbraind/pm-cli/blob/main/.agents/pm/issues/pm-f38n.toon)) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 272fa66ef..6fc89c399 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -23,22 +23,21 @@ Tracked documentation work: [pm-u9d0](../.agents/pm/epics/pm-u9d0.toon). ## Command Families -| Family | Commands | Purpose | -|--------|----------|---------| -| Bootstrap | `init`, `config`, `health`, `telemetry` | create and inspect tracker setup | -| Lifecycle | `create`, `copy`, `focus`, `claim`, `update`, `append`, `close`, `release`, `delete`, `start-task`, `pause-task`, `close-task` | mutate item state | -| Bulk | `update-many`, `close-many` | apply one change across a matched, dry-run-previewed set with a rollback checkpoint | -| Scheduling | `meet`, `event`, `remind` | low-friction Meeting/Event/Reminder creation | -| Planning | `plan create`, `plan add-step`, `plan update-step`, `plan complete-step`, `plan link`, `plan approve`, `plan materialize` | agent-optimized living plans with ordered steps, evidence, decisions, validation, and materialization | -| Links | `files`, `docs`, `test`, `deps` | connect items to artifacts, tests, and relationships | -| Verification | `test`, `test-all`, `test-runs`†, `validate`, `gc` | run linked tests and repository checks | -| History | `history`, `history-compact`, `history-redact`, `history-repair`, `activity`, `restore`, `stats` | inspect, compact, redact, re-anchor, and recover item state | -| Schema | `schema add-type` / `remove-type` / `add-status` / `remove-status` / `add-field` / `remove-field` / `apply-preset` | manage config-driven custom item types (`.agents/pm/schema/types.json`), statuses (`.agents/pm/schema/statuses.json`), and custom metadata fields (`.agents/pm/schema/fields.json`); `apply-preset` adopts a domain type preset; `add-type --infer` derives types from title-prefix conventions | -| Profiles | `profile list` / `show` / `apply` / `lint` | compose item types, statuses, fields, workflows, config, templates, and recommended packages into archetype bundles (agile/ops/research); `apply` stages every dimension idempotently; `lint` reports author-time consistency findings without writing | -| Calendar | `calendar`, `cal` | project deadlines, reminders, and events | -| Packages | `install`, `upgrade`, `package`, `packages`, `extension`, package/extension command groups | install, upgrade, manage, and run package-backed extension commands | -| Machines | `contracts`, `help`, optional `guide`/`completion` | command contracts plus optional guide-shell docs routing and shell helpers | - +| Family | Commands | Purpose | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Bootstrap | `init`, `config`, `health`, `telemetry` | create and inspect tracker setup | +| Lifecycle | `create`, `copy`, `focus`, `claim`, `update`, `append`, `close`, `release`, `delete`, `start-task`, `pause-task`, `close-task` | mutate item state | +| Bulk | `update-many`, `close-many` | apply one change across a matched, dry-run-previewed set with a rollback checkpoint | +| Scheduling | `meet`, `event`, `remind` | low-friction Meeting/Event/Reminder creation | +| Planning | `plan create`, `plan add-step`, `plan update-step`, `plan complete-step`, `plan link`, `plan approve`, `plan materialize` | agent-optimized living plans with ordered steps, evidence, decisions, validation, and materialization | +| Links | `files`, `docs`, `test`, `deps` | connect items to artifacts, tests, and relationships | +| Verification | `test`, `test-all`, `test-runs`†, `validate`, `gc` | run linked tests and repository checks | +| History | `history`, `history-compact`, `history-redact`, `history-repair`, `activity`, `restore`, `stats` | inspect, compact, redact, re-anchor, and recover item state | +| Schema | `schema add-type` / `remove-type` / `add-status` / `remove-status` / `add-field` / `remove-field` / `apply-preset` | manage config-driven custom item types (`.agents/pm/schema/types.json`), statuses (`.agents/pm/schema/statuses.json`), and custom metadata fields (`.agents/pm/schema/fields.json`); `apply-preset` adopts a domain type preset; `add-type --infer` derives types from title-prefix conventions | +| Profiles | `profile list` / `show` / `apply` / `lint` | compose item types, statuses, fields, workflows, config, templates, and recommended packages into archetype bundles (agile/ops/research); `apply` stages every dimension idempotently; `lint` reports author-time consistency findings without writing | +| Calendar | `calendar`, `cal` | project deadlines, reminders, and events | +| Packages | `install`, `upgrade`, `package`, `packages`, `extension`, package/extension command groups | install, upgrade, manage, and run package-backed extension commands | +| Machines | `contracts`, `help`, optional `guide`/`completion` | command contracts plus optional guide-shell docs routing and shell helpers | `†` `test-runs` subcommands are provided by the optional `linked-test-adapters` package (`pm install linked-test-adapters --project`). @@ -47,6 +46,7 @@ Tracked documentation work: [pm-u9d0](../.agents/pm/epics/pm-u9d0.toon). ```bash pm init pm init --defaults --with-packages +pm init --id-prefix ops --defaults pm init --workspace ./new-project --defaults pm init ./sandbox-tracker --defaults pm init --agent-guidance status @@ -57,6 +57,11 @@ pm telemetry status ``` `pm init` creates `.agents/pm`. `pm health --check-only --summary --json` gives the smallest machine-readable health gate without refreshing optional search artifacts. +Use `--id-prefix ` (alias `--prefix`) when automation should set the item +ID prefix without relying on the legacy positional form. `pm init ops` remains +compatible, and supplying both forms is accepted only when they normalize to the +same prefix; conflicting values fail with `init_id_prefix_conflict` instead of +silently choosing one. Use `pm init --workspace ` when `` is a project root; it creates `/.agents/pm`. A path-like positional remains the advanced tracker-root form and writes tracker files directly at that path. Both explicit target forms return `target.mode`, `target.tracker_root`, and tracker-scoped executable `next_steps` so agents can run the suggestions from any working directory. `pm init --agent-guidance ask` is the default behavior: prompt in TTY only when AGENTS/CLAUDE guidance is missing and no decline is recorded. Use `--agent-guidance add` to write guidance, `--agent-guidance skip` to persist a decline without writing, and `--agent-guidance status` to inspect guidance state. @@ -215,7 +220,11 @@ Tune ranking via `search.bm25.k1` (term-frequency saturation, default `1.2`) and ```json [ - { "query": "offline search ranking", "relevant_ids": ["pm-75k9"], "mode": "keyword" }, + { + "query": "offline search ranking", + "relevant_ids": ["pm-75k9"], + "mode": "keyword" + }, { "query": "relevance regression gate", "relevant_ids": ["pm-u8n5"] } ] ``` @@ -233,7 +242,7 @@ pm eval --fail-under 0.6 --json # CI gate: exit non-zero when aggregate nDC ### Full results, totals, and bodies -`pm list*` returns every matched row when neither `--limit` nor `--offset` is set. When a `--limit`/`--offset` *does* drop rows, the result adds a top-level `total` (the pre-pagination match count) so an agent knows how many remain. Pass `--no-truncate` (alias `--all`) to force the entire matched set and override any `--limit` in one call — the canonical "give me everything" flag for large-corpus audits: +`pm list*` returns every matched row when neither `--limit` nor `--offset` is set. When a `--limit`/`--offset` _does_ drop rows, the result adds a top-level `total` (the pre-pagination match count) so an agent knows how many remain. Pass `--no-truncate` (alias `--all`) to force the entire matched set and override any `--limit` in one call — the canonical "give me everything" flag for large-corpus audits: ```bash pm list-all --no-truncate --brief # every matched row, ignoring any --limit @@ -262,7 +271,7 @@ CSV output is RFC 4180 compliant (values with commas, quotes, or newlines are qu ### Missing-metadata filters -Every `list*` command also accepts metadata-presence filters for governance backfill: `--filter-ac-missing` (no `acceptance_criteria`), `--filter-estimates-missing` (no `estimated_minutes`; singular `--filter-estimate-missing` is an alias), `--filter-resolution-missing` (terminal items with no `resolution`), and `--filter-metadata-missing` (the union — missing *any* of those). Specific flags AND together; combine them with any other filter. They surface in the result's `filters` echo (`filter_ac_missing` etc.). +Every `list*` command also accepts metadata-presence filters for governance backfill: `--filter-ac-missing` (no `acceptance_criteria`), `--filter-estimates-missing` (no `estimated_minutes`; singular `--filter-estimate-missing` is an alias), `--filter-resolution-missing` (terminal items with no `resolution`), and `--filter-metadata-missing` (the union — missing _any_ of those). Specific flags AND together; combine them with any other filter. They surface in the result's `filters` echo (`filter_ac_missing` etc.). The same `list*` commands and `pm search` extend this with governance-field presence selectors — `--filter-reviewer-missing`, `--filter-risk-missing`, `--filter-confidence-missing`, `--filter-sprint-missing`, and `--filter-release-missing` — each selecting items where that single field is unset. @@ -279,7 +288,7 @@ pm list-open --filter-reviewer-missing --brief ### Content-field presence filters -`list*` and `pm search` also accept paired presence/absence selectors for each content field, so you can scope to items that *have* a given field populated or that are *missing* it: `--has-notes`/`--no-notes`, `--has-learnings`/`--no-learnings`, `--has-files`/`--no-files`, `--has-docs`/`--no-docs`, `--has-tests`/`--no-tests`, `--has-comments`/`--no-comments`, `--has-deps`/`--no-deps`, `--has-body`/`--empty-body`, and `--has-linked-command`/`--no-linked-command`. Requesting both the present and absent variant for the same field is a usage error. Multiple content filters AND together and compose with any other filter. +`list*` and `pm search` also accept paired presence/absence selectors for each content field, so you can scope to items that _have_ a given field populated or that are _missing_ it: `--has-notes`/`--no-notes`, `--has-learnings`/`--no-learnings`, `--has-files`/`--no-files`, `--has-docs`/`--no-docs`, `--has-tests`/`--no-tests`, `--has-comments`/`--no-comments`, `--has-deps`/`--no-deps`, `--has-body`/`--empty-body`, and `--has-linked-command`/`--no-linked-command`. Requesting both the present and absent variant for the same field is a usage error. Multiple content filters AND together and compose with any other filter. ```bash # Closed items that shipped no documented learnings @@ -650,7 +659,7 @@ pm validate --check-metadata --all-affected-ids pm validate --check-metadata --json | jq -r '.checks[] | select(.name=="metadata") | .details.missing_acceptance_criteria_item_ids[]' ``` -`--auto-fix` applies the safe, deterministic subset of those remediations automatically and reports the result under a top-level `fixes` object (`planned_fixes[]`, `applied_fixes[]`, `gated_fixes[]`, `failed_fixes[]` — each row lists the item id, check, field, and the equivalent standalone `pm` command). Safe means derivable and non-destructive: a closed item missing `resolution` is backfilled from its own `close_reason` (or the `"completed"` default), and a closed item missing `close_reason` is backfilled from its existing `resolution`. Auto-fix NEVER closes, cancels, or deletes items, and every applied fix runs through the normal audited `pm update` path. Two scopes are opt-in. Structural lifecycle fixes — an active item whose parent is terminal gets reparented to its active grandparent or has its parent link cleared — are always *planned* but only *applied* under an explicit `--fix-scope lifecycle`. Estimate backfills — an item missing `estimated_minutes` gets a config-driven per-type default — are likewise *planned* but only *applied* under `--fix-scope estimates` (estimates are heuristic per-type guesses, not derived facts, so they are never auto-granted). The defaults are `Epic`/`Milestone` 2880, `Feature`/`Story` 480, `Task`/`Plan` 120, `Issue`/`Bug` 60, `Chore` 30, `Decision` 15, and a 120-minute fallback for any other type; override them per type with the `validation.estimate_defaults_by_type` setting (a `{ "": }` map). `--fix-scope` is an exact allowlist of what `--auto-fix` may mutate (`metadata`, `resolution`, `estimates`, `lifecycle`; comma-separated or repeatable) — `--fix-scope estimates` alone applies *only* estimate fixes; omitting the flag grants the safe field-backfill scopes (`metadata`, `resolution`) and neither estimates nor lifecycle. `--dry-run` previews the full plan without mutating anything. With `--auto-fix` and no explicit `--check-*` flags, only the fix-capable checks (metadata, resolution, lifecycle) run. The `checks` in the output always describe the pre-fix state; re-run `pm validate` to confirm convergence. +`--auto-fix` applies the safe, deterministic subset of those remediations automatically and reports the result under a top-level `fixes` object (`planned_fixes[]`, `applied_fixes[]`, `gated_fixes[]`, `failed_fixes[]` — each row lists the item id, check, field, and the equivalent standalone `pm` command). Safe means derivable and non-destructive: a closed item missing `resolution` is backfilled from its own `close_reason` (or the `"completed"` default), and a closed item missing `close_reason` is backfilled from its existing `resolution`. Auto-fix NEVER closes, cancels, or deletes items, and every applied fix runs through the normal audited `pm update` path. Two scopes are opt-in. Structural lifecycle fixes — an active item whose parent is terminal gets reparented to its active grandparent or has its parent link cleared — are always _planned_ but only _applied_ under an explicit `--fix-scope lifecycle`. Estimate backfills — an item missing `estimated_minutes` gets a config-driven per-type default — are likewise _planned_ but only _applied_ under `--fix-scope estimates` (estimates are heuristic per-type guesses, not derived facts, so they are never auto-granted). The defaults are `Epic`/`Milestone` 2880, `Feature`/`Story` 480, `Task`/`Plan` 120, `Issue`/`Bug` 60, `Chore` 30, `Decision` 15, and a 120-minute fallback for any other type; override them per type with the `validation.estimate_defaults_by_type` setting (a `{ "": }` map). `--fix-scope` is an exact allowlist of what `--auto-fix` may mutate (`metadata`, `resolution`, `estimates`, `lifecycle`; comma-separated or repeatable) — `--fix-scope estimates` alone applies _only_ estimate fixes; omitting the flag grants the safe field-backfill scopes (`metadata`, `resolution`) and neither estimates nor lifecycle. `--dry-run` previews the full plan without mutating anything. With `--auto-fix` and no explicit `--check-*` flags, only the fix-capable checks (metadata, resolution, lifecycle) run. The `checks` in the output always describe the pre-fix state; re-run `pm validate` to confirm convergence. > Note: acceptance-criteria gaps are intentionally **not** auto-fixed. Unlike resolution/close_reason (derivable from the item's own fields) or estimates (a config-driven type default), acceptance criteria have no deterministic source — synthesizing them from the description would fabricate content rather than derive it, violating the auto-fix safety invariant. They remain `--fix-hints`-only. @@ -658,7 +667,7 @@ pm validate --check-metadata --json | jq -r '.checks[] | select(.name=="metadata `pm validate --check-files` classifies every stale linked path in `details.missing_linked_path_classifications` as either `moved` (a file with the same basename still exists in the scan — the row carries the top relink candidate, e.g. `old/path.md:moved:new/path.md`) or `deleted` (no candidate anywhere, e.g. `old/path.md:deleted`). It also reports `details.missing_linked_path_rows` — owner attribution so cleanup is evidence-based without a reverse lookup. By default these are token-efficient one-liners (`: owner= status= field= title="…"`); `--verbose-file-lists` expands them to the full structured shape (`{ path, classification, items: [{ id, type, title, status, field }] }`). Orphaned existing files get the same treatment through `details.orphaned_path_classifications` and `details.orphaned_path_rows`; classifications start with `docs_unowned`, `tests_unowned`, `source_unowned`, or `unlinked_existing`, and rows include a concrete `pm docs|files --add ...` hint when a likely owner is found from nearby linked paths. `--prune-missing` bulk-removes the stale links classified `deleted` from their items (link removal only — real files are never touched; `moved` links are kept so their relink candidates are not lost) and reports each removal in `fixes.applied_fixes[]` as the equivalent `pm files --remove ` / `pm docs --remove ` command. It honors `--dry-run` and implies `--check-files`. -The default `pm validate` run also includes a `format_version` check that compares every item's stored item metadata format version (`pm_format_version`, absent meaning the implicit baseline version `1`) against the version this pm runtime writes. `details.outdated_items` lists items that predate the current version (a future storage migration would rewrite them — advisory `warn`); `details.ahead_items` lists items written by a *newer* pm than the one running, which is an `error` because validation cannot vouch for fields it does not understand (upgrade pm to read them safely). At the current baseline version both lists are empty and the check is `ok`. The same `pm_format_version` integrity is surfaced by `pm health` under the `integrity` check (`details.counts.item_outdated_format_version` / `item_ahead_format_version`). +The default `pm validate` run also includes a `format_version` check that compares every item's stored item metadata format version (`pm_format_version`, absent meaning the implicit baseline version `1`) against the version this pm runtime writes. `details.outdated_items` lists items that predate the current version (a future storage migration would rewrite them — advisory `warn`); `details.ahead_items` lists items written by a _newer_ pm than the one running, which is an `error` because validation cannot vouch for fields it does not understand (upgrade pm to read them safely). At the current baseline version both lists are empty and the check is `ok`. The same `pm_format_version` integrity is surfaced by `pm health` under the `integrity` check (`details.counts.item_outdated_format_version` / `item_ahead_format_version`). ### Telemetry Local Analytics @@ -739,6 +748,7 @@ pm stats --field-utilization --json pm stats --by-assignee --by-priority pm stats --by-tag --tag-prefix domain: --json ``` + For governance dashboards, `--metadata-coverage` adds a `metadata_coverage` block reporting per-field `present`/`applicable`/`percent` for `acceptance_criteria`, `estimated_minutes`, `resolution`, `tags`, and `parent` — overall and `by_type` (resolution coverage is scoped to terminal items, its only applicable population). `--field-utilization` adds a `field_utilization` block reporting `present`/`total`/`percent` for each content field (`notes`, `learnings`, `files`, `docs`, `tests`, `comments`, `deps`, `body`, `linked_command`) across all items, so under-documented content dimensions are visible at a glance and pair naturally with the `--has-*`/`--no-*` list filters for drill-down. `--by-assignee`, `--by-tag`, and `--by-priority` add a `breakdowns` block with lifecycle-bucketed rows (`open`/`in_progress`/`blocked`/`draft`/`closed`/`canceled`/`other` + `total`) per group; blank keys render an explicit `(unassigned)`/`(untagged)` label. `--by-tag` accepts `--tag-prefix` to restrict counting to a tag namespace (for example `domain:`). All of these sections are gated behind their flags so the default `pm stats` stays token-light; the per-status/per-type distributions (already in `by_status`/`by_type`) zero-fill every configured state so underutilized lifecycle states and item types are visible at a glance. `history-redact` rewrites matching history payloads deterministically, recomputes hash chains, and appends an auditable `history_redact` marker entry when changes are applied. `history-compact` rewrites long streams into a synthetic checkpoint baseline plus a retained tail (`--before` accepts a 1-based version or ISO timestamp), re-anchors hashes, verifies integrity, and appends an auditable `history_compact` marker when applied. @@ -849,23 +859,23 @@ pm plan show --fields id,title,steps_summary Subcommand cheatsheet: -| Subcommand | Purpose | -|------------|---------| -| `create` | Create a Plan item with scope, harness, parent, related, blocked-by, and optional auto-claim | -| `show` | Progressive-disclosure read (brief / standard / deep) with current step + next-action hints | -| `add-step` | Append a step with title/body/owner/status/dependencies/files/tests/docs | -| `update-step` | Patch one step (title, body, status, evidence, owner, blocked reason) | -| `complete-step` | Shortcut for setting a step to `completed` with evidence | -| `block-step` | Shortcut for setting a step to `blocked` with required `--step-blocked-reason` | -| `reorder-step` | Move a step to a new 1-based order | -| `remove-step` | Drop a step and renumber remaining steps | -| `link` / `unlink` | Add or remove `linked_items` on a step (with optional `--promote-to-item-dep`) | -| `decision` | Append a decision log entry (decision/rationale/evidence) | -| `discovery` | Append a discovery log entry | -| `validation` | Append a validation check (text/command/expected) | -| `resume` | Replace the resume-context summary for stateless agents | -| `approve` | Move `plan_mode` to `approved` (default) or any other mode via `--mode` | -| `materialize` | Create real pm items (default `Task`) from selected steps with bidirectional links | +| Subcommand | Purpose | +| ----------------- | -------------------------------------------------------------------------------------------- | +| `create` | Create a Plan item with scope, harness, parent, related, blocked-by, and optional auto-claim | +| `show` | Progressive-disclosure read (brief / standard / deep) with current step + next-action hints | +| `add-step` | Append a step with title/body/owner/status/dependencies/files/tests/docs | +| `update-step` | Patch one step (title, body, status, evidence, owner, blocked reason) | +| `complete-step` | Shortcut for setting a step to `completed` with evidence | +| `block-step` | Shortcut for setting a step to `blocked` with required `--step-blocked-reason` | +| `reorder-step` | Move a step to a new 1-based order | +| `remove-step` | Drop a step and renumber remaining steps | +| `link` / `unlink` | Add or remove `linked_items` on a step (with optional `--promote-to-item-dep`) | +| `decision` | Append a decision log entry (decision/rationale/evidence) | +| `discovery` | Append a discovery log entry | +| `validation` | Append a validation check (text/command/expected) | +| `resume` | Replace the resume-context summary for stateless agents | +| `approve` | Move `plan_mode` to `approved` (default) or any other mode via `--mode` | +| `materialize` | Create real pm items (default `Task`) from selected steps with bidirectional links | Invariants: diff --git a/docs/SDK.md b/docs/SDK.md index 428c0d027..54a68d132 100644 --- a/docs/SDK.md +++ b/docs/SDK.md @@ -38,6 +38,7 @@ Source of truth: - [`src/sdk/files.ts`](../src/sdk/files.ts) - [`src/sdk/docs.ts`](../src/sdk/docs.ts) - [`src/sdk/dependencies.ts`](../src/sdk/dependencies.ts) +- [`src/sdk/schema.ts`](../src/sdk/schema.ts) - [`src/sdk/relationships.ts`](../src/sdk/relationships.ts) - [`src/sdk/relationship-history.ts`](../src/sdk/relationship-history.ts) - [`src/sdk/relationship-context.ts`](../src/sdk/relationship-context.ts) @@ -755,6 +756,11 @@ tools. `pm.init` stages a tracker, `pm.config` reads/writes settings, methods list, inspect, apply, and lint project archetypes. These helpers let a package or app construct an opinionated project-management experience while staying on the same schema/profile primitives the CLI and MCP use. +The schema mutation and inspection engine is owned by `src/sdk/schema.ts`; the +CLI schema module is a presentation-compatible re-export. Embedded runtimes can +therefore compose schema operations without importing CLI code, while CLI, MCP, +and `PmClient` continue to share the same validation, locking, persistence, and +result contracts. `pm.init` accepts `workspace` to initialize `/.agents/pm`; path-target calls retain tracker-root semantics. `InitResult.target` exposes the resolved mode, tracker root, and optional workspace root, while every explicit-target @@ -1192,7 +1198,13 @@ same list/default semantics as core flags: ```ts api.registerFlags("report", [ - { long: "--scope", short: "-s", value_type: "string", list: true, default: "all" }, + { + long: "--scope", + short: "-s", + value_type: "string", + list: true, + default: "all", + }, { long: "--limit", value_type: "number", default: 20 }, ]); ``` diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ef9166dcb..0ea6e673b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -167,17 +167,6 @@ "count": 2 } }, - "src/cli/commands/schema.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "no-useless-assignment": { - "count": 1 - }, - "sonarjs/cognitive-complexity": { - "count": 2 - } - }, "src/cli/commands/shared-unset-fields.ts": { "complexity": { "count": 1 diff --git a/scripts/release/sdk-import-boundary-baseline.json b/scripts/release/sdk-import-boundary-baseline.json index 544a1a17c..6200cf4be 100644 --- a/scripts/release/sdk-import-boundary-baseline.json +++ b/scripts/release/sdk-import-boundary-baseline.json @@ -682,28 +682,6 @@ "source": "src/cli/commands/scheduling-shortcuts.ts", "imports": ["src/core/shared/command-types.ts"] }, - { - "source": "src/cli/commands/schema.ts", - "imports": [ - "src/core/extensions/index.ts", - "src/core/fs/fs-utils.ts", - "src/core/item/type-registry.ts", - "src/core/lock/lock.ts", - "src/core/schema/fields-file.ts", - "src/core/schema/item-types-file.ts", - "src/core/schema/runtime-schema.ts", - "src/core/schema/status-defs-file.ts", - "src/core/schema/type-inference.ts", - "src/core/schema/type-presets.ts", - "src/core/shared/command-types.ts", - "src/core/shared/constants.ts", - "src/core/shared/errors.ts", - "src/core/shared/time.ts", - "src/core/store/item-store.ts", - "src/core/store/paths.ts", - "src/core/store/settings.ts" - ] - }, { "source": "src/cli/commands/search.ts", "imports": [ diff --git a/scripts/release/static-quality-gate.mjs b/scripts/release/static-quality-gate.mjs index 8c159bb64..e40bde092 100644 --- a/scripts/release/static-quality-gate.mjs +++ b/scripts/release/static-quality-gate.mjs @@ -1069,7 +1069,7 @@ export function checkCodeFactorComplexity(maxComplexity, changedPaths = collectC // gate and its pre-existing violations are grandfathered in the same change // (as with sonarjs/cognitive-complexity <= 16, re-baselined 104 -> 180; the // burn-down of that slice is tracked on the pm-92if epic). -export const MAX_ESLINT_SUPPRESSIONS = 174; +export const MAX_ESLINT_SUPPRESSIONS = 170; function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/src/cli/commands/completion.ts b/src/cli/commands/completion.ts index e6520cffd..be967d073 100644 --- a/src/cli/commands/completion.ts +++ b/src/cli/commands/completion.ts @@ -1597,6 +1597,9 @@ ${zshSearchRuntimeFieldFlags} '--json[Output JSON]' \\ ;; init) _arguments \\ + '(1)--id-prefix[Set the item ID prefix]:prefix' \\ + '(1)--prefix[Alias for --id-prefix]:prefix' \\ + '1:optional id prefix or tracker path' \\ '--preset[Governance preset for new setups]:preset:(minimal default strict)' \\ '--defaults[Use non-interactive setup defaults]' \\ '-y[Alias for --defaults]' \\ @@ -2626,6 +2629,8 @@ complete -c pm -n '__fish_seen_subcommand_from validate' -l prune-missing -d 'Re complete -c pm -n '__fish_seen_subcommand_from validate' -l check-history-drift -d 'Run item/history hash drift checks' complete -c pm -n '__fish_seen_subcommand_from validate' -l check-command-references -d 'Run linked-command PM-ID reference checks' complete -c pm -n '__fish_seen_subcommand_from init' -l preset -d 'Governance preset for new setups' -r -a 'minimal default strict' +complete -c pm -n '__fish_seen_subcommand_from init' -l id-prefix -d 'Set the item ID prefix' -r +complete -c pm -n '__fish_seen_subcommand_from init' -l prefix -d 'Alias for --id-prefix' -r complete -c pm -n '__fish_seen_subcommand_from init' -l defaults -d 'Use non-interactive setup defaults' complete -c pm -n '__fish_seen_subcommand_from init' -s y -l yes -d 'Alias for --defaults' complete -c pm -n '__fish_seen_subcommand_from init' -l author -d 'Set the default mutation author for this project' -r diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 404982bf5..eb697502c 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -129,6 +129,8 @@ export interface InitResult { /** Documents the init command options payload exchanged by command, SDK, and package integrations. */ export interface InitCommandOptions { + /** Item-id prefix supplied through the flag-oriented SDK/CLI input surface. */ + idPrefix?: string; /** Value that configures or reports preset for this contract. */ preset?: string; /** Value that configures or reports defaults for this contract. */ @@ -223,6 +225,7 @@ export const _testOnly = { normalizeInitAgentGuidanceMode, isPathLikeInitTarget, resolveInitInvocation, + resolveInitPrefixInput, parseYesNoChoice, applyGovernancePreset, runInitWizard, @@ -404,6 +407,38 @@ function resolveInitInvocation( }; } +/** Resolves the optional positional and flag-oriented id-prefix inputs, accepting equivalent normalized spellings and rejecting ambiguous conflicts. */ +function resolveInitPrefixInput( + positionalPrefix: string | undefined, + flaggedPrefix: string | undefined, +): string | undefined { + if (flaggedPrefix === undefined) { + return positionalPrefix; + } + if (flaggedPrefix.trim().length === 0) { + throw new PmCliError("--id-prefix must not be empty", EXIT_CODE.USAGE); + } + if ( + positionalPrefix !== undefined && + normalizePrefix(positionalPrefix) !== normalizePrefix(flaggedPrefix) + ) { + throw new PmCliError( + `Conflicting id prefixes: positional "${positionalPrefix}" and --id-prefix "${flaggedPrefix}".`, + EXIT_CODE.USAGE, + { + code: "init_id_prefix_conflict", + required: + "Use either the positional id prefix or --id-prefix with one value.", + examples: [ + `pm init ${normalizePrefix(positionalPrefix)}`, + `pm init --id-prefix ${normalizePrefix(flaggedPrefix)}`, + ], + }, + ); + } + return flaggedPrefix; +} + function normalizeInitAgentGuidanceMode( rawValue: string | undefined, ): InitAgentGuidanceMode { @@ -997,7 +1032,10 @@ function resolveInitWorkspaceRoot( ): string | undefined { const trackerRoot = path.resolve(target.tracker_root); const agentsRoot = path.dirname(trackerRoot); - if (path.basename(trackerRoot) === "pm" && path.basename(agentsRoot) === ".agents") { + if ( + path.basename(trackerRoot) === "pm" && + path.basename(agentsRoot) === ".agents" + ) { return path.dirname(agentsRoot); } return target.workspace_root; @@ -1098,7 +1136,7 @@ export async function runInit( options.workspace, ); const pmRoot = invocation.pmRoot; - prefixArg = invocation.prefixArg; + prefixArg = resolveInitPrefixInput(invocation.prefixArg, options.idPrefix); await assertExplicitTrackerPathIsNotWorkspaceRoot( pmRoot, invocation.target.mode === "tracker-path", diff --git a/src/cli/commands/schema.ts b/src/cli/commands/schema.ts index 0eda124de..4206aec48 100644 --- a/src/cli/commands/schema.ts +++ b/src/cli/commands/schema.ts @@ -1,2325 +1,7 @@ /** * @module cli/commands/schema * - * Implements the pm schema command surface and its agent-facing runtime behavior. + * Preserves the CLI command-module contract while schema behavior, result + * types, and rendering helpers are owned by the public SDK module. */ -import path from "node:path"; -import { mkdir } from "node:fs/promises"; -import { - pathExists, - readFileIfExists, - writeFileAtomic, -} from "../../core/fs/fs-utils.js"; -import { acquireLock } from "../../core/lock/lock.js"; -import { - assertAliasesAvailable, - assertTypeFolderAvailable, - buildInvalidTypeHint, - escapeForDoubleQuotes, - normalizeAddTypeInput, - parseItemTypesFile, - removeItemType, - serializeItemTypesFile, - upsertItemType, - type ItemTypeDefinition, -} from "../../core/schema/item-types-file.js"; -import { - assertStatusTokensAvailable, - BUILTIN_STATUS_IDS, - normalizeAddStatusInput, - normalizeStatusToken, - parseStatusDefsFile, - removeStatusDef, - serializeStatusDefsFile, - upsertStatusDef, - type RuntimeStatusDefinition, -} from "../../core/schema/status-defs-file.js"; -import { - BUILTIN_FIELD_KEYS, - normalizeAddFieldInput, - normalizeFieldKey, - parseFieldsFile, - removeField, - serializeFieldsFile, - upsertField, - type RuntimeFieldDefinition, -} from "../../core/schema/fields-file.js"; -import { - normalizeTypePresetName, - resolveTypePresetDefinitions, - TYPE_PRESET_NAMES, - type TypePresetName, -} from "../../core/schema/type-presets.js"; -import { - inferTypesFromTitles, - type InferredTypeCandidate, -} from "../../core/schema/type-inference.js"; -import { - DEFAULT_RUNTIME_SCHEMA_FILE_PATHS, - filePathForSchemaSection, - normalizeRuntimeSchemaSettings, - resolveRuntimeFieldRegistry, - resolveRuntimeStatusRegistry, -} from "../../core/schema/runtime-schema.js"; -import { - resolveItemTypeRegistry, - toDefaultFolder, - type ResolvedItemTypeDefinition, -} from "../../core/item/type-registry.js"; -import { listAllItemMetadataLight } from "../../core/store/item-store.js"; -import { EXIT_CODE } from "../../core/shared/constants.js"; -import type { GlobalOptions } from "../../core/shared/command-types.js"; -import type { PmSettings } from "../../types/index.js"; -import { PmCliError } from "../../core/shared/errors.js"; -import { nowIso } from "../../core/shared/time.js"; -import { - getActiveExtensionRegistrations, - runActiveOnWriteHooks, -} from "../../core/extensions/index.js"; -import { getSettingsPath, resolvePmRoot } from "../../core/store/paths.js"; -import { - readSettings, - resolveGovernanceKnobs, -} from "../../core/store/settings.js"; - -/** Public contract for schema subcommands, shared by SDK and presentation-layer consumers. */ -export const SCHEMA_SUBCOMMANDS = [ - "add-type", - "remove-type", - "add-status", - "remove-status", - "add-field", - "remove-field", - "list-fields", - "show-field", - "apply-preset", - "list", - "show", - "show-status", -] as const; -/** Restricts schema subcommand values accepted by command, SDK, and storage contracts. */ -export type SchemaSubcommand = (typeof SCHEMA_SUBCOMMANDS)[number]; - -const SCHEMA_TYPES_LOCK_ID = "schema-types"; -const SCHEMA_STATUSES_LOCK_ID = "schema-statuses"; -const SCHEMA_FIELDS_LOCK_ID = "schema-fields"; - -/** Documents the schema add type command options payload exchanged by command, SDK, and package integrations. */ -export interface SchemaAddTypeCommandOptions { - /** Value that configures or reports description for this contract. */ - description?: string; - /** Lifecycle state reported for defaultthe record. */ - defaultStatus?: string; - /** Value that configures or reports folder for this contract. */ - folder?: string; - /** Value that configures or reports alias for this contract. */ - alias?: string[]; - /** Value that configures or reports author for this contract. */ - author?: string; - /** Value that configures or reports force for this contract. */ - force?: boolean; -} - -/** Documents the schema remove type command options payload exchanged by command, SDK, and package integrations. */ -export interface SchemaRemoveTypeCommandOptions { - /** Value that configures or reports author for this contract. */ - author?: string; - /** Value that configures or reports force for this contract. */ - force?: boolean; -} - -/** Documents the schema add status command options payload exchanged by command, SDK, and package integrations. */ -export interface SchemaAddStatusCommandOptions { - /** Value that configures or reports role for this contract. */ - role?: string[]; - /** Value that configures or reports alias for this contract. */ - alias?: string[]; - /** Value that configures or reports description for this contract. */ - description?: string; - /** Value that configures or reports order for this contract. */ - order?: number; - /** Value that configures or reports author for this contract. */ - author?: string; - /** Value that configures or reports force for this contract. */ - force?: boolean; -} - -/** Documents the schema remove status command options payload exchanged by command, SDK, and package integrations. */ -export interface SchemaRemoveStatusCommandOptions { - /** Value that configures or reports author for this contract. */ - author?: string; - /** Value that configures or reports force for this contract. */ - force?: boolean; -} - -/** Documents the schema add type result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaAddTypeResult { - /** Value that configures or reports action for this contract. */ - action: "add-type"; - /** Value that configures or reports registered for this contract. */ - registered: boolean; - /** Value that configures or reports replaced for this contract. */ - replaced: boolean; - /** Schema type that determines the shape and validation rules for this value. */ - type: ItemTypeDefinition; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - definitions: number; - }; - /** Value that configures or reports warnings for this contract. */ - warnings: string[]; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Documents the schema remove type result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaRemoveTypeResult { - /** Value that configures or reports action for this contract. */ - action: "remove-type"; - /** Value that configures or reports removed for this contract. */ - removed: boolean; - /** Schema type that determines the shape and validation rules for this value. */ - type?: ItemTypeDefinition; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - definitions: number; - }; - /** Value that configures or reports warnings for this contract. */ - warnings: string[]; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Documents the schema add status result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaAddStatusResult { - /** Value that configures or reports action for this contract. */ - action: "add-status"; - /** Value that configures or reports registered for this contract. */ - registered: boolean; - /** Value that configures or reports replaced for this contract. */ - replaced: boolean; - /** Lifecycle state reported for status. */ - status: RuntimeStatusDefinition; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - statuses: number; - }; - /** Value that configures or reports warnings for this contract. */ - warnings: string[]; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Documents the schema remove status result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaRemoveStatusResult { - /** Value that configures or reports action for this contract. */ - action: "remove-status"; - /** Value that configures or reports removed for this contract. */ - removed: boolean; - /** Lifecycle state reported for status. */ - status?: RuntimeStatusDefinition; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - statuses: number; - }; - /** Value that configures or reports warnings for this contract. */ - warnings: string[]; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Documents the schema add field command options payload exchanged by command, SDK, and package integrations. */ -export interface SchemaAddFieldCommandOptions { - /** Schema type that determines the shape and validation rules for this value. */ - type?: string; - /** Value that configures or reports commands for this contract. */ - commands?: string[]; - /** Value that configures or reports description for this contract. */ - description?: string; - /** Value that configures or reports cli flag for this contract. */ - cliFlag?: string; - /** Value that configures or reports alias for this contract. */ - alias?: string[]; - /** Value that configures or reports required for this contract. */ - required?: boolean; - /** Value that configures or reports required on create for this contract. */ - requiredOnCreate?: boolean; - /** Value that configures or reports allow unset for this contract. */ - allowUnset?: boolean; - /** Value that configures or reports required types for this contract. */ - requiredTypes?: string[]; - /** Value that configures or reports author for this contract. */ - author?: string; - /** Value that configures or reports force for this contract. */ - force?: boolean; -} - -/** Documents the schema remove field command options payload exchanged by command, SDK, and package integrations. */ -export interface SchemaRemoveFieldCommandOptions { - /** Value that configures or reports author for this contract. */ - author?: string; - /** Value that configures or reports force for this contract. */ - force?: boolean; -} - -/** Documents the schema apply preset command options payload exchanged by command, SDK, and package integrations. */ -export interface SchemaApplyPresetCommandOptions { - /** Value that configures or reports author for this contract. */ - author?: string; - /** Value that configures or reports force for this contract. */ - force?: boolean; -} - -/** Documents the schema add type infer command options payload exchanged by command, SDK, and package integrations. */ -export interface SchemaAddTypeInferCommandOptions { - /** Number of min entries represented by this result. */ - minCount?: number; - /** Value that configures or reports apply for this contract. */ - apply?: boolean; - /** Value that configures or reports author for this contract. */ - author?: string; - /** Value that configures or reports force for this contract. */ - force?: boolean; -} - -/** Documents the schema field summary payload exchanged by command, SDK, and package integrations. */ -export interface SchemaFieldSummary { - /** Value that configures or reports key for this contract. */ - key: string; - /** Schema type that determines the shape and validation rules for this value. */ - type: string; - /** Value that configures or reports commands for this contract. */ - commands: string[]; - /** Value that configures or reports cli flag for this contract. */ - cli_flag: string; - /** Value that configures or reports cli aliases for this contract. */ - cli_aliases: string[]; - /** Value that configures or reports description for this contract. */ - description?: string; - /** Value that configures or reports required for this contract. */ - required: boolean; - /** Value that configures or reports required on create for this contract. */ - required_on_create: boolean; - /** Value that configures or reports allow unset for this contract. */ - allow_unset: boolean; - /** Value that configures or reports required types for this contract. */ - required_types: string[]; -} - -/** Documents the schema add field result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaAddFieldResult { - /** Value that configures or reports action for this contract. */ - action: "add-field"; - /** Value that configures or reports registered for this contract. */ - registered: boolean; - /** Value that configures or reports replaced for this contract. */ - replaced: boolean; - /** Value that configures or reports field for this contract. */ - field: RuntimeFieldDefinition; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - fields: number; - }; - /** Value that configures or reports warnings for this contract. */ - warnings: string[]; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Documents the schema remove field result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaRemoveFieldResult { - /** Value that configures or reports action for this contract. */ - action: "remove-field"; - /** Value that configures or reports removed for this contract. */ - removed: boolean; - /** Value that configures or reports field for this contract. */ - field?: RuntimeFieldDefinition; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - fields: number; - }; - /** Value that configures or reports warnings for this contract. */ - warnings: string[]; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Documents the schema list fields result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaListFieldsResult { - /** Value that configures or reports action for this contract. */ - action: "list-fields"; - /** Value that configures or reports fields for this contract. */ - fields: SchemaFieldSummary[]; - /** Value that configures or reports counts for this contract. */ - counts: { - total: number; - }; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - }; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Documents the schema show field result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaShowFieldResult { - /** Value that configures or reports action for this contract. */ - action: "show-field"; - /** Value that configures or reports field for this contract. */ - field: SchemaFieldSummary; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - }; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Documents the schema apply preset result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaApplyPresetResult { - /** Value that configures or reports action for this contract. */ - action: "apply-preset"; - /** Value that configures or reports preset for this contract. */ - preset: TypePresetName; - /** Value that configures or reports registered for this contract. */ - registered: string[]; - /** Value that configures or reports replaced for this contract. */ - replaced: string[]; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - definitions: number; - }; - /** Value that configures or reports warnings for this contract. */ - warnings: string[]; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Documents the schema add type infer result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaAddTypeInferResult { - /** Value that configures or reports action for this contract. */ - action: "infer-types"; - /** Value that configures or reports applied for this contract. */ - applied: boolean; - /** Number of min entries represented by this result. */ - min_count: number; - /** Value that configures or reports candidates for this contract. */ - candidates: InferredTypeCandidate[]; - /** Value that configures or reports registered for this contract. */ - registered: string[]; - /** Value that configures or reports replaced for this contract. */ - replaced: string[]; - /** Value that configures or reports skipped for this contract. */ - skipped: Array<{ name: string; reason: string }>; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - definitions: number; - }; - /** Value that configures or reports warnings for this contract. */ - warnings: string[]; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Documents the schema type summary payload exchanged by command, SDK, and package integrations. */ -export interface SchemaTypeSummary { - /** Value that configures or reports name for this contract. */ - name: string; - /** Value that configures or reports folder for this contract. */ - folder: string; - /** Value that configures or reports aliases for this contract. */ - aliases: string[]; - /** Lifecycle state reported for defaultthe record. */ - default_status?: string; - /** Value that configures or reports description for this contract. */ - description?: string; -} - -/** Documents the schema status summary payload exchanged by command, SDK, and package integrations. */ -export interface SchemaStatusSummary { - /** Stable identifier used to reference this record across commands and storage. */ - id: string; - /** Value that configures or reports source for this contract. */ - source: "builtin" | "custom"; - /** Value that configures or reports roles for this contract. */ - roles: string[]; - /** Value that configures or reports aliases for this contract. */ - aliases: string[]; - /** Value that configures or reports description for this contract. */ - description?: string; - /** Value that configures or reports order for this contract. */ - order?: number; -} - -/** Documents the schema type definition result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaTypeDefinitionResult extends SchemaTypeSummary { - /** Value that configures or reports source for this contract. */ - source: "builtin" | "custom" | "extension"; - /** Value that configures or reports extension for this contract. */ - extension?: { - layer: string; - name: string; - }; - /** Value that configures or reports required create fields for this contract. */ - required_create_fields: string[]; - /** Value that configures or reports required create repeatables for this contract. */ - required_create_repeatables: string[]; - /** Value that configures or reports options for this contract. */ - options: ResolvedItemTypeDefinition["options"]; - /** Value that configures or reports command option policies for this contract. */ - command_option_policies: ResolvedItemTypeDefinition["command_option_policies"]; -} - -/** Documents the schema list result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaListResult { - /** Value that configures or reports action for this contract. */ - action: "list"; - /** Value that configures or reports builtin for this contract. */ - builtin: SchemaTypeSummary[]; - /** Value that configures or reports custom for this contract. */ - custom: SchemaTypeSummary[]; - /** Value that configures or reports extension for this contract. */ - extension: SchemaTypeSummary[]; - /** Value that configures or reports counts for this contract. */ - counts: { - builtin: number; - custom: number; - extension: number; - total: number; - }; - /** Value that configures or reports statuses for this contract. */ - statuses: { - builtin: SchemaStatusSummary[]; - custom: SchemaStatusSummary[]; - counts: { - builtin: number; - custom: number; - total: number; - }; - }; - /** Value that configures or reports fields for this contract. */ - fields: { - custom: SchemaFieldSummary[]; - counts: { - total: number; - }; - }; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - }; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Documents the schema show result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaShowResult { - /** Value that configures or reports action for this contract. */ - action: "show"; - /** Schema type that determines the shape and validation rules for this value. */ - type: SchemaTypeDefinitionResult; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - }; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Documents the schema show status result payload exchanged by command, SDK, and package integrations. */ -export interface SchemaShowStatusResult { - /** Value that configures or reports action for this contract. */ - action: "show-status"; - /** Lifecycle state reported for status. */ - status: SchemaStatusSummary; - /** Value that configures or reports file for this contract. */ - file: { - path: string; - }; - /** ISO 8601 timestamp recording when generated occurred. */ - generated_at: string; -} - -/** Restricts schema inspect result values accepted by command, SDK, and storage contracts. */ -export type SchemaInspectResult = - | SchemaListResult - | SchemaShowResult - | SchemaShowStatusResult - | SchemaListFieldsResult - | SchemaShowFieldResult; - -function toAuthor( - candidate: string | undefined, - defaultAuthor: string, -): string { - const resolved = candidate ?? process.env.PM_AUTHOR ?? defaultAuthor; - const trimmed = resolved.trim(); - return trimmed.length > 0 ? trimmed : "unknown"; -} - -/* c8 ignore start -- schema command mutation/inspection branches are covered by broader schema integration workflows. */ -/** Implements run schema add type for the public runtime surface of this module. */ -export async function runSchemaAddType( - name: string | undefined, - options: SchemaAddTypeCommandOptions, - global: GlobalOptions, -): Promise { - const pmRoot = resolvePmRoot(process.cwd(), global.path); - if (!(await pathExists(getSettingsPath(pmRoot)))) { - throw new PmCliError( - `Tracker is not initialized at ${pmRoot}. Run pm init first.`, - EXIT_CODE.NOT_FOUND, - ); - } - - let normalized; - try { - normalized = normalizeAddTypeInput({ - name, - description: options.description, - defaultStatus: options.defaultStatus, - folder: options.folder, - aliases: options.alias, - }); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.USAGE, - ); - } - - const settings = await readSettings(pmRoot); - const schema = normalizeRuntimeSchemaSettings(settings.schema); - const typesPath = filePathForSchemaSection( - pmRoot, - schema.files.types, - DEFAULT_RUNTIME_SCHEMA_FILE_PATHS.types, - ); - - const warnings: string[] = []; - const author = toAuthor(options.author, settings.author_default); - const governance = resolveGovernanceKnobs(settings); - - const releaseLock = await acquireLock( - pmRoot, - SCHEMA_TYPES_LOCK_ID, - settings.locks.ttl_seconds, - author, - Boolean(options.force), - governance.force_required_for_stale_lock, - settings.locks.wait_ms, - ); - let upsert; - try { - const previousRaw = await readFileIfExists(typesPath); - let parsed; - try { - parsed = parseItemTypesFile(previousRaw); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.GENERIC_FAILURE, - ); - } - try { - assertAliasesAvailable(normalized, parsed); - // GH-248: reject distinct type names that would slug to the same storage - // folder (e.g. "Spike"/"Spikes") so item files never silently share a - // directory. Same-named upserts (recase/idempotent re-runs) are exempt. - // reservedFolders covers built-in + extension + settings-resolved custom - // folders so a slug like "Tasks" can't collide with the built-in Task. - const reservedFolders = new Map(); - const resolvedRegistry = resolveItemTypeRegistry( - settings, - getActiveExtensionRegistrations(), - ); - for (const [typeName, folder] of Object.entries( - resolvedRegistry.type_to_folder, - )) { - const folderKey = folder.trim().toLowerCase(); - if (folderKey.length > 0 && !reservedFolders.has(folderKey)) { - reservedFolders.set(folderKey, typeName); - } - } - assertTypeFolderAvailable(normalized, parsed, reservedFolders); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.USAGE, - ); - } - upsert = upsertItemType(parsed, normalized); - // GH-248: an upsert that only differs in case from the existing canonical - // name silently renames the stored type (e.g. registering "spike" over - // "Spike"). Surface it as a non-blocking warning so the operator knows the - // existing definition was replaced rather than a new type created. - if ( - upsert.replaced && - upsert.previousName !== undefined && - upsert.previousName !== upsert.definition.name - ) { - warnings.push( - `type_recased:${upsert.previousName}->${upsert.definition.name}`, - ); - } - // writeFileAtomic writes to a temp file then renames, so a failure leaves the - // existing types.json untouched; no manual rollback is needed. - await writeFileAtomic(typesPath, serializeItemTypesFile(upsert.file)); - await ensureTypeFolderScaffold( - pmRoot, - [upsert.definition], - warnings, - "schema:add-type-folder", - ); - warnings.push( - ...(await runActiveOnWriteHooks({ - path: typesPath, - scope: "project", - op: "schema:add-type", - })), - ); - } finally { - await releaseLock(); - } - - return { - action: "add-type", - registered: true, - replaced: upsert.replaced, - type: upsert.definition, - file: { - path: typesPath, - definitions: upsert.file.definitions.length, - }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), - generated_at: nowIso(), - }; -} - -async function ensureInitialized(pmRoot: string): Promise { - if (!(await pathExists(getSettingsPath(pmRoot)))) { - throw new PmCliError( - `Tracker is not initialized at ${pmRoot}. Run pm init first.`, - EXIT_CODE.NOT_FOUND, - ); - } -} - -/** Counts items whose resolved type matches `typeName` (case-insensitive). Uses the lightest existing read path (listAllItemMetadataLight skips the heavy collections cache). All items are counted — not just open ones — so the advisory warning surfaces every item the removed definition would orphan; the count is non-blocking. The caller passes its already-loaded `settings` so we never re-read settings.json from disk here. */ -async function countItemsUsingType( - pmRoot: string, - settings: PmSettings, - schema: ReturnType, - typeName: string, -): Promise { - const registry = resolveItemTypeRegistry( - settings, - getActiveExtensionRegistrations(), - ); - const lowerName = typeName.trim().toLowerCase(); - const items = await listAllItemMetadataLight( - pmRoot, - settings.item_format, - registry.type_to_folder, - [], - schema, - ); - return items.filter( - (item) => - typeof item.type === "string" && item.type.toLowerCase() === lowerName, - ).length; -} - -/** Counts items currently set to the status whose id/aliases resolve to `statusId`. Uses listAllItemMetadataLight (the lightest read path). All items are counted regardless of lifecycle phase; the count is advisory only. The caller passes its already-loaded `settings` so we never re-read from disk. */ -async function countItemsUsingStatus( - pmRoot: string, - settings: PmSettings, - schema: ReturnType, - statusId: string, -): Promise { - const registry = resolveItemTypeRegistry( - settings, - getActiveExtensionRegistrations(), - ); - const statusRegistry = resolveRuntimeStatusRegistry(schema); - const normalizedId = normalizeStatusToken(statusId); - const items = await listAllItemMetadataLight( - pmRoot, - settings.item_format, - registry.type_to_folder, - [], - schema, - ); - return items.filter((item) => { - const itemStatus = typeof item.status === "string" ? item.status : ""; - const resolved = - statusRegistry.alias_to_id.get(normalizeStatusToken(itemStatus)) ?? - normalizeStatusToken(itemStatus); - return resolved === normalizedId; - }).length; -} - -/** Returns the workflow role-slot names (open_status, close_status, ...) that currently point at `statusId` (normalized). Used to warn before removing a status that a workflow default still references. */ -function workflowSlotsReferencing( - workflow: { - draft_status?: string; - open_status?: string; - in_progress_status?: string; - blocked_status?: string; - close_status?: string; - canceled_status?: string; - }, - statusId: string, -): string[] { - const slots: Array<[string, string | undefined]> = [ - ["draft_status", workflow.draft_status], - ["open_status", workflow.open_status], - ["in_progress_status", workflow.in_progress_status], - ["blocked_status", workflow.blocked_status], - ["close_status", workflow.close_status], - ["canceled_status", workflow.canceled_status], - ]; - return slots - .filter( - ([, value]) => - value !== undefined && normalizeStatusToken(value) === statusId, - ) - .map(([slot]) => slot); -} - -/** Implements run schema remove type for the public runtime surface of this module. */ -export async function runSchemaRemoveType( - name: string | undefined, - options: SchemaRemoveTypeCommandOptions, - global: GlobalOptions, -): Promise { - const pmRoot = resolvePmRoot(process.cwd(), global.path); - await ensureInitialized(pmRoot); - - const settings = await readSettings(pmRoot); - const schema = normalizeRuntimeSchemaSettings(settings.schema); - const typesPath = filePathForSchemaSection( - pmRoot, - schema.files.types, - DEFAULT_RUNTIME_SCHEMA_FILE_PATHS.types, - ); - - const warnings: string[] = []; - const author = toAuthor(options.author, settings.author_default); - const governance = resolveGovernanceKnobs(settings); - - const releaseLock = await acquireLock( - pmRoot, - SCHEMA_TYPES_LOCK_ID, - settings.locks.ttl_seconds, - author, - Boolean(options.force), - governance.force_required_for_stale_lock, - settings.locks.wait_ms, - ); - let removal; - try { - const previousRaw = await readFileIfExists(typesPath); - let parsed; - try { - parsed = parseItemTypesFile(previousRaw); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.GENERIC_FAILURE, - ); - } - try { - removal = removeItemType(parsed, name); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.USAGE, - ); - } - if (removal.removed) { - // Only emit the advisory orphan-count warning once a removable custom - // definition was actually removed; a no-op/unknown removal would otherwise - // surface a misleading items_using_type:* warning. The count is - // non-blocking and reuses the already-loaded settings (no disk re-read). - const removedName = (removal.definition?.name ?? name ?? "").trim(); - if (removedName.length > 0) { - const usingType = await countItemsUsingType( - pmRoot, - settings, - schema, - removedName, - ); - if (usingType > 0) { - warnings.push(`items_using_type:${usingType}`); - } - } - // writeFileAtomic writes to a temp file then renames, so a failure leaves - // the existing types.json untouched; no manual rollback is needed. - await writeFileAtomic(typesPath, serializeItemTypesFile(removal.file)); - warnings.push( - ...(await runActiveOnWriteHooks({ - path: typesPath, - scope: "project", - op: "schema:remove-type", - })), - ); - } - } finally { - await releaseLock(); - } - - return { - action: "remove-type", - removed: removal.removed, - ...(removal.definition ? { type: removal.definition } : {}), - file: { - path: typesPath, - definitions: removal.file.definitions.length, - }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), - generated_at: nowIso(), - }; -} - -function statusesPathFor( - pmRoot: string, - schema: ReturnType, -): string { - return filePathForSchemaSection( - pmRoot, - schema.files.statuses, - DEFAULT_RUNTIME_SCHEMA_FILE_PATHS.statuses, - ); -} - -function fieldsPathFor( - pmRoot: string, - schema: ReturnType, -): string { - return filePathForSchemaSection( - pmRoot, - schema.files.fields, - DEFAULT_RUNTIME_SCHEMA_FILE_PATHS.fields, - ); -} - -function typesPathFor( - pmRoot: string, - schema: ReturnType, -): string { - return filePathForSchemaSection( - pmRoot, - schema.files.types, - DEFAULT_RUNTIME_SCHEMA_FILE_PATHS.types, - ); -} - -/** Creates the storage folder for each item-type definition (resolving the default folder when unset) and runs on-write hooks for newly created folders. Shared with `pm profile apply` so profile-staged types scaffold identically to `pm schema add-type`. Existing folders are skipped, keeping the call idempotent. */ -export async function ensureTypeFolderScaffold( - pmRoot: string, - definitions: readonly ItemTypeDefinition[], - warnings: string[], - op: string, -): Promise { - for (const definition of definitions) { - const folder = definition.folder ?? toDefaultFolder(definition.name); - const target = path.join(pmRoot, folder); - if (await pathExists(target)) { - continue; - } - await mkdir(target, { recursive: true }); - warnings.push( - ...(await runActiveOnWriteHooks({ - path: target, - scope: "project", - op, - })), - ); - } -} - -function toSchemaFieldSummary( - field: ReturnType["definitions"][number], -): SchemaFieldSummary { - return { - key: field.key, - type: field.type, - commands: [...field.commands], - cli_flag: `--${field.cli_flag}`, - cli_aliases: field.cli_aliases.map((alias) => `--${alias}`), - ...(field.description ? { description: field.description } : {}), - required: field.required, - required_on_create: field.required_on_create, - allow_unset: field.allow_unset, - required_types: [...field.required_types], - }; -} - -function toExistingStatusDefinition( - resolvedExisting: - | ReturnType["definitions"][number] - | undefined, -): RuntimeStatusDefinition | undefined { - if (!resolvedExisting) { - return undefined; - } - return { - id: resolvedExisting.id, - ...(resolvedExisting.roles.length > 0 - ? { roles: [...resolvedExisting.roles] } - : {}), - ...(resolvedExisting.aliases.length > 0 - ? { aliases: [...resolvedExisting.aliases] } - : {}), - ...(resolvedExisting.description - ? { description: resolvedExisting.description } - : {}), - ...(typeof resolvedExisting.order === "number" - ? { order: resolvedExisting.order } - : {}), - }; -} - -function buildStatusFileAliasMap( - statuses: RuntimeStatusDefinition[], -): Map { - const fileAliasToId = new Map(); - for (const definition of statuses) { - const defId = normalizeStatusToken(definition.id); - if (defId.length === 0) { - continue; - } - fileAliasToId.set(defId, defId); - for (const alias of Array.isArray(definition.aliases) - ? definition.aliases - : []) { - const aliasToken = normalizeStatusToken(alias); - if (aliasToken.length > 0 && !fileAliasToId.has(aliasToken)) { - fileAliasToId.set(aliasToken, defId); - } - } - } - return fileAliasToId; -} - -/** Implements run schema add status for the public runtime surface of this module. */ -export async function runSchemaAddStatus( - id: string | undefined, - options: SchemaAddStatusCommandOptions, - global: GlobalOptions, -): Promise { - const pmRoot = resolvePmRoot(process.cwd(), global.path); - await ensureInitialized(pmRoot); - - let normalized; - try { - normalized = normalizeAddStatusInput({ - id, - roles: options.role, - aliases: options.alias, - description: options.description, - order: options.order, - }); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.USAGE, - ); - } - - const settings = await readSettings(pmRoot); - const schema = normalizeRuntimeSchemaSettings(settings.schema); - const statusesPath = statusesPathFor(pmRoot, schema); - const statusRegistry = resolveRuntimeStatusRegistry(schema); - - // Reject id/alias collisions with a DIFFERENT existing status so a custom - // status can never shadow a built-in lifecycle token (e.g. --alias open). - try { - assertStatusTokensAvailable(normalized, statusRegistry.alias_to_id); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.USAGE, - ); - } - - // Seed the upsert from the resolved (settings-or-file) definition so omitting - // --role/--alias preserves metadata defined in settings.schema.statuses, not - // only what is already in statuses.json. - const baseDefinition = toExistingStatusDefinition( - statusRegistry.by_id.get(normalized.id), - ); - - const warnings: string[] = []; - const author = toAuthor(options.author, settings.author_default); - const governance = resolveGovernanceKnobs(settings); - - const releaseLock = await acquireLock( - pmRoot, - SCHEMA_STATUSES_LOCK_ID, - settings.locks.ttl_seconds, - author, - Boolean(options.force), - governance.force_required_for_stale_lock, - settings.locks.wait_ms, - ); - let upsert; - try { - const previousRaw = await readFileIfExists(statusesPath); - let parsed; - try { - parsed = parseStatusDefsFile(previousRaw); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.GENERIC_FAILURE, - ); - } - // Re-check collisions against the CURRENT file under the lock: the pre-lock - // check used the registry loaded before acquiring schema-statuses, so a - // concurrent add-status could have written a colliding id/alias in between. - // The lock serializes writes; this serializes the collision decision too. - const fileAliasToId = buildStatusFileAliasMap(parsed.statuses); - try { - assertStatusTokensAvailable(normalized, fileAliasToId); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.USAGE, - ); - } - upsert = upsertStatusDef(parsed, normalized, baseDefinition); - // writeFileAtomic writes to a temp file then renames, so a failure leaves - // the existing statuses.json untouched; no manual rollback is needed. - await writeFileAtomic(statusesPath, serializeStatusDefsFile(upsert.file)); - warnings.push( - ...(await runActiveOnWriteHooks({ - path: statusesPath, - scope: "project", - op: "schema:add-status", - })), - ); - } finally { - await releaseLock(); - } - - return { - action: "add-status", - registered: true, - replaced: upsert.replaced, - status: upsert.definition, - file: { - path: statusesPath, - statuses: upsert.file.statuses.length, - }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), - generated_at: nowIso(), - }; -} - -/** Implements run schema remove status for the public runtime surface of this module. */ -export async function runSchemaRemoveStatus( - id: string | undefined, - options: SchemaRemoveStatusCommandOptions, - global: GlobalOptions, -): Promise { - const pmRoot = resolvePmRoot(process.cwd(), global.path); - await ensureInitialized(pmRoot); - - const settings = await readSettings(pmRoot); - const schema = normalizeRuntimeSchemaSettings(settings.schema); - const statusesPath = statusesPathFor(pmRoot, schema); - - const warnings: string[] = []; - const author = toAuthor(options.author, settings.author_default); - const governance = resolveGovernanceKnobs(settings); - - const releaseLock = await acquireLock( - pmRoot, - SCHEMA_STATUSES_LOCK_ID, - settings.locks.ttl_seconds, - author, - Boolean(options.force), - governance.force_required_for_stale_lock, - settings.locks.wait_ms, - ); - let removal; - try { - const previousRaw = await readFileIfExists(statusesPath); - let parsed; - try { - parsed = parseStatusDefsFile(previousRaw); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.GENERIC_FAILURE, - ); - } - try { - removal = removeStatusDef(parsed, id); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.USAGE, - ); - } - if (removal.removed) { - // Only emit the advisory orphan-count warning once a removable custom - // status was actually removed; a no-op/unknown removal would otherwise - // surface a misleading items_using_status:* warning. The count reuses the - // already-loaded settings (no disk re-read) and is non-blocking. - const removedId = normalizeStatusToken(removal.definition?.id ?? id); - if (removedId.length > 0 && !BUILTIN_STATUS_IDS.has(removedId)) { - const usingStatus = await countItemsUsingStatus( - pmRoot, - settings, - schema, - removedId, - ); - if (usingStatus > 0) { - warnings.push(`items_using_status:${usingStatus}`); - } - // Removing a status that a workflow default still points at would leave - // pm close / default pm create resolving to an unregistered status. Warn - // (non-blocking, consistent with remove-type) so the operator re-points - // the workflow slot via schema/workflows.json or pm config. - const referencingSlots = workflowSlotsReferencing( - schema.workflow, - removedId, - ); - if (referencingSlots.length > 0) { - warnings.push( - `status_referenced_by_workflow:${referencingSlots.join(",")}`, - ); - } - } - await writeFileAtomic( - statusesPath, - serializeStatusDefsFile(removal.file), - ); - warnings.push( - ...(await runActiveOnWriteHooks({ - path: statusesPath, - scope: "project", - op: "schema:remove-status", - })), - ); - } - } finally { - await releaseLock(); - } - - return { - action: "remove-status", - removed: removal.removed, - ...(removal.definition ? { status: removal.definition } : {}), - file: { - path: statusesPath, - statuses: removal.file.statuses.length, - }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), - generated_at: nowIso(), - }; -} - -function toSchemaTypeSummary( - definition: ResolvedItemTypeDefinition, -): SchemaTypeSummary { - return { - name: definition.name, - folder: definition.folder, - aliases: Array.isArray(definition.aliases) ? [...definition.aliases] : [], - ...(definition.default_status - ? { default_status: definition.default_status } - : {}), - ...(definition.description ? { description: definition.description } : {}), - }; -} - -function toSchemaTypeDefinition( - definition: ResolvedItemTypeDefinition, - source: SchemaTypeDefinitionResult["source"], - extensionProvenance?: SchemaTypeDefinitionResult["extension"], -): SchemaTypeDefinitionResult { - return { - ...toSchemaTypeSummary(definition), - source, - ...(extensionProvenance ? { extension: extensionProvenance } : {}), - required_create_fields: [...definition.required_create_fields], - required_create_repeatables: [...definition.required_create_repeatables], - options: structuredClone(definition.options), - command_option_policies: structuredClone( - definition.command_option_policies, - ), - }; -} - -function toSchemaStatusSummary( - definition: RuntimeStatusDefinition, -): SchemaStatusSummary { - return { - id: definition.id, - source: BUILTIN_STATUS_IDS.has(definition.id) ? "builtin" : "custom", - roles: Array.isArray(definition.roles) ? [...definition.roles] : [], - aliases: Array.isArray(definition.aliases) ? [...definition.aliases] : [], - ...(definition.description ? { description: definition.description } : {}), - ...(typeof definition.order === "number" - ? { order: definition.order } - : {}), - }; -} - -function classifyTypeSource( - name: string, - customNames: Set, - extensionNames: Set, -): SchemaTypeDefinitionResult["source"] { - const lowerName = name.toLowerCase(); - if (extensionNames.has(lowerName)) { - return "extension"; - } - if (customNames.has(lowerName)) { - return "custom"; - } - return "builtin"; -} - -function collectExtensionTypeProvenance(): Map< - string, - SchemaTypeDefinitionResult["extension"] -> { - const provenance = new Map(); - const registrations = getActiveExtensionRegistrations(); - for (const registration of registrations?.item_types ?? []) { - const rawTypes = (registration as { types?: unknown[] }).types; - if (!Array.isArray(rawTypes)) { - continue; - } - for (const rawType of rawTypes) { - if ( - typeof rawType !== "object" || - rawType === null || - Array.isArray(rawType) - ) { - continue; - } - const name = (rawType as { name?: unknown }).name; - if (typeof name === "string" && name.trim().length > 0) { - provenance.set(name.trim().toLowerCase(), { - layer: registration.layer, - name: registration.name, - }); - } - } - } - return provenance; -} - -function buildSchemaStatusSummaries( - schema: ReturnType, -): { builtin: SchemaStatusSummary[]; custom: SchemaStatusSummary[] } { - const registry = resolveRuntimeStatusRegistry(schema); - const builtin: SchemaStatusSummary[] = []; - const custom: SchemaStatusSummary[] = []; - for (const definition of registry.definitions) { - const summary = toSchemaStatusSummary(definition); - if (summary.source === "builtin") { - builtin.push(summary); - } else { - custom.push(summary); - } - } - return { builtin, custom }; -} - -async function loadSchemaInspectionContext(global: GlobalOptions): Promise<{ - pmRoot: string; - typesPath: string; - statusesPath: string; - byType: Record; - customNames: Set; - extensionProvenance: Map; - schema: ReturnType; -}> { - const pmRoot = resolvePmRoot(process.cwd(), global.path); - if (!(await pathExists(getSettingsPath(pmRoot)))) { - throw new PmCliError( - `Tracker is not initialized at ${pmRoot}. Run pm init first.`, - EXIT_CODE.NOT_FOUND, - ); - } - const settings = await readSettings(pmRoot); - const schema = normalizeRuntimeSchemaSettings(settings.schema); - const typesPath = filePathForSchemaSection( - pmRoot, - schema.files.types, - DEFAULT_RUNTIME_SCHEMA_FILE_PATHS.types, - ); - const statusesPath = statusesPathFor(pmRoot, schema); - const registry = resolveItemTypeRegistry( - settings, - getActiveExtensionRegistrations(), - ); - const extensionProvenance = collectExtensionTypeProvenance(); - const customNames = new Set( - (settings.item_types?.definitions ?? []) - .map((definition) => definition.name.trim().toLowerCase()) - .filter((name) => name.length > 0 && !extensionProvenance.has(name)), - ); - return { - pmRoot, - typesPath, - statusesPath, - byType: registry.by_type, - customNames, - extensionProvenance, - schema, - }; -} - -/** Implements run schema list for the public runtime surface of this module. */ -export async function runSchemaList( - global: GlobalOptions, -): Promise { - const context = await loadSchemaInspectionContext(global); - const builtin: SchemaTypeSummary[] = []; - const custom: SchemaTypeSummary[] = []; - const extension: SchemaTypeSummary[] = []; - for (const definition of Object.values(context.byType).sort((left, right) => - left.name.localeCompare(right.name), - )) { - const source = classifyTypeSource( - definition.name, - context.customNames, - new Set(context.extensionProvenance.keys()), - ); - const summary = toSchemaTypeSummary(definition); - if (source === "extension") { - extension.push(summary); - } else if (source === "custom") { - custom.push(summary); - } else { - builtin.push(summary); - } - } - const statusSummaries = buildSchemaStatusSummaries(context.schema); - const fieldSummaries = resolveRuntimeFieldRegistry( - context.schema, - ).definitions.map(toSchemaFieldSummary); - return { - action: "list", - builtin, - custom, - extension, - counts: { - builtin: builtin.length, - custom: custom.length, - extension: extension.length, - total: builtin.length + custom.length + extension.length, - }, - statuses: { - builtin: statusSummaries.builtin, - custom: statusSummaries.custom, - counts: { - builtin: statusSummaries.builtin.length, - custom: statusSummaries.custom.length, - total: statusSummaries.builtin.length + statusSummaries.custom.length, - }, - }, - fields: { - custom: fieldSummaries, - counts: { - total: fieldSummaries.length, - }, - }, - file: { - path: context.typesPath, - }, - generated_at: nowIso(), - }; -} - -/** Implements run schema show for the public runtime surface of this module. */ -export async function runSchemaShow( - name: string | undefined, - global: GlobalOptions, -): Promise { - const typeName = (name ?? "").trim(); - if (typeName.length === 0) { - throw new PmCliError("Type name must not be empty.", EXIT_CODE.USAGE); - } - const context = await loadSchemaInspectionContext(global); - const match = Object.values(context.byType).find( - (definition) => - definition.name.toLowerCase() === typeName.toLowerCase() || - definition.aliases.some( - (alias) => alias.toLowerCase() === typeName.toLowerCase(), - ), - ); - if (!match) { - throw new PmCliError( - `Unknown item type "${typeName}". Run pm schema list to inspect registered types, or pm schema add-type "${escapeForDoubleQuotes(typeName)}" to register it.`, - EXIT_CODE.NOT_FOUND, - { code: "unknown_item_type" }, - ); - } - return { - action: "show", - type: toSchemaTypeDefinition( - match, - classifyTypeSource( - match.name, - context.customNames, - new Set(context.extensionProvenance.keys()), - ), - context.extensionProvenance.get(match.name.toLowerCase()), - ), - file: { - path: context.typesPath, - }, - generated_at: nowIso(), - }; -} - -/** Implements run schema show status for the public runtime surface of this module. */ -export async function runSchemaShowStatus( - id: string | undefined, - global: GlobalOptions, -): Promise { - const statusToken = normalizeStatusToken(id); - if (statusToken.length === 0) { - throw new PmCliError("Status id must not be empty.", EXIT_CODE.USAGE); - } - const context = await loadSchemaInspectionContext(global); - const statusRegistry = resolveRuntimeStatusRegistry(context.schema); - const resolvedId = statusRegistry.alias_to_id.get(statusToken) ?? statusToken; - const match = statusRegistry.by_id.get(resolvedId); - if (!match) { - throw new PmCliError( - `Unknown status "${id}". Run pm schema list to inspect registered statuses, or pm schema add-status "${escapeForDoubleQuotes( - id ?? "", - )}" to register it.`, - EXIT_CODE.NOT_FOUND, - { code: "unknown_status" }, - ); - } - return { - action: "show-status", - status: toSchemaStatusSummary(match), - file: { - path: context.statusesPath, - }, - generated_at: nowIso(), - }; -} - -/** Implements run schema add field for the public runtime surface of this module. */ -export async function runSchemaAddField( - key: string | undefined, - options: SchemaAddFieldCommandOptions, - global: GlobalOptions, -): Promise { - const pmRoot = resolvePmRoot(process.cwd(), global.path); - await ensureInitialized(pmRoot); - - let normalized; - try { - normalized = normalizeAddFieldInput({ - key, - type: options.type, - commands: options.commands, - description: options.description, - cliFlag: options.cliFlag, - aliases: options.alias, - required: options.required, - requiredOnCreate: options.requiredOnCreate, - allowUnset: options.allowUnset, - requiredTypes: options.requiredTypes, - }); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.USAGE, - ); - } - - const settings = await readSettings(pmRoot); - const schema = normalizeRuntimeSchemaSettings(settings.schema); - const fieldsPath = fieldsPathFor(pmRoot, schema); - - const warnings: string[] = []; - const author = toAuthor(options.author, settings.author_default); - const governance = resolveGovernanceKnobs(settings); - - const releaseLock = await acquireLock( - pmRoot, - SCHEMA_FIELDS_LOCK_ID, - settings.locks.ttl_seconds, - author, - Boolean(options.force), - governance.force_required_for_stale_lock, - settings.locks.wait_ms, - ); - let upsert; - try { - const previousRaw = await readFileIfExists(fieldsPath); - let parsed; - try { - parsed = parseFieldsFile(previousRaw); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.GENERIC_FAILURE, - ); - } - upsert = upsertField(parsed, normalized); - await writeFileAtomic(fieldsPath, serializeFieldsFile(upsert.file)); - warnings.push( - ...(await runActiveOnWriteHooks({ - path: fieldsPath, - scope: "project", - op: "schema:add-field", - })), - ); - } finally { - await releaseLock(); - } - - return { - action: "add-field", - registered: true, - replaced: upsert.replaced, - field: upsert.definition, - file: { - path: fieldsPath, - fields: upsert.file.fields.length, - }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), - generated_at: nowIso(), - }; -} - -/** Counts items carrying a non-empty value for the custom field's metadata key. Advisory only (non-blocking) so remove-field can surface how many items would lose a managed column. Reuses the already-loaded settings (no disk re-read). */ -async function countItemsUsingField( - pmRoot: string, - settings: PmSettings, - schema: ReturnType, - metadataKey: string, -): Promise { - const registry = resolveItemTypeRegistry( - settings, - getActiveExtensionRegistrations(), - ); - const items = await listAllItemMetadataLight( - pmRoot, - settings.item_format, - registry.type_to_folder, - [], - schema, - ); - return items.filter((item) => { - const value = (item as unknown as Record)[metadataKey]; - if (value === undefined || value === null) { - return false; - } - if (Array.isArray(value)) { - return value.length > 0; - } - if (typeof value === "string") { - return value.trim().length > 0; - } - return true; - }).length; -} - -/** Implements run schema remove field for the public runtime surface of this module. */ -export async function runSchemaRemoveField( - key: string | undefined, - options: SchemaRemoveFieldCommandOptions, - global: GlobalOptions, -): Promise { - const pmRoot = resolvePmRoot(process.cwd(), global.path); - await ensureInitialized(pmRoot); - - const settings = await readSettings(pmRoot); - const schema = normalizeRuntimeSchemaSettings(settings.schema); - const fieldsPath = fieldsPathFor(pmRoot, schema); - - const warnings: string[] = []; - const author = toAuthor(options.author, settings.author_default); - const governance = resolveGovernanceKnobs(settings); - - const releaseLock = await acquireLock( - pmRoot, - SCHEMA_FIELDS_LOCK_ID, - settings.locks.ttl_seconds, - author, - Boolean(options.force), - governance.force_required_for_stale_lock, - settings.locks.wait_ms, - ); - let removal; - try { - const previousRaw = await readFileIfExists(fieldsPath); - let parsed; - try { - parsed = parseFieldsFile(previousRaw); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.GENERIC_FAILURE, - ); - } - try { - removal = removeField(parsed, key); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.USAGE, - ); - } - if (removal.removed) { - const removedKey = normalizeFieldKey(removal.definition?.key ?? key); - const metadataKey = normalizeFieldKey( - removal.definition?.metadata_key ?? - removal.definition?.front_matter_key ?? - removedKey, - ); - if (metadataKey.length > 0) { - const usingField = await countItemsUsingField( - pmRoot, - settings, - schema, - metadataKey, - ); - if (usingField > 0) { - warnings.push(`items_using_field:${usingField}`); - } - } - await writeFileAtomic(fieldsPath, serializeFieldsFile(removal.file)); - warnings.push( - ...(await runActiveOnWriteHooks({ - path: fieldsPath, - scope: "project", - op: "schema:remove-field", - })), - ); - } - } finally { - await releaseLock(); - } - - return { - action: "remove-field", - removed: removal.removed, - ...(removal.definition ? { field: removal.definition } : {}), - file: { - path: fieldsPath, - fields: removal.file.fields.length, - }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), - generated_at: nowIso(), - }; -} - -/** Implements run schema list fields for the public runtime surface of this module. */ -export async function runSchemaListFields( - global: GlobalOptions, -): Promise { - const context = await loadSchemaInspectionContext(global); - const fields = resolveRuntimeFieldRegistry(context.schema).definitions.map( - toSchemaFieldSummary, - ); - return { - action: "list-fields", - fields, - counts: { - total: fields.length, - }, - file: { - path: fieldsPathFor(context.pmRoot, context.schema), - }, - generated_at: nowIso(), - }; -} - -/** Implements run schema show field for the public runtime surface of this module. */ -export async function runSchemaShowField( - key: string | undefined, - global: GlobalOptions, -): Promise { - const fieldKey = normalizeFieldKey(key); - if (fieldKey.length === 0) { - throw new PmCliError("Field key must not be empty.", EXIT_CODE.USAGE); - } - const context = await loadSchemaInspectionContext(global); - const match = resolveRuntimeFieldRegistry(context.schema).by_key.get( - fieldKey, - ); - if (!match) { - throw new PmCliError( - `Unknown custom field "${key}". Run pm schema list-fields to inspect registered fields, or pm schema add-field "${escapeForDoubleQuotes( - key ?? "", - )}" to register it.`, - EXIT_CODE.NOT_FOUND, - { code: "unknown_field" }, - ); - } - return { - action: "show-field", - field: toSchemaFieldSummary(match), - file: { - path: fieldsPathFor(context.pmRoot, context.schema), - }, - generated_at: nowIso(), - }; -} - -/** Implements run schema apply preset for the public runtime surface of this module. */ -export async function runSchemaApplyPreset( - preset: string | undefined, - options: SchemaApplyPresetCommandOptions, - global: GlobalOptions, -): Promise { - const pmRoot = resolvePmRoot(process.cwd(), global.path); - await ensureInitialized(pmRoot); - - let presetName: TypePresetName; - try { - const normalized = normalizeTypePresetName(preset); - if (normalized === undefined) { - throw new Error( - `Type preset name is required. Allowed: ${TYPE_PRESET_NAMES.join(", ")}.`, - ); - } - presetName = normalized; - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.USAGE, - ); - } - - const settings = await readSettings(pmRoot); - const schema = normalizeRuntimeSchemaSettings(settings.schema); - const typesPath = typesPathFor(pmRoot, schema); - - const warnings: string[] = []; - const author = toAuthor(options.author, settings.author_default); - const governance = resolveGovernanceKnobs(settings); - - const registered: string[] = []; - const replaced: string[] = []; - let definitionsCount = 0; - const releaseLock = await acquireLock( - pmRoot, - SCHEMA_TYPES_LOCK_ID, - settings.locks.ttl_seconds, - author, - Boolean(options.force), - governance.force_required_for_stale_lock, - settings.locks.wait_ms, - ); - try { - const previousRaw = await readFileIfExists(typesPath); - let parsed; - try { - parsed = parseItemTypesFile(previousRaw); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.GENERIC_FAILURE, - ); - } - let nextFile = parsed; - for (const normalized of resolveTypePresetDefinitions(presetName)) { - const upsert = upsertItemType(nextFile, normalized); - nextFile = upsert.file; - (upsert.replaced ? replaced : registered).push(upsert.definition.name); - } - definitionsCount = nextFile.definitions.length; - await writeFileAtomic(typesPath, serializeItemTypesFile(nextFile)); - await ensureTypeFolderScaffold( - pmRoot, - resolveTypePresetDefinitions(presetName), - warnings, - "schema:apply-preset-folder", - ); - warnings.push( - ...(await runActiveOnWriteHooks({ - path: typesPath, - scope: "project", - op: "schema:apply-preset", - })), - ); - } finally { - await releaseLock(); - } - - return { - action: "apply-preset", - preset: presetName, - registered, - replaced, - file: { - path: typesPath, - definitions: definitionsCount, - }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), - generated_at: nowIso(), - }; -} - -/** Implements run schema infer types for the public runtime surface of this module. */ -export async function runSchemaInferTypes( - options: SchemaAddTypeInferCommandOptions, - global: GlobalOptions, -): Promise { - const pmRoot = resolvePmRoot(process.cwd(), global.path); - await ensureInitialized(pmRoot); - - const settings = await readSettings(pmRoot); - const schema = normalizeRuntimeSchemaSettings(settings.schema); - const typesPath = typesPathFor(pmRoot, schema); - let minCount = 10; - if (options.minCount !== undefined) { - if (!Number.isInteger(options.minCount) || options.minCount < 1) { - throw new PmCliError( - "--min-count must be a positive integer (>= 1).", - EXIT_CODE.USAGE, - ); - } - minCount = options.minCount; - } - - const registry = resolveItemTypeRegistry( - settings, - getActiveExtensionRegistrations(), - ); - const items = await listAllItemMetadataLight( - pmRoot, - settings.item_format, - registry.type_to_folder, - [], - schema, - ); - const titles = items.map((item) => - typeof item.title === "string" ? item.title : "", - ); - const candidates = inferTypesFromTitles(titles, { minCount }); - - const warnings: string[] = []; - const registered: string[] = []; - const replaced: string[] = []; - const skipped: Array<{ name: string; reason: string }> = []; - let definitionsCount: number; - - // Dry-run preview by default: only --apply mutates schema/types.json. - if (!options.apply) { - let parsed; - try { - parsed = parseItemTypesFile(await readFileIfExists(typesPath)); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.GENERIC_FAILURE, - ); - } - definitionsCount = parsed.definitions.length; - return { - action: "infer-types", - applied: false, - min_count: minCount, - candidates, - registered, - replaced, - skipped, - file: { - path: typesPath, - definitions: definitionsCount, - }, - warnings, - generated_at: nowIso(), - }; - } - - const author = toAuthor(options.author, settings.author_default); - const governance = resolveGovernanceKnobs(settings); - const releaseLock = await acquireLock( - pmRoot, - SCHEMA_TYPES_LOCK_ID, - settings.locks.ttl_seconds, - author, - Boolean(options.force), - governance.force_required_for_stale_lock, - settings.locks.wait_ms, - ); - try { - const previousRaw = await readFileIfExists(typesPath); - let parsed; - try { - parsed = parseItemTypesFile(previousRaw); - } catch (error) { - throw new PmCliError( - error instanceof Error ? error.message : String(error), - EXIT_CODE.GENERIC_FAILURE, - ); - } - let nextFile = parsed; - for (const candidate of candidates) { - if (candidate.shadows_builtin) { - skipped.push({ name: candidate.name, reason: "shadows_builtin" }); - continue; - } - let normalized; - try { - normalized = normalizeAddTypeInput({ name: candidate.name }); - } catch { - // A title prefix that does not normalize to a valid type token (rare, - // given the inference pattern) is skipped rather than aborting the batch. - skipped.push({ name: candidate.name, reason: "invalid_type_token" }); - continue; - } - const upsert = upsertItemType(nextFile, normalized); - nextFile = upsert.file; - (upsert.replaced ? replaced : registered).push(upsert.definition.name); - } - definitionsCount = nextFile.definitions.length; - await writeFileAtomic(typesPath, serializeItemTypesFile(nextFile)); - await ensureTypeFolderScaffold( - pmRoot, - nextFile.definitions.filter( - (definition) => - registered.includes(definition.name) || - replaced.includes(definition.name), - ), - warnings, - "schema:infer-types-folder", - ); - warnings.push( - ...(await runActiveOnWriteHooks({ - path: typesPath, - scope: "project", - op: "schema:infer-types", - })), - ); - } finally { - await releaseLock(); - } - - return { - action: "infer-types", - applied: true, - min_count: minCount, - candidates, - registered, - replaced, - skipped, - file: { - path: typesPath, - definitions: definitionsCount, - }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), - generated_at: nowIso(), - }; -} - -/** Implements format schema add type human for the public runtime surface of this module. */ -export function formatSchemaAddTypeHuman(result: SchemaAddTypeResult): string { - const verb = result.replaced ? "Updated" : "Registered"; - const aliasSuffix = - result.type.aliases && result.type.aliases.length > 0 - ? ` (aliases: ${result.type.aliases.join(", ")})` - : ""; - return `${verb} custom item type "${result.type.name}"${aliasSuffix} in ${result.file.path}. Run: pm create "${escapeForDoubleQuotes(result.type.name)}" ""`; -} - -/** Implements format schema remove type human for the public runtime surface of this module. */ -export function formatSchemaRemoveTypeHuman( - result: SchemaRemoveTypeResult, -): string { - if (!result.removed) { - return `No custom item type matched; nothing removed from ${result.file.path}.`; - } - const name = result.type?.name ?? "(unknown)"; - return `Removed custom item type "${name}" from ${result.file.path}.`; -} - -/** Implements format schema add status human for the public runtime surface of this module. */ -export function formatSchemaAddStatusHuman( - result: SchemaAddStatusResult, -): string { - const verb = result.replaced ? "Updated" : "Registered"; - const roleSuffix = - result.status.roles && result.status.roles.length > 0 - ? ` (roles: ${result.status.roles.join(", ")})` - : ""; - const aliasSuffix = - result.status.aliases && result.status.aliases.length > 0 - ? ` (aliases: ${result.status.aliases.join(", ")})` - : ""; - return `${verb} status "${result.status.id}"${roleSuffix}${aliasSuffix} in ${result.file.path}.`; -} - -/** Implements format schema remove status human for the public runtime surface of this module. */ -export function formatSchemaRemoveStatusHuman( - result: SchemaRemoveStatusResult, -): string { - if (!result.removed) { - return `No custom status matched; nothing removed from ${result.file.path}.`; - } - const id = result.status?.id ?? "(unknown)"; - return `Removed custom status "${id}" from ${result.file.path}.`; -} - -/** Implements format schema list human for the public runtime surface of this module. */ -export function formatSchemaListHuman(result: SchemaListResult): string { - const lines = [ - `Schema types: ${result.counts.total} total (${result.counts.builtin} builtin, ${result.counts.custom} custom, ${result.counts.extension} extension)`, - ]; - for (const [label, entries] of [ - ["builtin", result.builtin], - ["custom", result.custom], - ["extension", result.extension], - ] as const) { - if (entries.length === 0) { - continue; - } - lines.push(`${label}: ${entries.map((entry) => entry.name).join(", ")}`); - } - lines.push( - `statuses: ${result.statuses.counts.total} total (${result.statuses.counts.builtin} builtin, ${result.statuses.counts.custom} custom)`, - ); - for (const [label, entries] of [ - ["builtin statuses", result.statuses.builtin], - ["custom statuses", result.statuses.custom], - ] as const) { - if (entries.length === 0) { - continue; - } - lines.push(`${label}: ${entries.map((entry) => entry.id).join(", ")}`); - } - lines.push(`custom fields: ${result.fields.counts.total} total`); - if (result.fields.custom.length > 0) { - lines.push( - `fields: ${result.fields.custom.map((field) => `${field.key} (${field.type}, ${field.cli_flag})`).join(", ")}`, - ); - } - lines.push(`Inspect one: pm schema show <Type>`); - lines.push(`Inspect one status: pm schema show-status <status>`); - lines.push(`Inspect one field: pm schema show-field <key>`); - return lines.join("\n"); -} - -/** Implements format schema show human for the public runtime surface of this module. */ -export function formatSchemaShowHuman(result: SchemaShowResult): string { - const parts = [ - `type: ${result.type.name}`, - `source: ${result.type.source}`, - `folder: ${result.type.folder}`, - ]; - if (result.type.default_status) { - parts.push(`default_status: ${result.type.default_status}`); - } - if (result.type.aliases.length > 0) { - parts.push(`aliases: ${result.type.aliases.join(", ")}`); - } - if (result.type.description) { - parts.push(`description: ${result.type.description}`); - } - if (result.type.options.length > 0) { - parts.push( - `options: ${result.type.options.map((option) => option.key).join(", ")}`, - ); - } - return parts.join("\n"); -} - -/** Implements format schema show status human for the public runtime surface of this module. */ -export function formatSchemaShowStatusHuman( - result: SchemaShowStatusResult, -): string { - const parts = [ - `status: ${result.status.id}`, - `source: ${result.status.source}`, - ]; - if (result.status.roles.length > 0) { - parts.push(`roles: ${result.status.roles.join(", ")}`); - } - if (result.status.aliases.length > 0) { - parts.push(`aliases: ${result.status.aliases.join(", ")}`); - } - if (result.status.description) { - parts.push(`description: ${result.status.description}`); - } - if (typeof result.status.order === "number") { - parts.push(`order: ${result.status.order}`); - } - return parts.join("\n"); -} - -function formatFieldSummaryLine(field: SchemaFieldSummary): string { - const flags: string[] = []; - if (field.required) { - flags.push("required"); - } - if (field.required_on_create) { - flags.push("required_on_create"); - } - if (!field.allow_unset) { - flags.push("no_unset"); - } - const flagSuffix = flags.length > 0 ? ` [${flags.join(", ")}]` : ""; - return `${field.key} (${field.type}) ${field.cli_flag} commands=${field.commands.join("/")}${flagSuffix}`; -} - -/** Implements format schema add field human for the public runtime surface of this module. */ -export function formatSchemaAddFieldHuman( - result: SchemaAddFieldResult, -): string { - const verb = result.replaced ? "Updated" : "Registered"; - return `${verb} custom field "${result.field.key}" in ${result.file.path}. Use it: pm create "<title>" --${(result.field.cli_flag ?? result.field.key).replaceAll("_", "-")} <value>`; -} - -/** Implements format schema remove field human for the public runtime surface of this module. */ -export function formatSchemaRemoveFieldHuman( - result: SchemaRemoveFieldResult, -): string { - if (!result.removed) { - return `No custom field matched; nothing removed from ${result.file.path}.`; - } - const key = result.field?.key ?? "(unknown)"; - return `Removed custom field "${key}" from ${result.file.path}.`; -} - -/** Implements format schema list fields human for the public runtime surface of this module. */ -export function formatSchemaListFieldsHuman( - result: SchemaListFieldsResult, -): string { - const lines = [`Custom fields: ${result.counts.total} total`]; - for (const field of result.fields) { - lines.push(` ${formatFieldSummaryLine(field)}`); - } - lines.push(`Inspect one: pm schema show-field <key>`); - return lines.join("\n"); -} - -/** Implements format schema show field human for the public runtime surface of this module. */ -export function formatSchemaShowFieldHuman( - result: SchemaShowFieldResult, -): string { - const field = result.field; - const parts = [ - `field: ${field.key}`, - `type: ${field.type}`, - `cli_flag: ${field.cli_flag}`, - `commands: ${field.commands.join(", ")}`, - ]; - if (field.cli_aliases.length > 0) { - parts.push(`cli_aliases: ${field.cli_aliases.join(", ")}`); - } - if (field.description) { - parts.push(`description: ${field.description}`); - } - if (field.required) { - parts.push(`required: true`); - } - if (field.required_on_create) { - parts.push(`required_on_create: true`); - } - if (!field.allow_unset) { - parts.push(`allow_unset: false`); - } - if (field.required_types.length > 0) { - parts.push(`required_types: ${field.required_types.join(", ")}`); - } - return parts.join("\n"); -} - -/** Implements format schema apply preset human for the public runtime surface of this module. */ -export function formatSchemaApplyPresetHuman( - result: SchemaApplyPresetResult, -): string { - const registered = - result.registered.length > 0 - ? `registered: ${result.registered.join(", ")}` - : ""; - const replaced = - result.replaced.length > 0 ? `updated: ${result.replaced.join(", ")}` : ""; - const detail = [registered, replaced] - .filter((part) => part.length > 0) - .join("; "); - return `Applied "${result.preset}" type preset to ${result.file.path}${detail.length > 0 ? ` (${detail})` : ""}.`; -} - -/** Implements format schema infer types human for the public runtime surface of this module. */ -export function formatSchemaInferTypesHuman( - result: SchemaAddTypeInferResult, -): string { - if (result.candidates.length === 0) { - return `No title-prefix conventions found with at least ${result.min_count} items. Lower the threshold with --min-count <n>.`; - } - const lines = result.applied - ? [ - `Inferred and registered custom types from title prefixes (min-count ${result.min_count}):`, - ] - : [ - `Inferred custom type candidates from title prefixes (min-count ${result.min_count}, dry-run):`, - ]; - for (const candidate of result.candidates) { - const note = candidate.shadows_builtin - ? " [shadows built-in, skipped]" - : ""; - lines.push( - ` ${candidate.name} <- "${candidate.prefix}" (${candidate.count} items)${note}`, - ); - } - if (!result.applied) { - lines.push(`Re-run with --apply to register the non-shadowing candidates.`); - } else { - if (result.registered.length > 0) { - lines.push(`registered: ${result.registered.join(", ")}`); - } - if (result.replaced.length > 0) { - lines.push(`updated: ${result.replaced.join(", ")}`); - } - } - return lines.join("\n"); -} - -/* c8 ignore stop */ - -/** Re-export so register-mutation can surface the hint in usage examples without importing the core module directly. */ -export { buildInvalidTypeHint }; - -/** Public contract for test only schema command, shared by SDK and presentation-layer consumers. */ -export const _testOnlySchemaCommand = { - toAuthor, - workflowSlotsReferencing, -}; +export * from "../../sdk/schema.js"; diff --git a/src/cli/register-setup.ts b/src/cli/register-setup.ts index 6f79bc5c1..9df920f95 100644 --- a/src/cli/register-setup.ts +++ b/src/cli/register-setup.ts @@ -892,6 +892,8 @@ async function runInitCommandAction( const globalOptions = getGlobalOptions(command); const startedAt = Date.now(); const result = await runInit(prefix, globalOptions, { + idPrefix: + typeof options.idPrefix === "string" ? options.idPrefix : undefined, preset: typeof options.preset === "string" ? options.preset : undefined, defaults: options.defaults === true || options.yes === true, author: typeof options.author === "string" ? options.author : undefined, @@ -1030,6 +1032,10 @@ export function registerSetupCommands(program: Command): void { "[prefix-or-path]", "Optional id prefix, or path-like tracker target such as ./pm-sandbox", ) + .option( + "--prefix, --id-prefix <value>", + "Set the item ID prefix (alias for the positional prefix)", + ) .option( "--preset <value>", "Governance preset for new setups: minimal|default|strict", diff --git a/src/sdk/cli-contracts/flag-contracts.ts b/src/sdk/cli-contracts/flag-contracts.ts index 1fd56f03f..46f9ae261 100644 --- a/src/sdk/cli-contracts/flag-contracts.ts +++ b/src/sdk/cli-contracts/flag-contracts.ts @@ -253,7 +253,6 @@ export const AGGREGATE_FLAG_CONTRACTS: CliFlagContract[] = [ { flag: "--release" }, ]; - /** Public contract for comments flag contracts, shared by SDK and presentation-layer consumers. */ export const COMMENTS_FLAG_CONTRACTS: CliFlagContract[] = [ { flag: "--add", aliases: ["--comment", "--body"] }, @@ -458,6 +457,7 @@ export const PLAN_FLAG_CONTRACTS: CliFlagContract[] = [ /** Public contract for init flag contracts, shared by SDK and presentation-layer consumers. */ export const INIT_FLAG_CONTRACTS: CliFlagContract[] = [ + { flag: "--id-prefix", aliases: ["--prefix"] }, { flag: "--preset" }, { flag: "--type-preset" }, { flag: "--defaults", short: "-y", aliases: ["--yes"] }, @@ -1262,7 +1262,6 @@ export const UPDATE_MANY_FLAG_CONTRACTS: CliFlagContract[] = [ { flag: "--force" }, ]; - /** Public contract for calendar flag contracts, shared by SDK and presentation-layer consumers. */ export const CALENDAR_FLAG_CONTRACTS: CliFlagContract[] = [ { flag: "--view" }, diff --git a/src/sdk/runtime.ts b/src/sdk/runtime.ts index 9d6cb4797..1b90727ed 100644 --- a/src/sdk/runtime.ts +++ b/src/sdk/runtime.ts @@ -92,19 +92,6 @@ import { runProfileShow, runRestore, runRelease, - runSchemaAddField, - runSchemaAddStatus, - runSchemaAddType, - runSchemaApplyPreset, - runSchemaInferTypes, - runSchemaList, - runSchemaListFields, - runSchemaRemoveField, - runSchemaRemoveStatus, - runSchemaRemoveType, - runSchemaShow, - runSchemaShowField, - runSchemaShowStatus, runSearch, runStats, runTelemetry, @@ -221,31 +208,44 @@ import type { ProfileShowResult, ProfileSubcommand, } from "../cli/commands/profile.js"; -import type { - SchemaAddFieldCommandOptions, - SchemaAddFieldResult, - SchemaAddStatusCommandOptions, - SchemaAddStatusResult, - SchemaAddTypeCommandOptions, - SchemaAddTypeInferCommandOptions, - SchemaAddTypeInferResult, - SchemaAddTypeResult, - SchemaApplyPresetCommandOptions, - SchemaApplyPresetResult, - SchemaInspectResult, - SchemaListFieldsResult, - SchemaListResult, - SchemaRemoveFieldCommandOptions, - SchemaRemoveFieldResult, - SchemaRemoveStatusCommandOptions, - SchemaRemoveStatusResult, - SchemaRemoveTypeCommandOptions, - SchemaRemoveTypeResult, - SchemaShowFieldResult, - SchemaShowResult, - SchemaShowStatusResult, - SchemaSubcommand, -} from "../cli/commands/schema.js"; +import { + runSchemaAddField, + runSchemaAddStatus, + runSchemaAddType, + runSchemaApplyPreset, + runSchemaInferTypes, + runSchemaList, + runSchemaListFields, + runSchemaRemoveField, + runSchemaRemoveStatus, + runSchemaRemoveType, + runSchemaShow, + runSchemaShowField, + runSchemaShowStatus, + type SchemaAddFieldCommandOptions, + type SchemaAddFieldResult, + type SchemaAddStatusCommandOptions, + type SchemaAddStatusResult, + type SchemaAddTypeCommandOptions, + type SchemaAddTypeInferCommandOptions, + type SchemaAddTypeInferResult, + type SchemaAddTypeResult, + type SchemaApplyPresetCommandOptions, + type SchemaApplyPresetResult, + type SchemaInspectResult, + type SchemaListFieldsResult, + type SchemaListResult, + type SchemaRemoveFieldCommandOptions, + type SchemaRemoveFieldResult, + type SchemaRemoveStatusCommandOptions, + type SchemaRemoveStatusResult, + type SchemaRemoveTypeCommandOptions, + type SchemaRemoveTypeResult, + type SchemaShowFieldResult, + type SchemaShowResult, + type SchemaShowStatusResult, + type SchemaSubcommand, +} from "./schema.js"; import type { ValidateCommandOptions, ValidateResult, diff --git a/src/sdk/schema.ts b/src/sdk/schema.ts new file mode 100644 index 000000000..d1aed04e3 --- /dev/null +++ b/src/sdk/schema.ts @@ -0,0 +1,2344 @@ +/** + * @module sdk/schema + * + * Owns typed schema customization primitives shared by SDK, CLI, and MCP consumers. + */ +import path from "node:path"; +import { mkdir } from "node:fs/promises"; +import { + pathExists, + readFileIfExists, + writeFileAtomic, +} from "../core/fs/fs-utils.js"; +import { acquireLock } from "../core/lock/lock.js"; +import { + assertAliasesAvailable, + assertTypeFolderAvailable, + buildInvalidTypeHint, + escapeForDoubleQuotes, + normalizeAddTypeInput, + parseItemTypesFile, + removeItemType, + serializeItemTypesFile, + upsertItemType, + type ItemTypeDefinition, +} from "../core/schema/item-types-file.js"; +import { + assertStatusTokensAvailable, + BUILTIN_STATUS_IDS, + normalizeAddStatusInput, + normalizeStatusToken, + parseStatusDefsFile, + removeStatusDef, + serializeStatusDefsFile, + upsertStatusDef, + type RuntimeStatusDefinition, +} from "../core/schema/status-defs-file.js"; +import { + normalizeAddFieldInput, + normalizeFieldKey, + parseFieldsFile, + removeField, + serializeFieldsFile, + upsertField, + type RuntimeFieldDefinition, +} from "../core/schema/fields-file.js"; +import { + normalizeTypePresetName, + resolveTypePresetDefinitions, + TYPE_PRESET_NAMES, + type TypePresetName, +} from "../core/schema/type-presets.js"; +import { + inferTypesFromTitles, + type InferredTypeCandidate, +} from "../core/schema/type-inference.js"; +import { + DEFAULT_RUNTIME_SCHEMA_FILE_PATHS, + filePathForSchemaSection, + normalizeRuntimeSchemaSettings, + resolveRuntimeFieldRegistry, + resolveRuntimeStatusRegistry, +} from "../core/schema/runtime-schema.js"; +import { + resolveItemTypeRegistry, + toDefaultFolder, + type ResolvedItemTypeDefinition, +} from "../core/item/type-registry.js"; +import { listAllItemMetadataLight } from "../core/store/item-store.js"; +import { EXIT_CODE } from "../core/shared/constants.js"; +import type { GlobalOptions } from "../core/shared/command-types.js"; +import type { PmSettings } from "../types/index.js"; +import { PmCliError } from "../core/shared/errors.js"; +import { nowIso } from "../core/shared/time.js"; +import { + getActiveExtensionRegistrations, + runActiveOnWriteHooks, +} from "../core/extensions/index.js"; +import { getSettingsPath, resolvePmRoot } from "../core/store/paths.js"; +import { + readSettings, + resolveGovernanceKnobs, +} from "../core/store/settings.js"; + +/** Public contract for schema subcommands, shared by SDK and presentation-layer consumers. */ +export const SCHEMA_SUBCOMMANDS = [ + "add-type", + "remove-type", + "add-status", + "remove-status", + "add-field", + "remove-field", + "list-fields", + "show-field", + "apply-preset", + "list", + "show", + "show-status", +] as const; +/** Restricts schema subcommand values accepted by command, SDK, and storage contracts. */ +export type SchemaSubcommand = (typeof SCHEMA_SUBCOMMANDS)[number]; + +const SCHEMA_TYPES_LOCK_ID = "schema-types"; +const SCHEMA_STATUSES_LOCK_ID = "schema-statuses"; +const SCHEMA_FIELDS_LOCK_ID = "schema-fields"; + +/** Documents the schema add type command options payload exchanged by command, SDK, and package integrations. */ +export interface SchemaAddTypeCommandOptions { + /** Value that configures or reports description for this contract. */ + description?: string; + /** Lifecycle state reported for defaultthe record. */ + defaultStatus?: string; + /** Value that configures or reports folder for this contract. */ + folder?: string; + /** Value that configures or reports alias for this contract. */ + alias?: string[]; + /** Value that configures or reports author for this contract. */ + author?: string; + /** Value that configures or reports force for this contract. */ + force?: boolean; +} + +/** Documents the schema remove type command options payload exchanged by command, SDK, and package integrations. */ +export interface SchemaRemoveTypeCommandOptions { + /** Value that configures or reports author for this contract. */ + author?: string; + /** Value that configures or reports force for this contract. */ + force?: boolean; +} + +/** Documents the schema add status command options payload exchanged by command, SDK, and package integrations. */ +export interface SchemaAddStatusCommandOptions { + /** Value that configures or reports role for this contract. */ + role?: string[]; + /** Value that configures or reports alias for this contract. */ + alias?: string[]; + /** Value that configures or reports description for this contract. */ + description?: string; + /** Value that configures or reports order for this contract. */ + order?: number; + /** Value that configures or reports author for this contract. */ + author?: string; + /** Value that configures or reports force for this contract. */ + force?: boolean; +} + +/** Documents the schema remove status command options payload exchanged by command, SDK, and package integrations. */ +export interface SchemaRemoveStatusCommandOptions { + /** Value that configures or reports author for this contract. */ + author?: string; + /** Value that configures or reports force for this contract. */ + force?: boolean; +} + +/** Documents the schema add type result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaAddTypeResult { + /** Value that configures or reports action for this contract. */ + action: "add-type"; + /** Value that configures or reports registered for this contract. */ + registered: boolean; + /** Value that configures or reports replaced for this contract. */ + replaced: boolean; + /** Schema type that determines the shape and validation rules for this value. */ + type: ItemTypeDefinition; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + definitions: number; + }; + /** Value that configures or reports warnings for this contract. */ + warnings: string[]; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Documents the schema remove type result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaRemoveTypeResult { + /** Value that configures or reports action for this contract. */ + action: "remove-type"; + /** Value that configures or reports removed for this contract. */ + removed: boolean; + /** Schema type that determines the shape and validation rules for this value. */ + type?: ItemTypeDefinition; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + definitions: number; + }; + /** Value that configures or reports warnings for this contract. */ + warnings: string[]; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Documents the schema add status result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaAddStatusResult { + /** Value that configures or reports action for this contract. */ + action: "add-status"; + /** Value that configures or reports registered for this contract. */ + registered: boolean; + /** Value that configures or reports replaced for this contract. */ + replaced: boolean; + /** Lifecycle state reported for status. */ + status: RuntimeStatusDefinition; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + statuses: number; + }; + /** Value that configures or reports warnings for this contract. */ + warnings: string[]; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Documents the schema remove status result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaRemoveStatusResult { + /** Value that configures or reports action for this contract. */ + action: "remove-status"; + /** Value that configures or reports removed for this contract. */ + removed: boolean; + /** Lifecycle state reported for status. */ + status?: RuntimeStatusDefinition; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + statuses: number; + }; + /** Value that configures or reports warnings for this contract. */ + warnings: string[]; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Documents the schema add field command options payload exchanged by command, SDK, and package integrations. */ +export interface SchemaAddFieldCommandOptions { + /** Schema type that determines the shape and validation rules for this value. */ + type?: string; + /** Value that configures or reports commands for this contract. */ + commands?: string[]; + /** Value that configures or reports description for this contract. */ + description?: string; + /** Value that configures or reports cli flag for this contract. */ + cliFlag?: string; + /** Value that configures or reports alias for this contract. */ + alias?: string[]; + /** Value that configures or reports required for this contract. */ + required?: boolean; + /** Value that configures or reports required on create for this contract. */ + requiredOnCreate?: boolean; + /** Value that configures or reports allow unset for this contract. */ + allowUnset?: boolean; + /** Value that configures or reports required types for this contract. */ + requiredTypes?: string[]; + /** Value that configures or reports author for this contract. */ + author?: string; + /** Value that configures or reports force for this contract. */ + force?: boolean; +} + +/** Documents the schema remove field command options payload exchanged by command, SDK, and package integrations. */ +export interface SchemaRemoveFieldCommandOptions { + /** Value that configures or reports author for this contract. */ + author?: string; + /** Value that configures or reports force for this contract. */ + force?: boolean; +} + +/** Documents the schema apply preset command options payload exchanged by command, SDK, and package integrations. */ +export interface SchemaApplyPresetCommandOptions { + /** Value that configures or reports author for this contract. */ + author?: string; + /** Value that configures or reports force for this contract. */ + force?: boolean; +} + +/** Documents the schema add type infer command options payload exchanged by command, SDK, and package integrations. */ +export interface SchemaAddTypeInferCommandOptions { + /** Number of min entries represented by this result. */ + minCount?: number; + /** Value that configures or reports apply for this contract. */ + apply?: boolean; + /** Value that configures or reports author for this contract. */ + author?: string; + /** Value that configures or reports force for this contract. */ + force?: boolean; +} + +/** Documents the schema field summary payload exchanged by command, SDK, and package integrations. */ +export interface SchemaFieldSummary { + /** Value that configures or reports key for this contract. */ + key: string; + /** Schema type that determines the shape and validation rules for this value. */ + type: string; + /** Value that configures or reports commands for this contract. */ + commands: string[]; + /** Value that configures or reports cli flag for this contract. */ + cli_flag: string; + /** Value that configures or reports cli aliases for this contract. */ + cli_aliases: string[]; + /** Value that configures or reports description for this contract. */ + description?: string; + /** Value that configures or reports required for this contract. */ + required: boolean; + /** Value that configures or reports required on create for this contract. */ + required_on_create: boolean; + /** Value that configures or reports allow unset for this contract. */ + allow_unset: boolean; + /** Value that configures or reports required types for this contract. */ + required_types: string[]; +} + +/** Documents the schema add field result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaAddFieldResult { + /** Value that configures or reports action for this contract. */ + action: "add-field"; + /** Value that configures or reports registered for this contract. */ + registered: boolean; + /** Value that configures or reports replaced for this contract. */ + replaced: boolean; + /** Value that configures or reports field for this contract. */ + field: RuntimeFieldDefinition; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + fields: number; + }; + /** Value that configures or reports warnings for this contract. */ + warnings: string[]; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Documents the schema remove field result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaRemoveFieldResult { + /** Value that configures or reports action for this contract. */ + action: "remove-field"; + /** Value that configures or reports removed for this contract. */ + removed: boolean; + /** Value that configures or reports field for this contract. */ + field?: RuntimeFieldDefinition; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + fields: number; + }; + /** Value that configures or reports warnings for this contract. */ + warnings: string[]; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Documents the schema list fields result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaListFieldsResult { + /** Value that configures or reports action for this contract. */ + action: "list-fields"; + /** Value that configures or reports fields for this contract. */ + fields: SchemaFieldSummary[]; + /** Value that configures or reports counts for this contract. */ + counts: { + total: number; + }; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + }; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Documents the schema show field result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaShowFieldResult { + /** Value that configures or reports action for this contract. */ + action: "show-field"; + /** Value that configures or reports field for this contract. */ + field: SchemaFieldSummary; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + }; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Documents the schema apply preset result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaApplyPresetResult { + /** Value that configures or reports action for this contract. */ + action: "apply-preset"; + /** Value that configures or reports preset for this contract. */ + preset: TypePresetName; + /** Value that configures or reports registered for this contract. */ + registered: string[]; + /** Value that configures or reports replaced for this contract. */ + replaced: string[]; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + definitions: number; + }; + /** Value that configures or reports warnings for this contract. */ + warnings: string[]; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Documents the schema add type infer result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaAddTypeInferResult { + /** Value that configures or reports action for this contract. */ + action: "infer-types"; + /** Value that configures or reports applied for this contract. */ + applied: boolean; + /** Number of min entries represented by this result. */ + min_count: number; + /** Value that configures or reports candidates for this contract. */ + candidates: InferredTypeCandidate[]; + /** Value that configures or reports registered for this contract. */ + registered: string[]; + /** Value that configures or reports replaced for this contract. */ + replaced: string[]; + /** Value that configures or reports skipped for this contract. */ + skipped: Array<{ name: string; reason: string }>; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + definitions: number; + }; + /** Value that configures or reports warnings for this contract. */ + warnings: string[]; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Documents the schema type summary payload exchanged by command, SDK, and package integrations. */ +export interface SchemaTypeSummary { + /** Value that configures or reports name for this contract. */ + name: string; + /** Value that configures or reports folder for this contract. */ + folder: string; + /** Value that configures or reports aliases for this contract. */ + aliases: string[]; + /** Lifecycle state reported for defaultthe record. */ + default_status?: string; + /** Value that configures or reports description for this contract. */ + description?: string; +} + +/** Documents the schema status summary payload exchanged by command, SDK, and package integrations. */ +export interface SchemaStatusSummary { + /** Stable identifier used to reference this record across commands and storage. */ + id: string; + /** Value that configures or reports source for this contract. */ + source: "builtin" | "custom"; + /** Value that configures or reports roles for this contract. */ + roles: string[]; + /** Value that configures or reports aliases for this contract. */ + aliases: string[]; + /** Value that configures or reports description for this contract. */ + description?: string; + /** Value that configures or reports order for this contract. */ + order?: number; +} + +/** Documents the schema type definition result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaTypeDefinitionResult extends SchemaTypeSummary { + /** Value that configures or reports source for this contract. */ + source: "builtin" | "custom" | "extension"; + /** Value that configures or reports extension for this contract. */ + extension?: { + layer: string; + name: string; + }; + /** Value that configures or reports required create fields for this contract. */ + required_create_fields: string[]; + /** Value that configures or reports required create repeatables for this contract. */ + required_create_repeatables: string[]; + /** Value that configures or reports options for this contract. */ + options: ResolvedItemTypeDefinition["options"]; + /** Value that configures or reports command option policies for this contract. */ + command_option_policies: ResolvedItemTypeDefinition["command_option_policies"]; +} + +/** Documents the schema list result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaListResult { + /** Value that configures or reports action for this contract. */ + action: "list"; + /** Value that configures or reports builtin for this contract. */ + builtin: SchemaTypeSummary[]; + /** Value that configures or reports custom for this contract. */ + custom: SchemaTypeSummary[]; + /** Value that configures or reports extension for this contract. */ + extension: SchemaTypeSummary[]; + /** Value that configures or reports counts for this contract. */ + counts: { + builtin: number; + custom: number; + extension: number; + total: number; + }; + /** Value that configures or reports statuses for this contract. */ + statuses: { + builtin: SchemaStatusSummary[]; + custom: SchemaStatusSummary[]; + counts: { + builtin: number; + custom: number; + total: number; + }; + }; + /** Value that configures or reports fields for this contract. */ + fields: { + custom: SchemaFieldSummary[]; + counts: { + total: number; + }; + }; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + }; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Documents the schema show result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaShowResult { + /** Value that configures or reports action for this contract. */ + action: "show"; + /** Schema type that determines the shape and validation rules for this value. */ + type: SchemaTypeDefinitionResult; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + }; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Documents the schema show status result payload exchanged by command, SDK, and package integrations. */ +export interface SchemaShowStatusResult { + /** Value that configures or reports action for this contract. */ + action: "show-status"; + /** Lifecycle state reported for status. */ + status: SchemaStatusSummary; + /** Value that configures or reports file for this contract. */ + file: { + path: string; + }; + /** ISO 8601 timestamp recording when generated occurred. */ + generated_at: string; +} + +/** Restricts schema inspect result values accepted by command, SDK, and storage contracts. */ +export type SchemaInspectResult = + | SchemaListResult + | SchemaShowResult + | SchemaShowStatusResult + | SchemaListFieldsResult + | SchemaShowFieldResult; + +function toAuthor( + candidate: string | undefined, + defaultAuthor: string, +): string { + const resolved = candidate ?? process.env.PM_AUTHOR ?? defaultAuthor; + const trimmed = resolved.trim(); + return trimmed.length > 0 ? trimmed : "unknown"; +} + +/* c8 ignore start -- schema command mutation/inspection branches are covered by broader schema integration workflows. */ +/** Implements run schema add type for the public runtime surface of this module. */ +export async function runSchemaAddType( + name: string | undefined, + options: SchemaAddTypeCommandOptions, + global: GlobalOptions, +): Promise<SchemaAddTypeResult> { + const pmRoot = resolvePmRoot(process.cwd(), global.path); + if (!(await pathExists(getSettingsPath(pmRoot)))) { + throw new PmCliError( + `Tracker is not initialized at ${pmRoot}. Run pm init first.`, + EXIT_CODE.NOT_FOUND, + ); + } + + let normalized; + try { + normalized = normalizeAddTypeInput({ + name, + description: options.description, + defaultStatus: options.defaultStatus, + folder: options.folder, + aliases: options.alias, + }); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.USAGE, + ); + } + + const settings = await readSettings(pmRoot); + const schema = normalizeRuntimeSchemaSettings(settings.schema); + const typesPath = filePathForSchemaSection( + pmRoot, + schema.files.types, + DEFAULT_RUNTIME_SCHEMA_FILE_PATHS.types, + ); + + const warnings: string[] = []; + const author = toAuthor(options.author, settings.author_default); + const governance = resolveGovernanceKnobs(settings); + + const releaseLock = await acquireLock( + pmRoot, + SCHEMA_TYPES_LOCK_ID, + settings.locks.ttl_seconds, + author, + Boolean(options.force), + governance.force_required_for_stale_lock, + settings.locks.wait_ms, + ); + let upsert; + try { + const previousRaw = await readFileIfExists(typesPath); + let parsed; + try { + parsed = parseItemTypesFile(previousRaw); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.GENERIC_FAILURE, + ); + } + try { + assertAliasesAvailable(normalized, parsed); + // GH-248: reject distinct type names that would slug to the same storage + // folder (e.g. "Spike"/"Spikes") so item files never silently share a + // directory. Same-named upserts (recase/idempotent re-runs) are exempt. + // reservedFolders covers built-in + extension + settings-resolved custom + // folders so a slug like "Tasks" can't collide with the built-in Task. + const reservedFolders = new Map<string, string>(); + const resolvedRegistry = resolveItemTypeRegistry( + settings, + getActiveExtensionRegistrations(), + ); + for (const [typeName, folder] of Object.entries( + resolvedRegistry.type_to_folder, + )) { + const folderKey = folder.trim().toLowerCase(); + if (folderKey.length > 0 && !reservedFolders.has(folderKey)) { + reservedFolders.set(folderKey, typeName); + } + } + assertTypeFolderAvailable(normalized, parsed, reservedFolders); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.USAGE, + ); + } + upsert = upsertItemType(parsed, normalized); + // GH-248: an upsert that only differs in case from the existing canonical + // name silently renames the stored type (e.g. registering "spike" over + // "Spike"). Surface it as a non-blocking warning so the operator knows the + // existing definition was replaced rather than a new type created. + if ( + upsert.replaced && + upsert.previousName !== undefined && + upsert.previousName !== upsert.definition.name + ) { + warnings.push( + `type_recased:${upsert.previousName}->${upsert.definition.name}`, + ); + } + // writeFileAtomic writes to a temp file then renames, so a failure leaves the + // existing types.json untouched; no manual rollback is needed. + await writeFileAtomic(typesPath, serializeItemTypesFile(upsert.file)); + await ensureTypeFolderScaffold( + pmRoot, + [upsert.definition], + warnings, + "schema:add-type-folder", + ); + warnings.push( + ...(await runActiveOnWriteHooks({ + path: typesPath, + scope: "project", + op: "schema:add-type", + })), + ); + } finally { + await releaseLock(); + } + + return { + action: "add-type", + registered: true, + replaced: upsert.replaced, + type: upsert.definition, + file: { + path: typesPath, + definitions: upsert.file.definitions.length, + }, + warnings: [...new Set(warnings)].sort((left, right) => + left.localeCompare(right), + ), + generated_at: nowIso(), + }; +} + +async function ensureInitialized(pmRoot: string): Promise<void> { + if (!(await pathExists(getSettingsPath(pmRoot)))) { + throw new PmCliError( + `Tracker is not initialized at ${pmRoot}. Run pm init first.`, + EXIT_CODE.NOT_FOUND, + ); + } +} + +/** Counts items whose resolved type matches `typeName` (case-insensitive). Uses the lightest existing read path (listAllItemMetadataLight skips the heavy collections cache). All items are counted — not just open ones — so the advisory warning surfaces every item the removed definition would orphan; the count is non-blocking. The caller passes its already-loaded `settings` so we never re-read settings.json from disk here. */ +async function countItemsUsingType( + pmRoot: string, + settings: PmSettings, + schema: ReturnType<typeof normalizeRuntimeSchemaSettings>, + typeName: string, +): Promise<number> { + const registry = resolveItemTypeRegistry( + settings, + getActiveExtensionRegistrations(), + ); + const lowerName = typeName.trim().toLowerCase(); + const items = await listAllItemMetadataLight( + pmRoot, + settings.item_format, + registry.type_to_folder, + [], + schema, + ); + return items.filter( + (item) => + typeof item.type === "string" && item.type.toLowerCase() === lowerName, + ).length; +} + +/** Counts items currently set to the status whose id/aliases resolve to `statusId`. Uses listAllItemMetadataLight (the lightest read path). All items are counted regardless of lifecycle phase; the count is advisory only. The caller passes its already-loaded `settings` so we never re-read from disk. */ +async function countItemsUsingStatus( + pmRoot: string, + settings: PmSettings, + schema: ReturnType<typeof normalizeRuntimeSchemaSettings>, + statusId: string, +): Promise<number> { + const registry = resolveItemTypeRegistry( + settings, + getActiveExtensionRegistrations(), + ); + const statusRegistry = resolveRuntimeStatusRegistry(schema); + const normalizedId = normalizeStatusToken(statusId); + const items = await listAllItemMetadataLight( + pmRoot, + settings.item_format, + registry.type_to_folder, + [], + schema, + ); + return items.filter((item) => { + const itemStatus = typeof item.status === "string" ? item.status : ""; + const resolved = + statusRegistry.alias_to_id.get(normalizeStatusToken(itemStatus)) ?? + normalizeStatusToken(itemStatus); + return resolved === normalizedId; + }).length; +} + +/** Adds non-blocking usage and workflow diagnostics for a removed custom status. */ +async function appendRemovedStatusWarnings( + warnings: string[], + pmRoot: string, + settings: PmSettings, + schema: ReturnType<typeof normalizeRuntimeSchemaSettings>, + removedId: string, +): Promise<void> { + if (removedId.length === 0 || BUILTIN_STATUS_IDS.has(removedId)) { + return; + } + const usingStatus = await countItemsUsingStatus( + pmRoot, + settings, + schema, + removedId, + ); + if (usingStatus > 0) { + warnings.push(`items_using_status:${usingStatus}`); + } + const referencingSlots = workflowSlotsReferencing(schema.workflow, removedId); + if (referencingSlots.length > 0) { + warnings.push( + `status_referenced_by_workflow:${referencingSlots.join(",")}`, + ); + } +} + +/** Returns the workflow role-slot names (open_status, close_status, ...) that currently point at `statusId` (normalized). Used to warn before removing a status that a workflow default still references. */ +function workflowSlotsReferencing( + workflow: { + draft_status?: string; + open_status?: string; + in_progress_status?: string; + blocked_status?: string; + close_status?: string; + canceled_status?: string; + }, + statusId: string, +): string[] { + const slots: Array<[string, string | undefined]> = [ + ["draft_status", workflow.draft_status], + ["open_status", workflow.open_status], + ["in_progress_status", workflow.in_progress_status], + ["blocked_status", workflow.blocked_status], + ["close_status", workflow.close_status], + ["canceled_status", workflow.canceled_status], + ]; + return slots + .filter( + ([, value]) => + value !== undefined && normalizeStatusToken(value) === statusId, + ) + .map(([slot]) => slot); +} + +/** Implements run schema remove type for the public runtime surface of this module. */ +export async function runSchemaRemoveType( + name: string | undefined, + options: SchemaRemoveTypeCommandOptions, + global: GlobalOptions, +): Promise<SchemaRemoveTypeResult> { + const pmRoot = resolvePmRoot(process.cwd(), global.path); + await ensureInitialized(pmRoot); + + const settings = await readSettings(pmRoot); + const schema = normalizeRuntimeSchemaSettings(settings.schema); + const typesPath = filePathForSchemaSection( + pmRoot, + schema.files.types, + DEFAULT_RUNTIME_SCHEMA_FILE_PATHS.types, + ); + + const warnings: string[] = []; + const author = toAuthor(options.author, settings.author_default); + const governance = resolveGovernanceKnobs(settings); + + const releaseLock = await acquireLock( + pmRoot, + SCHEMA_TYPES_LOCK_ID, + settings.locks.ttl_seconds, + author, + Boolean(options.force), + governance.force_required_for_stale_lock, + settings.locks.wait_ms, + ); + let removal; + try { + const previousRaw = await readFileIfExists(typesPath); + let parsed; + try { + parsed = parseItemTypesFile(previousRaw); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.GENERIC_FAILURE, + ); + } + try { + removal = removeItemType(parsed, name); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.USAGE, + ); + } + if (removal.removed) { + // Only emit the advisory orphan-count warning once a removable custom + // definition was actually removed; a no-op/unknown removal would otherwise + // surface a misleading items_using_type:* warning. The count is + // non-blocking and reuses the already-loaded settings (no disk re-read). + const removedName = (removal.definition?.name ?? name ?? "").trim(); + if (removedName.length > 0) { + const usingType = await countItemsUsingType( + pmRoot, + settings, + schema, + removedName, + ); + if (usingType > 0) { + warnings.push(`items_using_type:${usingType}`); + } + } + // writeFileAtomic writes to a temp file then renames, so a failure leaves + // the existing types.json untouched; no manual rollback is needed. + await writeFileAtomic(typesPath, serializeItemTypesFile(removal.file)); + warnings.push( + ...(await runActiveOnWriteHooks({ + path: typesPath, + scope: "project", + op: "schema:remove-type", + })), + ); + } + } finally { + await releaseLock(); + } + + return { + action: "remove-type", + removed: removal.removed, + ...(removal.definition ? { type: removal.definition } : {}), + file: { + path: typesPath, + definitions: removal.file.definitions.length, + }, + warnings: [...new Set(warnings)].sort((left, right) => + left.localeCompare(right), + ), + generated_at: nowIso(), + }; +} + +function statusesPathFor( + pmRoot: string, + schema: ReturnType<typeof normalizeRuntimeSchemaSettings>, +): string { + return filePathForSchemaSection( + pmRoot, + schema.files.statuses, + DEFAULT_RUNTIME_SCHEMA_FILE_PATHS.statuses, + ); +} + +function fieldsPathFor( + pmRoot: string, + schema: ReturnType<typeof normalizeRuntimeSchemaSettings>, +): string { + return filePathForSchemaSection( + pmRoot, + schema.files.fields, + DEFAULT_RUNTIME_SCHEMA_FILE_PATHS.fields, + ); +} + +function typesPathFor( + pmRoot: string, + schema: ReturnType<typeof normalizeRuntimeSchemaSettings>, +): string { + return filePathForSchemaSection( + pmRoot, + schema.files.types, + DEFAULT_RUNTIME_SCHEMA_FILE_PATHS.types, + ); +} + +/** Creates the storage folder for each item-type definition (resolving the default folder when unset) and runs on-write hooks for newly created folders. Shared with `pm profile apply` so profile-staged types scaffold identically to `pm schema add-type`. Existing folders are skipped, keeping the call idempotent. */ +export async function ensureTypeFolderScaffold( + pmRoot: string, + definitions: readonly ItemTypeDefinition[], + warnings: string[], + op: string, +): Promise<void> { + for (const definition of definitions) { + const folder = definition.folder ?? toDefaultFolder(definition.name); + const target = path.join(pmRoot, folder); + if (await pathExists(target)) { + continue; + } + await mkdir(target, { recursive: true }); + warnings.push( + ...(await runActiveOnWriteHooks({ + path: target, + scope: "project", + op, + })), + ); + } +} + +function toSchemaFieldSummary( + field: ReturnType<typeof resolveRuntimeFieldRegistry>["definitions"][number], +): SchemaFieldSummary { + return { + key: field.key, + type: field.type, + commands: [...field.commands], + cli_flag: `--${field.cli_flag}`, + cli_aliases: field.cli_aliases.map((alias) => `--${alias}`), + ...(field.description ? { description: field.description } : {}), + required: field.required, + required_on_create: field.required_on_create, + allow_unset: field.allow_unset, + required_types: [...field.required_types], + }; +} + +function toExistingStatusDefinition( + resolvedExisting: + | ReturnType<typeof resolveRuntimeStatusRegistry>["definitions"][number] + | undefined, +): RuntimeStatusDefinition | undefined { + if (!resolvedExisting) { + return undefined; + } + return { + id: resolvedExisting.id, + ...(resolvedExisting.roles.length > 0 + ? { roles: [...resolvedExisting.roles] } + : {}), + ...(resolvedExisting.aliases.length > 0 + ? { aliases: [...resolvedExisting.aliases] } + : {}), + ...(resolvedExisting.description + ? { description: resolvedExisting.description } + : {}), + ...(typeof resolvedExisting.order === "number" + ? { order: resolvedExisting.order } + : {}), + }; +} + +function buildStatusFileAliasMap( + statuses: RuntimeStatusDefinition[], +): Map<string, string> { + const fileAliasToId = new Map<string, string>(); + for (const definition of statuses) { + const defId = normalizeStatusToken(definition.id); + if (defId.length === 0) { + continue; + } + fileAliasToId.set(defId, defId); + for (const alias of Array.isArray(definition.aliases) + ? definition.aliases + : []) { + const aliasToken = normalizeStatusToken(alias); + if (aliasToken.length > 0 && !fileAliasToId.has(aliasToken)) { + fileAliasToId.set(aliasToken, defId); + } + } + } + return fileAliasToId; +} + +/** Implements run schema add status for the public runtime surface of this module. */ +export async function runSchemaAddStatus( + id: string | undefined, + options: SchemaAddStatusCommandOptions, + global: GlobalOptions, +): Promise<SchemaAddStatusResult> { + const pmRoot = resolvePmRoot(process.cwd(), global.path); + await ensureInitialized(pmRoot); + + let normalized; + try { + normalized = normalizeAddStatusInput({ + id, + roles: options.role, + aliases: options.alias, + description: options.description, + order: options.order, + }); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.USAGE, + ); + } + + const settings = await readSettings(pmRoot); + const schema = normalizeRuntimeSchemaSettings(settings.schema); + const statusesPath = statusesPathFor(pmRoot, schema); + const statusRegistry = resolveRuntimeStatusRegistry(schema); + + // Reject id/alias collisions with a DIFFERENT existing status so a custom + // status can never shadow a built-in lifecycle token (e.g. --alias open). + try { + assertStatusTokensAvailable(normalized, statusRegistry.alias_to_id); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.USAGE, + ); + } + + // Seed the upsert from the resolved (settings-or-file) definition so omitting + // --role/--alias preserves metadata defined in settings.schema.statuses, not + // only what is already in statuses.json. + const baseDefinition = toExistingStatusDefinition( + statusRegistry.by_id.get(normalized.id), + ); + + const warnings: string[] = []; + const author = toAuthor(options.author, settings.author_default); + const governance = resolveGovernanceKnobs(settings); + + const releaseLock = await acquireLock( + pmRoot, + SCHEMA_STATUSES_LOCK_ID, + settings.locks.ttl_seconds, + author, + Boolean(options.force), + governance.force_required_for_stale_lock, + settings.locks.wait_ms, + ); + let upsert; + try { + const previousRaw = await readFileIfExists(statusesPath); + let parsed; + try { + parsed = parseStatusDefsFile(previousRaw); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.GENERIC_FAILURE, + ); + } + // Re-check collisions against the CURRENT file under the lock: the pre-lock + // check used the registry loaded before acquiring schema-statuses, so a + // concurrent add-status could have written a colliding id/alias in between. + // The lock serializes writes; this serializes the collision decision too. + const fileAliasToId = buildStatusFileAliasMap(parsed.statuses); + try { + assertStatusTokensAvailable(normalized, fileAliasToId); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.USAGE, + ); + } + upsert = upsertStatusDef(parsed, normalized, baseDefinition); + // writeFileAtomic writes to a temp file then renames, so a failure leaves + // the existing statuses.json untouched; no manual rollback is needed. + await writeFileAtomic(statusesPath, serializeStatusDefsFile(upsert.file)); + warnings.push( + ...(await runActiveOnWriteHooks({ + path: statusesPath, + scope: "project", + op: "schema:add-status", + })), + ); + } finally { + await releaseLock(); + } + + return { + action: "add-status", + registered: true, + replaced: upsert.replaced, + status: upsert.definition, + file: { + path: statusesPath, + statuses: upsert.file.statuses.length, + }, + warnings: [...new Set(warnings)].sort((left, right) => + left.localeCompare(right), + ), + generated_at: nowIso(), + }; +} + +/** Implements run schema remove status for the public runtime surface of this module. */ +export async function runSchemaRemoveStatus( + id: string | undefined, + options: SchemaRemoveStatusCommandOptions, + global: GlobalOptions, +): Promise<SchemaRemoveStatusResult> { + const pmRoot = resolvePmRoot(process.cwd(), global.path); + await ensureInitialized(pmRoot); + + const settings = await readSettings(pmRoot); + const schema = normalizeRuntimeSchemaSettings(settings.schema); + const statusesPath = statusesPathFor(pmRoot, schema); + + const warnings: string[] = []; + const author = toAuthor(options.author, settings.author_default); + const governance = resolveGovernanceKnobs(settings); + + const releaseLock = await acquireLock( + pmRoot, + SCHEMA_STATUSES_LOCK_ID, + settings.locks.ttl_seconds, + author, + Boolean(options.force), + governance.force_required_for_stale_lock, + settings.locks.wait_ms, + ); + let removal; + try { + const previousRaw = await readFileIfExists(statusesPath); + let parsed; + try { + parsed = parseStatusDefsFile(previousRaw); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.GENERIC_FAILURE, + ); + } + try { + removal = removeStatusDef(parsed, id); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.USAGE, + ); + } + if (removal.removed) { + // Only emit the advisory orphan-count warning once a removable custom + // status was actually removed; a no-op/unknown removal would otherwise + // surface a misleading items_using_status:* warning. The count reuses the + // already-loaded settings (no disk re-read) and is non-blocking. + const removedId = normalizeStatusToken(removal.definition?.id ?? id); + // Removing a status that a workflow default still points at would leave + // pm close / default pm create resolving to an unregistered status. Warn + // (non-blocking, consistent with remove-type) so the operator re-points + // the workflow slot via schema/workflows.json or pm config. + await appendRemovedStatusWarnings( + warnings, + pmRoot, + settings, + schema, + removedId, + ); + await writeFileAtomic( + statusesPath, + serializeStatusDefsFile(removal.file), + ); + warnings.push( + ...(await runActiveOnWriteHooks({ + path: statusesPath, + scope: "project", + op: "schema:remove-status", + })), + ); + } + } finally { + await releaseLock(); + } + + return { + action: "remove-status", + removed: removal.removed, + ...(removal.definition ? { status: removal.definition } : {}), + file: { + path: statusesPath, + statuses: removal.file.statuses.length, + }, + warnings: [...new Set(warnings)].sort((left, right) => + left.localeCompare(right), + ), + generated_at: nowIso(), + }; +} + +function toSchemaTypeSummary( + definition: ResolvedItemTypeDefinition, +): SchemaTypeSummary { + return { + name: definition.name, + folder: definition.folder, + aliases: Array.isArray(definition.aliases) ? [...definition.aliases] : [], + ...(definition.default_status + ? { default_status: definition.default_status } + : {}), + ...(definition.description ? { description: definition.description } : {}), + }; +} + +function toSchemaTypeDefinition( + definition: ResolvedItemTypeDefinition, + source: SchemaTypeDefinitionResult["source"], + extensionProvenance?: SchemaTypeDefinitionResult["extension"], +): SchemaTypeDefinitionResult { + return { + ...toSchemaTypeSummary(definition), + source, + ...(extensionProvenance ? { extension: extensionProvenance } : {}), + required_create_fields: [...definition.required_create_fields], + required_create_repeatables: [...definition.required_create_repeatables], + options: structuredClone(definition.options), + command_option_policies: structuredClone( + definition.command_option_policies, + ), + }; +} + +function toSchemaStatusSummary( + definition: RuntimeStatusDefinition, +): SchemaStatusSummary { + return { + id: definition.id, + source: BUILTIN_STATUS_IDS.has(definition.id) ? "builtin" : "custom", + roles: Array.isArray(definition.roles) ? [...definition.roles] : [], + aliases: Array.isArray(definition.aliases) ? [...definition.aliases] : [], + ...(definition.description ? { description: definition.description } : {}), + ...(typeof definition.order === "number" + ? { order: definition.order } + : {}), + }; +} + +function classifyTypeSource( + name: string, + customNames: Set<string>, + extensionNames: Set<string>, +): SchemaTypeDefinitionResult["source"] { + const lowerName = name.toLowerCase(); + if (extensionNames.has(lowerName)) { + return "extension"; + } + if (customNames.has(lowerName)) { + return "custom"; + } + return "builtin"; +} + +function collectExtensionTypeProvenance(): Map< + string, + SchemaTypeDefinitionResult["extension"] +> { + const provenance = new Map<string, SchemaTypeDefinitionResult["extension"]>(); + const registrations = getActiveExtensionRegistrations(); + for (const registration of registrations?.item_types ?? []) { + const rawTypes = (registration as { types?: unknown[] }).types; + if (!Array.isArray(rawTypes)) { + continue; + } + for (const rawType of rawTypes) { + if ( + typeof rawType !== "object" || + rawType === null || + Array.isArray(rawType) + ) { + continue; + } + const name = (rawType as { name?: unknown }).name; + if (typeof name === "string" && name.trim().length > 0) { + provenance.set(name.trim().toLowerCase(), { + layer: registration.layer, + name: registration.name, + }); + } + } + } + return provenance; +} + +function buildSchemaStatusSummaries( + schema: ReturnType<typeof normalizeRuntimeSchemaSettings>, +): { builtin: SchemaStatusSummary[]; custom: SchemaStatusSummary[] } { + const registry = resolveRuntimeStatusRegistry(schema); + const builtin: SchemaStatusSummary[] = []; + const custom: SchemaStatusSummary[] = []; + for (const definition of registry.definitions) { + const summary = toSchemaStatusSummary(definition); + if (summary.source === "builtin") { + builtin.push(summary); + } else { + custom.push(summary); + } + } + return { builtin, custom }; +} + +async function loadSchemaInspectionContext(global: GlobalOptions): Promise<{ + pmRoot: string; + typesPath: string; + statusesPath: string; + byType: Record<string, ResolvedItemTypeDefinition>; + customNames: Set<string>; + extensionProvenance: Map<string, SchemaTypeDefinitionResult["extension"]>; + schema: ReturnType<typeof normalizeRuntimeSchemaSettings>; +}> { + const pmRoot = resolvePmRoot(process.cwd(), global.path); + if (!(await pathExists(getSettingsPath(pmRoot)))) { + throw new PmCliError( + `Tracker is not initialized at ${pmRoot}. Run pm init first.`, + EXIT_CODE.NOT_FOUND, + ); + } + const settings = await readSettings(pmRoot); + const schema = normalizeRuntimeSchemaSettings(settings.schema); + const typesPath = filePathForSchemaSection( + pmRoot, + schema.files.types, + DEFAULT_RUNTIME_SCHEMA_FILE_PATHS.types, + ); + const statusesPath = statusesPathFor(pmRoot, schema); + const registry = resolveItemTypeRegistry( + settings, + getActiveExtensionRegistrations(), + ); + const extensionProvenance = collectExtensionTypeProvenance(); + const customNames = new Set( + (settings.item_types?.definitions ?? []) + .map((definition) => definition.name.trim().toLowerCase()) + .filter((name) => name.length > 0 && !extensionProvenance.has(name)), + ); + return { + pmRoot, + typesPath, + statusesPath, + byType: registry.by_type, + customNames, + extensionProvenance, + schema, + }; +} + +/** Implements run schema list for the public runtime surface of this module. */ +export async function runSchemaList( + global: GlobalOptions, +): Promise<SchemaListResult> { + const context = await loadSchemaInspectionContext(global); + const builtin: SchemaTypeSummary[] = []; + const custom: SchemaTypeSummary[] = []; + const extension: SchemaTypeSummary[] = []; + for (const definition of Object.values(context.byType).sort((left, right) => + left.name.localeCompare(right.name), + )) { + const source = classifyTypeSource( + definition.name, + context.customNames, + new Set(context.extensionProvenance.keys()), + ); + const summary = toSchemaTypeSummary(definition); + if (source === "extension") { + extension.push(summary); + } else if (source === "custom") { + custom.push(summary); + } else { + builtin.push(summary); + } + } + const statusSummaries = buildSchemaStatusSummaries(context.schema); + const fieldSummaries = resolveRuntimeFieldRegistry( + context.schema, + ).definitions.map(toSchemaFieldSummary); + return { + action: "list", + builtin, + custom, + extension, + counts: { + builtin: builtin.length, + custom: custom.length, + extension: extension.length, + total: builtin.length + custom.length + extension.length, + }, + statuses: { + builtin: statusSummaries.builtin, + custom: statusSummaries.custom, + counts: { + builtin: statusSummaries.builtin.length, + custom: statusSummaries.custom.length, + total: statusSummaries.builtin.length + statusSummaries.custom.length, + }, + }, + fields: { + custom: fieldSummaries, + counts: { + total: fieldSummaries.length, + }, + }, + file: { + path: context.typesPath, + }, + generated_at: nowIso(), + }; +} + +/** Implements run schema show for the public runtime surface of this module. */ +export async function runSchemaShow( + name: string | undefined, + global: GlobalOptions, +): Promise<SchemaShowResult> { + const typeName = (name ?? "").trim(); + if (typeName.length === 0) { + throw new PmCliError("Type name must not be empty.", EXIT_CODE.USAGE); + } + const context = await loadSchemaInspectionContext(global); + const match = Object.values(context.byType).find( + (definition) => + definition.name.toLowerCase() === typeName.toLowerCase() || + definition.aliases.some( + (alias) => alias.toLowerCase() === typeName.toLowerCase(), + ), + ); + if (!match) { + throw new PmCliError( + `Unknown item type "${typeName}". Run pm schema list to inspect registered types, or pm schema add-type "${escapeForDoubleQuotes(typeName)}" to register it.`, + EXIT_CODE.NOT_FOUND, + { code: "unknown_item_type" }, + ); + } + return { + action: "show", + type: toSchemaTypeDefinition( + match, + classifyTypeSource( + match.name, + context.customNames, + new Set(context.extensionProvenance.keys()), + ), + context.extensionProvenance.get(match.name.toLowerCase()), + ), + file: { + path: context.typesPath, + }, + generated_at: nowIso(), + }; +} + +/** Implements run schema show status for the public runtime surface of this module. */ +export async function runSchemaShowStatus( + id: string | undefined, + global: GlobalOptions, +): Promise<SchemaShowStatusResult> { + const statusToken = normalizeStatusToken(id); + if (statusToken.length === 0) { + throw new PmCliError("Status id must not be empty.", EXIT_CODE.USAGE); + } + const context = await loadSchemaInspectionContext(global); + const statusRegistry = resolveRuntimeStatusRegistry(context.schema); + const resolvedId = statusRegistry.alias_to_id.get(statusToken) ?? statusToken; + const match = statusRegistry.by_id.get(resolvedId); + if (!match) { + throw new PmCliError( + `Unknown status "${id}". Run pm schema list to inspect registered statuses, or pm schema add-status "${escapeForDoubleQuotes( + id ?? "", + )}" to register it.`, + EXIT_CODE.NOT_FOUND, + { code: "unknown_status" }, + ); + } + return { + action: "show-status", + status: toSchemaStatusSummary(match), + file: { + path: context.statusesPath, + }, + generated_at: nowIso(), + }; +} + +/** Implements run schema add field for the public runtime surface of this module. */ +export async function runSchemaAddField( + key: string | undefined, + options: SchemaAddFieldCommandOptions, + global: GlobalOptions, +): Promise<SchemaAddFieldResult> { + const pmRoot = resolvePmRoot(process.cwd(), global.path); + await ensureInitialized(pmRoot); + + let normalized; + try { + normalized = normalizeAddFieldInput({ + key, + type: options.type, + commands: options.commands, + description: options.description, + cliFlag: options.cliFlag, + aliases: options.alias, + required: options.required, + requiredOnCreate: options.requiredOnCreate, + allowUnset: options.allowUnset, + requiredTypes: options.requiredTypes, + }); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.USAGE, + ); + } + + const settings = await readSettings(pmRoot); + const schema = normalizeRuntimeSchemaSettings(settings.schema); + const fieldsPath = fieldsPathFor(pmRoot, schema); + + const warnings: string[] = []; + const author = toAuthor(options.author, settings.author_default); + const governance = resolveGovernanceKnobs(settings); + + const releaseLock = await acquireLock( + pmRoot, + SCHEMA_FIELDS_LOCK_ID, + settings.locks.ttl_seconds, + author, + Boolean(options.force), + governance.force_required_for_stale_lock, + settings.locks.wait_ms, + ); + let upsert; + try { + const previousRaw = await readFileIfExists(fieldsPath); + let parsed; + try { + parsed = parseFieldsFile(previousRaw); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.GENERIC_FAILURE, + ); + } + upsert = upsertField(parsed, normalized); + await writeFileAtomic(fieldsPath, serializeFieldsFile(upsert.file)); + warnings.push( + ...(await runActiveOnWriteHooks({ + path: fieldsPath, + scope: "project", + op: "schema:add-field", + })), + ); + } finally { + await releaseLock(); + } + + return { + action: "add-field", + registered: true, + replaced: upsert.replaced, + field: upsert.definition, + file: { + path: fieldsPath, + fields: upsert.file.fields.length, + }, + warnings: [...new Set(warnings)].sort((left, right) => + left.localeCompare(right), + ), + generated_at: nowIso(), + }; +} + +/** Counts items carrying a non-empty value for the custom field's metadata key. Advisory only (non-blocking) so remove-field can surface how many items would lose a managed column. Reuses the already-loaded settings (no disk re-read). */ +async function countItemsUsingField( + pmRoot: string, + settings: PmSettings, + schema: ReturnType<typeof normalizeRuntimeSchemaSettings>, + metadataKey: string, +): Promise<number> { + const registry = resolveItemTypeRegistry( + settings, + getActiveExtensionRegistrations(), + ); + const items = await listAllItemMetadataLight( + pmRoot, + settings.item_format, + registry.type_to_folder, + [], + schema, + ); + return items.filter((item) => { + const value = (item as unknown as Record<string, unknown>)[metadataKey]; + if (value === undefined || value === null) { + return false; + } + if (Array.isArray(value)) { + return value.length > 0; + } + if (typeof value === "string") { + return value.trim().length > 0; + } + return true; + }).length; +} + +/** Implements run schema remove field for the public runtime surface of this module. */ +export async function runSchemaRemoveField( + key: string | undefined, + options: SchemaRemoveFieldCommandOptions, + global: GlobalOptions, +): Promise<SchemaRemoveFieldResult> { + const pmRoot = resolvePmRoot(process.cwd(), global.path); + await ensureInitialized(pmRoot); + + const settings = await readSettings(pmRoot); + const schema = normalizeRuntimeSchemaSettings(settings.schema); + const fieldsPath = fieldsPathFor(pmRoot, schema); + + const warnings: string[] = []; + const author = toAuthor(options.author, settings.author_default); + const governance = resolveGovernanceKnobs(settings); + + const releaseLock = await acquireLock( + pmRoot, + SCHEMA_FIELDS_LOCK_ID, + settings.locks.ttl_seconds, + author, + Boolean(options.force), + governance.force_required_for_stale_lock, + settings.locks.wait_ms, + ); + let removal; + try { + const previousRaw = await readFileIfExists(fieldsPath); + let parsed; + try { + parsed = parseFieldsFile(previousRaw); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.GENERIC_FAILURE, + ); + } + try { + removal = removeField(parsed, key); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.USAGE, + ); + } + if (removal.removed) { + const removedKey = normalizeFieldKey(removal.definition?.key ?? key); + const metadataKey = normalizeFieldKey( + removal.definition?.metadata_key ?? + removal.definition?.front_matter_key ?? + removedKey, + ); + if (metadataKey.length > 0) { + const usingField = await countItemsUsingField( + pmRoot, + settings, + schema, + metadataKey, + ); + if (usingField > 0) { + warnings.push(`items_using_field:${usingField}`); + } + } + await writeFileAtomic(fieldsPath, serializeFieldsFile(removal.file)); + warnings.push( + ...(await runActiveOnWriteHooks({ + path: fieldsPath, + scope: "project", + op: "schema:remove-field", + })), + ); + } + } finally { + await releaseLock(); + } + + return { + action: "remove-field", + removed: removal.removed, + ...(removal.definition ? { field: removal.definition } : {}), + file: { + path: fieldsPath, + fields: removal.file.fields.length, + }, + warnings: [...new Set(warnings)].sort((left, right) => + left.localeCompare(right), + ), + generated_at: nowIso(), + }; +} + +/** Implements run schema list fields for the public runtime surface of this module. */ +export async function runSchemaListFields( + global: GlobalOptions, +): Promise<SchemaListFieldsResult> { + const context = await loadSchemaInspectionContext(global); + const fields = resolveRuntimeFieldRegistry(context.schema).definitions.map( + toSchemaFieldSummary, + ); + return { + action: "list-fields", + fields, + counts: { + total: fields.length, + }, + file: { + path: fieldsPathFor(context.pmRoot, context.schema), + }, + generated_at: nowIso(), + }; +} + +/** Implements run schema show field for the public runtime surface of this module. */ +export async function runSchemaShowField( + key: string | undefined, + global: GlobalOptions, +): Promise<SchemaShowFieldResult> { + const fieldKey = normalizeFieldKey(key); + if (fieldKey.length === 0) { + throw new PmCliError("Field key must not be empty.", EXIT_CODE.USAGE); + } + const context = await loadSchemaInspectionContext(global); + const match = resolveRuntimeFieldRegistry(context.schema).by_key.get( + fieldKey, + ); + if (!match) { + throw new PmCliError( + `Unknown custom field "${key}". Run pm schema list-fields to inspect registered fields, or pm schema add-field "${escapeForDoubleQuotes( + key ?? "", + )}" to register it.`, + EXIT_CODE.NOT_FOUND, + { code: "unknown_field" }, + ); + } + return { + action: "show-field", + field: toSchemaFieldSummary(match), + file: { + path: fieldsPathFor(context.pmRoot, context.schema), + }, + generated_at: nowIso(), + }; +} + +/** Implements run schema apply preset for the public runtime surface of this module. */ +export async function runSchemaApplyPreset( + preset: string | undefined, + options: SchemaApplyPresetCommandOptions, + global: GlobalOptions, +): Promise<SchemaApplyPresetResult> { + const pmRoot = resolvePmRoot(process.cwd(), global.path); + await ensureInitialized(pmRoot); + + let presetName: TypePresetName; + try { + const normalized = normalizeTypePresetName(preset); + if (normalized === undefined) { + throw new Error( + `Type preset name is required. Allowed: ${TYPE_PRESET_NAMES.join(", ")}.`, + ); + } + presetName = normalized; + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.USAGE, + ); + } + + const settings = await readSettings(pmRoot); + const schema = normalizeRuntimeSchemaSettings(settings.schema); + const typesPath = typesPathFor(pmRoot, schema); + + const warnings: string[] = []; + const author = toAuthor(options.author, settings.author_default); + const governance = resolveGovernanceKnobs(settings); + + const registered: string[] = []; + const replaced: string[] = []; + let definitionsCount: number; + const releaseLock = await acquireLock( + pmRoot, + SCHEMA_TYPES_LOCK_ID, + settings.locks.ttl_seconds, + author, + Boolean(options.force), + governance.force_required_for_stale_lock, + settings.locks.wait_ms, + ); + try { + const previousRaw = await readFileIfExists(typesPath); + let parsed; + try { + parsed = parseItemTypesFile(previousRaw); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.GENERIC_FAILURE, + ); + } + let nextFile = parsed; + for (const normalized of resolveTypePresetDefinitions(presetName)) { + const upsert = upsertItemType(nextFile, normalized); + nextFile = upsert.file; + (upsert.replaced ? replaced : registered).push(upsert.definition.name); + } + definitionsCount = nextFile.definitions.length; + await writeFileAtomic(typesPath, serializeItemTypesFile(nextFile)); + await ensureTypeFolderScaffold( + pmRoot, + resolveTypePresetDefinitions(presetName), + warnings, + "schema:apply-preset-folder", + ); + warnings.push( + ...(await runActiveOnWriteHooks({ + path: typesPath, + scope: "project", + op: "schema:apply-preset", + })), + ); + } finally { + await releaseLock(); + } + + return { + action: "apply-preset", + preset: presetName, + registered, + replaced, + file: { + path: typesPath, + definitions: definitionsCount, + }, + warnings: [...new Set(warnings)].sort((left, right) => + left.localeCompare(right), + ), + generated_at: nowIso(), + }; +} + +/** Validates the positive inference threshold while preserving the command default. */ +function resolveSchemaInferenceMinCount(value: number | undefined): number { + if (value === undefined) { + return 10; + } + if (!Number.isInteger(value) || value < 1) { + throw new PmCliError( + "--min-count must be a positive integer (>= 1).", + EXIT_CODE.USAGE, + ); + } + return value; +} + +/** Implements run schema infer types for the public runtime surface of this module. */ +export async function runSchemaInferTypes( + options: SchemaAddTypeInferCommandOptions, + global: GlobalOptions, +): Promise<SchemaAddTypeInferResult> { + const pmRoot = resolvePmRoot(process.cwd(), global.path); + await ensureInitialized(pmRoot); + + const settings = await readSettings(pmRoot); + const schema = normalizeRuntimeSchemaSettings(settings.schema); + const typesPath = typesPathFor(pmRoot, schema); + const minCount = resolveSchemaInferenceMinCount(options.minCount); + + const registry = resolveItemTypeRegistry( + settings, + getActiveExtensionRegistrations(), + ); + const items = await listAllItemMetadataLight( + pmRoot, + settings.item_format, + registry.type_to_folder, + [], + schema, + ); + const titles = items.map((item) => + typeof item.title === "string" ? item.title : "", + ); + const candidates = inferTypesFromTitles(titles, { minCount }); + + const warnings: string[] = []; + const registered: string[] = []; + const replaced: string[] = []; + const skipped: Array<{ name: string; reason: string }> = []; + let definitionsCount: number; + + // Dry-run preview by default: only --apply mutates schema/types.json. + if (!options.apply) { + let parsed; + try { + parsed = parseItemTypesFile(await readFileIfExists(typesPath)); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.GENERIC_FAILURE, + ); + } + definitionsCount = parsed.definitions.length; + return { + action: "infer-types", + applied: false, + min_count: minCount, + candidates, + registered, + replaced, + skipped, + file: { + path: typesPath, + definitions: definitionsCount, + }, + warnings, + generated_at: nowIso(), + }; + } + + const author = toAuthor(options.author, settings.author_default); + const governance = resolveGovernanceKnobs(settings); + const releaseLock = await acquireLock( + pmRoot, + SCHEMA_TYPES_LOCK_ID, + settings.locks.ttl_seconds, + author, + Boolean(options.force), + governance.force_required_for_stale_lock, + settings.locks.wait_ms, + ); + try { + const previousRaw = await readFileIfExists(typesPath); + let parsed; + try { + parsed = parseItemTypesFile(previousRaw); + } catch (error) { + throw new PmCliError( + error instanceof Error ? error.message : String(error), + EXIT_CODE.GENERIC_FAILURE, + ); + } + let nextFile = parsed; + for (const candidate of candidates) { + if (candidate.shadows_builtin) { + skipped.push({ name: candidate.name, reason: "shadows_builtin" }); + continue; + } + let normalized; + try { + normalized = normalizeAddTypeInput({ name: candidate.name }); + } catch { + // A title prefix that does not normalize to a valid type token (rare, + // given the inference pattern) is skipped rather than aborting the batch. + skipped.push({ name: candidate.name, reason: "invalid_type_token" }); + continue; + } + const upsert = upsertItemType(nextFile, normalized); + nextFile = upsert.file; + (upsert.replaced ? replaced : registered).push(upsert.definition.name); + } + definitionsCount = nextFile.definitions.length; + await writeFileAtomic(typesPath, serializeItemTypesFile(nextFile)); + await ensureTypeFolderScaffold( + pmRoot, + nextFile.definitions.filter( + (definition) => + registered.includes(definition.name) || + replaced.includes(definition.name), + ), + warnings, + "schema:infer-types-folder", + ); + warnings.push( + ...(await runActiveOnWriteHooks({ + path: typesPath, + scope: "project", + op: "schema:infer-types", + })), + ); + } finally { + await releaseLock(); + } + + return { + action: "infer-types", + applied: true, + min_count: minCount, + candidates, + registered, + replaced, + skipped, + file: { + path: typesPath, + definitions: definitionsCount, + }, + warnings: [...new Set(warnings)].sort((left, right) => + left.localeCompare(right), + ), + generated_at: nowIso(), + }; +} + +/** Implements format schema add type human for the public runtime surface of this module. */ +export function formatSchemaAddTypeHuman(result: SchemaAddTypeResult): string { + const verb = result.replaced ? "Updated" : "Registered"; + const aliasSuffix = + result.type.aliases && result.type.aliases.length > 0 + ? ` (aliases: ${result.type.aliases.join(", ")})` + : ""; + return `${verb} custom item type "${result.type.name}"${aliasSuffix} in ${result.file.path}. Run: pm create "${escapeForDoubleQuotes(result.type.name)}" "<title>"`; +} + +/** Implements format schema remove type human for the public runtime surface of this module. */ +export function formatSchemaRemoveTypeHuman( + result: SchemaRemoveTypeResult, +): string { + if (!result.removed) { + return `No custom item type matched; nothing removed from ${result.file.path}.`; + } + const name = result.type?.name ?? "(unknown)"; + return `Removed custom item type "${name}" from ${result.file.path}.`; +} + +/** Implements format schema add status human for the public runtime surface of this module. */ +export function formatSchemaAddStatusHuman( + result: SchemaAddStatusResult, +): string { + const verb = result.replaced ? "Updated" : "Registered"; + const roleSuffix = + result.status.roles && result.status.roles.length > 0 + ? ` (roles: ${result.status.roles.join(", ")})` + : ""; + const aliasSuffix = + result.status.aliases && result.status.aliases.length > 0 + ? ` (aliases: ${result.status.aliases.join(", ")})` + : ""; + return `${verb} status "${result.status.id}"${roleSuffix}${aliasSuffix} in ${result.file.path}.`; +} + +/** Implements format schema remove status human for the public runtime surface of this module. */ +export function formatSchemaRemoveStatusHuman( + result: SchemaRemoveStatusResult, +): string { + if (!result.removed) { + return `No custom status matched; nothing removed from ${result.file.path}.`; + } + const id = result.status?.id ?? "(unknown)"; + return `Removed custom status "${id}" from ${result.file.path}.`; +} + +/** Implements format schema list human for the public runtime surface of this module. */ +export function formatSchemaListHuman(result: SchemaListResult): string { + const lines = [ + `Schema types: ${result.counts.total} total (${result.counts.builtin} builtin, ${result.counts.custom} custom, ${result.counts.extension} extension)`, + ]; + for (const [label, entries] of [ + ["builtin", result.builtin], + ["custom", result.custom], + ["extension", result.extension], + ] as const) { + if (entries.length === 0) { + continue; + } + lines.push(`${label}: ${entries.map((entry) => entry.name).join(", ")}`); + } + lines.push( + `statuses: ${result.statuses.counts.total} total (${result.statuses.counts.builtin} builtin, ${result.statuses.counts.custom} custom)`, + ); + for (const [label, entries] of [ + ["builtin statuses", result.statuses.builtin], + ["custom statuses", result.statuses.custom], + ] as const) { + if (entries.length === 0) { + continue; + } + lines.push(`${label}: ${entries.map((entry) => entry.id).join(", ")}`); + } + lines.push(`custom fields: ${result.fields.counts.total} total`); + if (result.fields.custom.length > 0) { + lines.push( + `fields: ${result.fields.custom.map((field) => `${field.key} (${field.type}, ${field.cli_flag})`).join(", ")}`, + ); + } + lines.push(`Inspect one: pm schema show <Type>`); + lines.push(`Inspect one status: pm schema show-status <status>`); + lines.push(`Inspect one field: pm schema show-field <key>`); + return lines.join("\n"); +} + +/** Implements format schema show human for the public runtime surface of this module. */ +export function formatSchemaShowHuman(result: SchemaShowResult): string { + const parts = [ + `type: ${result.type.name}`, + `source: ${result.type.source}`, + `folder: ${result.type.folder}`, + ]; + if (result.type.default_status) { + parts.push(`default_status: ${result.type.default_status}`); + } + if (result.type.aliases.length > 0) { + parts.push(`aliases: ${result.type.aliases.join(", ")}`); + } + if (result.type.description) { + parts.push(`description: ${result.type.description}`); + } + if (result.type.options.length > 0) { + parts.push( + `options: ${result.type.options.map((option) => option.key).join(", ")}`, + ); + } + return parts.join("\n"); +} + +/** Implements format schema show status human for the public runtime surface of this module. */ +export function formatSchemaShowStatusHuman( + result: SchemaShowStatusResult, +): string { + const parts = [ + `status: ${result.status.id}`, + `source: ${result.status.source}`, + ]; + if (result.status.roles.length > 0) { + parts.push(`roles: ${result.status.roles.join(", ")}`); + } + if (result.status.aliases.length > 0) { + parts.push(`aliases: ${result.status.aliases.join(", ")}`); + } + if (result.status.description) { + parts.push(`description: ${result.status.description}`); + } + if (typeof result.status.order === "number") { + parts.push(`order: ${result.status.order}`); + } + return parts.join("\n"); +} + +function formatFieldSummaryLine(field: SchemaFieldSummary): string { + const flags: string[] = []; + if (field.required) { + flags.push("required"); + } + if (field.required_on_create) { + flags.push("required_on_create"); + } + if (!field.allow_unset) { + flags.push("no_unset"); + } + const flagSuffix = flags.length > 0 ? ` [${flags.join(", ")}]` : ""; + return `${field.key} (${field.type}) ${field.cli_flag} commands=${field.commands.join("/")}${flagSuffix}`; +} + +/** Implements format schema add field human for the public runtime surface of this module. */ +export function formatSchemaAddFieldHuman( + result: SchemaAddFieldResult, +): string { + const verb = result.replaced ? "Updated" : "Registered"; + return `${verb} custom field "${result.field.key}" in ${result.file.path}. Use it: pm create "<title>" --${(result.field.cli_flag ?? result.field.key).replaceAll("_", "-")} <value>`; +} + +/** Implements format schema remove field human for the public runtime surface of this module. */ +export function formatSchemaRemoveFieldHuman( + result: SchemaRemoveFieldResult, +): string { + if (!result.removed) { + return `No custom field matched; nothing removed from ${result.file.path}.`; + } + const key = result.field?.key ?? "(unknown)"; + return `Removed custom field "${key}" from ${result.file.path}.`; +} + +/** Implements format schema list fields human for the public runtime surface of this module. */ +export function formatSchemaListFieldsHuman( + result: SchemaListFieldsResult, +): string { + const lines = [`Custom fields: ${result.counts.total} total`]; + for (const field of result.fields) { + lines.push(` ${formatFieldSummaryLine(field)}`); + } + lines.push(`Inspect one: pm schema show-field <key>`); + return lines.join("\n"); +} + +/** Implements format schema show field human for the public runtime surface of this module. */ +export function formatSchemaShowFieldHuman( + result: SchemaShowFieldResult, +): string { + const field = result.field; + const parts = [ + `field: ${field.key}`, + `type: ${field.type}`, + `cli_flag: ${field.cli_flag}`, + `commands: ${field.commands.join(", ")}`, + ]; + if (field.cli_aliases.length > 0) { + parts.push(`cli_aliases: ${field.cli_aliases.join(", ")}`); + } + if (field.description) { + parts.push(`description: ${field.description}`); + } + if (field.required) { + parts.push(`required: true`); + } + if (field.required_on_create) { + parts.push(`required_on_create: true`); + } + if (!field.allow_unset) { + parts.push(`allow_unset: false`); + } + if (field.required_types.length > 0) { + parts.push(`required_types: ${field.required_types.join(", ")}`); + } + return parts.join("\n"); +} + +/** Implements format schema apply preset human for the public runtime surface of this module. */ +export function formatSchemaApplyPresetHuman( + result: SchemaApplyPresetResult, +): string { + const registered = + result.registered.length > 0 + ? `registered: ${result.registered.join(", ")}` + : ""; + const replaced = + result.replaced.length > 0 ? `updated: ${result.replaced.join(", ")}` : ""; + const detail = [registered, replaced] + .filter((part) => part.length > 0) + .join("; "); + return `Applied "${result.preset}" type preset to ${result.file.path}${detail.length > 0 ? ` (${detail})` : ""}.`; +} + +/** Implements format schema infer types human for the public runtime surface of this module. */ +export function formatSchemaInferTypesHuman( + result: SchemaAddTypeInferResult, +): string { + if (result.candidates.length === 0) { + return `No title-prefix conventions found with at least ${result.min_count} items. Lower the threshold with --min-count <n>.`; + } + const lines = result.applied + ? [ + `Inferred and registered custom types from title prefixes (min-count ${result.min_count}):`, + ] + : [ + `Inferred custom type candidates from title prefixes (min-count ${result.min_count}, dry-run):`, + ]; + for (const candidate of result.candidates) { + const note = candidate.shadows_builtin + ? " [shadows built-in, skipped]" + : ""; + lines.push( + ` ${candidate.name} <- "${candidate.prefix}" (${candidate.count} items)${note}`, + ); + } + if (!result.applied) { + lines.push(`Re-run with --apply to register the non-shadowing candidates.`); + } else { + if (result.registered.length > 0) { + lines.push(`registered: ${result.registered.join(", ")}`); + } + if (result.replaced.length > 0) { + lines.push(`updated: ${result.replaced.join(", ")}`); + } + } + return lines.join("\n"); +} + +/* c8 ignore stop */ + +/** Re-export so register-mutation can surface the hint in usage examples without importing the core module directly. */ +export { buildInvalidTypeHint }; + +/** Public contract for test only schema command, shared by SDK and presentation-layer consumers. */ +export const _testOnlySchemaCommand = { + toAuthor, + workflowSlotsReferencing, +}; diff --git a/tests/fixtures/contracts/full.json b/tests/fixtures/contracts/full.json index 626edad11..8fd9691ba 100644 --- a/tests/fixtures/contracts/full.json +++ b/tests/fixtures/contracts/full.json @@ -2634,6 +2634,12 @@ { "command": "init", "flags": [ + { + "aliases": [ + "--prefix" + ], + "flag": "--id-prefix" + }, { "flag": "--preset" }, diff --git a/tests/integration/init-path-guard.integration.spec.ts b/tests/integration/init-path-guard.integration.spec.ts index 205d1e2c4..efca8ecd0 100644 --- a/tests/integration/init-path-guard.integration.spec.ts +++ b/tests/integration/init-path-guard.integration.spec.ts @@ -7,6 +7,87 @@ import { expectJsonErrorEnvelope } from "../helpers/jsonErrorEnvelope.js"; import { withTempPmPath } from "../helpers/withTempPmPath.js"; describe("init tracker-path guardrails", () => { + it("accepts flag-oriented id prefixes, preserves positional parity, and rejects conflicting inputs", async () => { + await withTempPmPath(async (context) => { + const flaggedRoot = path.join(context.tempRoot, "flagged-prefix"); + const flagged = context.runCli( + [ + "--pm-path", + flaggedRoot, + "init", + "--id-prefix", + "ops", + "--json", + "--yes", + ], + { expectJson: true }, + ); + expect(flagged.code).toBe(0); + expect(flagged.json.settings).toMatchObject({ id_prefix: "ops-" }); + + const aliasRoot = path.join(context.tempRoot, "alias-prefix"); + const aliased = context.runCli( + ["--pm-path", aliasRoot, "init", "--prefix", "svc", "--json", "--yes"], + { expectJson: true }, + ); + expect(aliased.code).toBe(0); + expect(aliased.json.settings).toMatchObject({ id_prefix: "svc-" }); + + const positionalRoot = path.join(context.tempRoot, "positional-prefix"); + const positional = context.runCli( + ["--pm-path", positionalRoot, "init", "app", "--json", "--yes"], + { expectJson: true }, + ); + expect(positional.code).toBe(0); + expect(positional.json.settings).toMatchObject({ id_prefix: "app-" }); + + const equivalentRoot = path.join(context.tempRoot, "equivalent-prefix"); + const equivalent = context.runCli( + [ + "--pm-path", + equivalentRoot, + "init", + "app", + "--id-prefix", + "app-", + "--json", + "--yes", + ], + { expectJson: true }, + ); + expect(equivalent.code).toBe(0); + expect(equivalent.json.settings).toMatchObject({ id_prefix: "app-" }); + + const conflict = context.runCli([ + "--pm-path", + path.join(context.tempRoot, "conflicting-prefix"), + "init", + "app", + "--id-prefix", + "ops", + "--json", + "--yes", + ]); + expect(conflict.code).toBe(2); + expectJsonErrorEnvelope(conflict.stderr, { + code: "init_id_prefix_conflict", + exit_code: 2, + }); + + const empty = context.runCli([ + "--pm-path", + path.join(context.tempRoot, "empty-prefix"), + "init", + "--id-prefix", + " ", + "--json", + "--yes", + ]); + expect(empty.code).toBe(2); + expect(empty.stderr).toContain("--id-prefix must not be empty"); + }); + }); + it("treats path-like init positionals as target tracker paths without mutating the caller tracker", async () => { await withTempPmPath(async (context) => { const projectRoot = path.join(context.tempRoot, "caller-project"); @@ -14,21 +95,43 @@ describe("init tracker-path guardrails", () => { const targetPmPath = path.join(context.tempRoot, "target-pm"); await mkdir(projectRoot, { recursive: true }); - const callerInit = context.runCli(["--pm-path", callerPmPath, "init", "pm", "--json", "--yes"], { - expectJson: true, - cwd: projectRoot, - }); + const callerInit = context.runCli( + ["--pm-path", callerPmPath, "init", "pm", "--json", "--yes"], + { + expectJson: true, + cwd: projectRoot, + }, + ); expect(callerInit.code).toBe(0); - const callerSettingsBefore = await readFile(path.join(callerPmPath, "settings.json"), "utf8"); + const callerSettingsBefore = await readFile( + path.join(callerPmPath, "settings.json"), + "utf8", + ); - const callerExtensionsBefore = await readdir(path.join(callerPmPath, "extensions")); - const targetInit = context.runCli(["init", targetPmPath, "--json", "--yes", "--author", "sandbox-agent", "--with-packages"], { - expectJson: true, - cwd: projectRoot, - }); + const callerExtensionsBefore = await readdir( + path.join(callerPmPath, "extensions"), + ); + const targetInit = context.runCli( + [ + "init", + targetPmPath, + "--json", + "--yes", + "--author", + "sandbox-agent", + "--with-packages", + ], + { + expectJson: true, + cwd: projectRoot, + }, + ); expect(targetInit.code).toBe(0); expect(targetInit.json.path).toBe(targetPmPath); - expect(targetInit.json.target).toEqual({ mode: "tracker-path", tracker_root: targetPmPath }); + expect(targetInit.json.target).toEqual({ + mode: "tracker-path", + tracker_root: targetPmPath, + }); const nextSteps = targetInit.json.next_steps as string[]; expect(nextSteps.slice(0, 3)).toEqual([ expect.stringContaining(`pm --pm-path ${targetPmPath} create`), @@ -36,24 +139,45 @@ describe("init tracker-path guardrails", () => { expect.stringContaining(`pm --pm-path ${targetPmPath} context`), ]); for (const step of nextSteps) { - const commands = step.match(/pm (?:--pm-path [^ ]+ )?[a-z][^,)]*/g) ?? []; - expect(commands.every((command) => command.startsWith(`pm --pm-path ${targetPmPath} `))).toBe(true); + const commands = + step.match(/pm (?:--pm-path [^ ]+ )?[a-z][^,)]*/g) ?? []; + expect( + commands.every((command) => + command.startsWith(`pm --pm-path ${targetPmPath} `), + ), + ).toBe(true); } - const scopedList = context.runCli(["--pm-path", targetPmPath, "list", "--json"], { expectJson: true, cwd: targetPmPath }); - const scopedContext = context.runCli(["--pm-path", targetPmPath, "context", "--json"], { expectJson: true, cwd: targetPmPath }); + const scopedList = context.runCli( + ["--pm-path", targetPmPath, "list", "--json"], + { expectJson: true, cwd: targetPmPath }, + ); + const scopedContext = context.runCli( + ["--pm-path", targetPmPath, "context", "--json"], + { expectJson: true, cwd: targetPmPath }, + ); expect(scopedList.code).toBe(0); expect(scopedContext.code).toBe(0); - const callerSettingsAfter = await readFile(path.join(callerPmPath, "settings.json"), "utf8"); - const callerExtensionsAfter = await readdir(path.join(callerPmPath, "extensions")); - const targetSettings = JSON.parse(await readFile(path.join(targetPmPath, "settings.json"), "utf8")) as { + const callerSettingsAfter = await readFile( + path.join(callerPmPath, "settings.json"), + "utf8", + ); + const callerExtensionsAfter = await readdir( + path.join(callerPmPath, "extensions"), + ); + const targetSettings = JSON.parse( + await readFile(path.join(targetPmPath, "settings.json"), "utf8"), + ) as { id_prefix?: string; author_default?: string; }; expect(callerSettingsAfter).toBe(callerSettingsBefore); expect(callerExtensionsAfter).toEqual(callerExtensionsBefore); - expect(targetInit.json.installed_packages).toMatchObject({ installed_all: true, installed_count: 12 }); + expect(targetInit.json.installed_packages).toMatchObject({ + installed_all: true, + installed_count: 12, + }); expect(targetSettings.id_prefix).toBe("pm-"); expect(targetSettings.author_default).toBe("sandbox-agent"); }); @@ -63,38 +187,72 @@ describe("init tracker-path guardrails", () => { await withTempPmPath(async (context) => { const workspaceRoot = path.join(context.tempRoot, "explicit-workspace"); const trackerRoot = path.join(workspaceRoot, ".agents", "pm"); - const initialized = context.runCli(["init", "acme", "--workspace", workspaceRoot, "--json", "--yes"], { - expectJson: true, - }); + const initialized = context.runCli( + ["init", "acme", "--workspace", workspaceRoot, "--json", "--yes"], + { + expectJson: true, + }, + ); expect(initialized.code).toBe(0); expect(initialized.json).toMatchObject({ path: trackerRoot, - target: { mode: "workspace-path", tracker_root: trackerRoot, workspace_root: workspaceRoot }, + target: { + mode: "workspace-path", + tracker_root: trackerRoot, + workspace_root: workspaceRoot, + }, settings: { id_prefix: "acme-" }, }); - expect(JSON.parse(await readFile(path.join(trackerRoot, "settings.json"), "utf8"))).toMatchObject({ id_prefix: "acme-" }); - expect(await readFile(path.join(workspaceRoot, ".gitignore"), "utf8")).toContain(".agents/pm/runtime/\n.agents/pm/search/"); + expect( + JSON.parse( + await readFile(path.join(trackerRoot, "settings.json"), "utf8"), + ), + ).toMatchObject({ id_prefix: "acme-" }); + expect( + await readFile(path.join(workspaceRoot, ".gitignore"), "utf8"), + ).toContain(".agents/pm/runtime/\n.agents/pm/search/"); - const discovered = context.runCli(["context", "--json"], { expectJson: true, cwd: workspaceRoot }); + const discovered = context.runCli(["context", "--json"], { + expectJson: true, + cwd: workspaceRoot, + }); expect(discovered.code).toBe(0); const nested = path.join(workspaceRoot, "nested"); await mkdir(nested, { recursive: true }); - const nestedInit = context.runCli(["init", "--json", "--yes"], { expectJson: true, cwd: nested }); + const nestedInit = context.runCli(["init", "--json", "--yes"], { + expectJson: true, + cwd: nested, + }); expect(nestedInit.code).toBe(0); - await expect(readFile(path.join(nested, ".gitignore"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + readFile(path.join(nested, ".gitignore"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); }); }); it("guards explicit --pm-path values that point at a workspace root", async () => { await withTempPmPath(async (context) => { - const workspaceRoot = path.join(context.tempRoot, "workspace-root-path-trap"); + const workspaceRoot = path.join( + context.tempRoot, + "workspace-root-path-trap", + ); await mkdir(path.join(workspaceRoot, ".git"), { recursive: true }); - await mkdir(path.join(workspaceRoot, ".agents", "pm"), { recursive: true }); - await writeFile(path.join(workspaceRoot, "package.json"), JSON.stringify({ name: "path-trap" }), "utf8"); + await mkdir(path.join(workspaceRoot, ".agents", "pm"), { + recursive: true, + }); + await writeFile( + path.join(workspaceRoot, "package.json"), + JSON.stringify({ name: "path-trap" }), + "utf8", + ); - const positionalGuarded = context.runCli(["init", workspaceRoot, "--json"]); + const positionalGuarded = context.runCli([ + "init", + workspaceRoot, + "--json", + ]); expect(positionalGuarded.code).toBe(2); expectJsonErrorEnvelope(positionalGuarded.stderr, { type: "urn:pm-cli:error:workspace_root_pm_path", @@ -102,7 +260,12 @@ describe("init tracker-path guardrails", () => { exit_code: 2, }); - const guarded = context.runCli(["--pm-path", workspaceRoot, "init", "--json"]); + const guarded = context.runCli([ + "--pm-path", + workspaceRoot, + "init", + "--json", + ]); expect(guarded.code).toBe(2); const envelope = expectJsonErrorEnvelope(guarded.stderr, { type: "urn:pm-cli:error:workspace_root_pm_path", @@ -110,16 +273,21 @@ describe("init tracker-path guardrails", () => { exit_code: 2, }); expect(envelope.why).toContain("tracker storage directory itself"); - expect(envelope.recovery?.next_best_command).toContain(path.join(workspaceRoot, ".agents", "pm")); + expect(envelope.recovery?.next_best_command).toContain( + path.join(workspaceRoot, ".agents", "pm"), + ); const rootEntries = await readdir(workspaceRoot); expect(rootEntries).not.toContain("history"); expect(rootEntries).not.toContain("tasks"); expect(rootEntries).not.toContain("runtime"); - const forced = context.runCli(["--pm-path", workspaceRoot, "init", "--json", "--yes", "--force"], { - expectJson: true, - }); + const forced = context.runCli( + ["--pm-path", workspaceRoot, "init", "--json", "--yes", "--force"], + { + expectJson: true, + }, + ); expect(forced.code).toBe(0); const forcedEntries = await readdir(workspaceRoot); expect(forcedEntries).toContain("history"); @@ -131,9 +299,13 @@ describe("init tracker-path guardrails", () => { await withTempPmPath(async (context) => { // `pm init <dir>` writes settings.json directly into <dir> (tracker-path mode). const workspaceRoot = path.join(context.tempRoot, "root-layout-ws"); - const init = context.runCli(["init", workspaceRoot, "--json", "--yes"], { expectJson: true }); + const init = context.runCli(["init", workspaceRoot, "--json", "--yes"], { + expectJson: true, + }); expect(init.code).toBe(0); - expect(init.json).toMatchObject({ target: { mode: "tracker-path", tracker_root: workspaceRoot } }); + expect(init.json).toMatchObject({ + target: { mode: "tracker-path", tracker_root: workspaceRoot }, + }); // Probe extension echoing the pm_root the runtime hands command handlers. await writeTestExtension({ @@ -166,19 +338,30 @@ describe("init tracker-path guardrails", () => { const bareEnv = { ...context.env }; delete bareEnv.PM_PATH; - const bareList = runDirectDistCli(["list", "--json"], { cwd: workspaceRoot, env: bareEnv, expectJson: true }); + const bareList = runDirectDistCli(["list", "--json"], { + cwd: workspaceRoot, + env: bareEnv, + expectJson: true, + }); expect(bareList.code).toBe(0); - const probe = runDirectDistCli(["probe", "root", "--json"], { cwd: workspaceRoot, env: bareEnv, expectJson: true }); - expect(probe.code).toBe(0); - expect(probe.json).toMatchObject({ ok: true, pm_root: workspaceRoot }); - - // The documented extension spawn-back pattern: `pm --pm-path <ctx.pm_root> stats`. - const spawnBack = runDirectDistCli(["--pm-path", workspaceRoot, "stats", "--json"], { + const probe = runDirectDistCli(["probe", "root", "--json"], { cwd: workspaceRoot, env: bareEnv, expectJson: true, }); + expect(probe.code).toBe(0); + expect(probe.json).toMatchObject({ ok: true, pm_root: workspaceRoot }); + + // The documented extension spawn-back pattern: `pm --pm-path <ctx.pm_root> stats`. + const spawnBack = runDirectDistCli( + ["--pm-path", workspaceRoot, "stats", "--json"], + { + cwd: workspaceRoot, + env: bareEnv, + expectJson: true, + }, + ); expect(spawnBack.code).toBe(0); }); }); diff --git a/tests/unit/commands/completion-command.spec.ts b/tests/unit/commands/completion-command.spec.ts index d94687af8..004ee6993 100644 --- a/tests/unit/commands/completion-command.spec.ts +++ b/tests/unit/commands/completion-command.spec.ts @@ -9,7 +9,11 @@ import { } from "../../../src/cli/commands/completion.js"; import { EXIT_CODE } from "../../../src/core/shared/constants.js"; import { PmCliError } from "../../../src/core/shared/errors.js"; -import { readSettings, writeSettings } from "../../../src/core/store/settings.js"; +import { + readSettings, + writeSettings, +} from "../../../src/core/store/settings.js"; +import { SCHEMA_SUBCOMMANDS } from "../../../src/sdk/schema.js"; import { withTempPmPath } from "../../helpers/withTempPmPath.js"; describe("generateBashScript", () => { @@ -142,16 +146,27 @@ describe("generateBashScript", () => { expect(bashScript).toContain("-y"); expect(bashScript).toContain("--verbose"); expect(bashScript).toContain("--type-preset"); + expect(bashScript).toContain("--id-prefix"); + expect(bashScript).toContain("--prefix"); const zshScript = generateZshScript(); const zshInitStart = zshScript.indexOf(" init)"); - const zshInitBlock = zshScript.slice(zshInitStart, zshScript.indexOf(" config)", zshInitStart)); + const zshInitBlock = zshScript.slice( + zshInitStart, + zshScript.indexOf(" config)", zshInitStart), + ); expect(zshInitStart).toBeGreaterThan(-1); expect(zshInitBlock).toContain("_arguments"); - expect(zshInitBlock).toContain("--preset[Governance preset for new setups]"); - expect(zshInitBlock).toContain("--defaults[Use non-interactive setup defaults]"); + expect(zshInitBlock).toContain( + "--preset[Governance preset for new setups]", + ); + expect(zshInitBlock).toContain( + "--defaults[Use non-interactive setup defaults]", + ); expect(zshScript).toContain("--agent-guidance[Agent guidance mode]"); expect(zshScript).toContain("--type-preset[Register domain item types]"); + expect(zshInitBlock).toContain("--id-prefix[Set the item ID prefix]"); + expect(zshInitBlock).toContain("--prefix[Alias for --id-prefix]"); expect(zshScript).toContain("Alias for --defaults"); expect(zshScript).toContain("--verbose["); @@ -159,6 +174,8 @@ describe("generateBashScript", () => { expect(fishScript).toContain("__fish_seen_subcommand_from init"); expect(fishScript).toContain("-l agent-guidance"); expect(fishScript).toContain("-l type-preset"); + expect(fishScript).toContain("-l id-prefix"); + expect(fishScript).toContain("-l prefix"); expect(fishScript).toContain("-s y -l yes"); expect(fishScript).toContain("-l verbose"); }); @@ -180,9 +197,13 @@ describe("generateBashScript", () => { const zshScript = generateZshScript(); expect(zshScript).toContain("update-many)"); - expect(zshScript).toContain("--replace-tests[Atomically replace linked tests with provided --test values]"); + expect(zshScript).toContain( + "--replace-tests[Atomically replace linked tests with provided --test values]", + ); expect(zshScript).toContain("--clear-tests[Clear linked tests]"); - expect(zshScript).toContain("--dep[Dependency seed id=<id>,kind=<kind>,author=<author>,created_at=<timestamp>]"); + expect(zshScript).toContain( + "--dep[Dependency seed id=<id>,kind=<kind>,author=<author>,created_at=<timestamp>]", + ); const fishScript = generateFishScript(); expect(fishScript).toContain("__fish_seen_subcommand_from update-many"); @@ -191,7 +212,6 @@ describe("generateBashScript", () => { expect(fishScript).toContain("-l reminder"); }); - it("includes append required --body flag from command contracts", () => { const bashScript = generateBashScript(); expect(bashScript).toContain("append)"); @@ -202,7 +222,6 @@ describe("generateBashScript", () => { expect(fishScript).toContain("-l body"); }); - it("includes deps ergonomics flags", () => { const script = generateBashScript(); expect(script).toContain("--format"); @@ -219,21 +238,33 @@ describe("generateBashScript", () => { expect(script).toContain("--delete"); const zshScript = generateZshScript(); - expect(zshScript).toContain("--stdin[Read comment text from stdin (supports multiline markdown)]"); - expect(zshScript).toContain("--file[Read comment text from file (supports multiline markdown)]:path"); + expect(zshScript).toContain( + "--stdin[Read comment text from stdin (supports multiline markdown)]", + ); + expect(zshScript).toContain( + "--file[Read comment text from file (supports multiline markdown)]:path", + ); expect(zshScript).toContain("--edit[Replace the comment at 1-based index"); - expect(zshScript).toContain("--delete[Delete the comment at 1-based index]:index"); + expect(zshScript).toContain( + "--delete[Delete the comment at 1-based index]:index", + ); const fishScript = generateFishScript(); expect(fishScript).toContain("__fish_seen_subcommand_from comments"); - expect(fishScript).toContain("-l stdin -d 'Read comment text from stdin (supports multiline markdown)'"); - expect(fishScript).toContain("-l file -d 'Read comment text from file (supports multiline markdown)'"); - expect(fishScript).toContain("-l edit -d 'Replace the comment at 1-based index"); - expect(fishScript).toContain("-l delete -d 'Delete the comment at 1-based index'"); + expect(fishScript).toContain( + "-l stdin -d 'Read comment text from stdin (supports multiline markdown)'", + ); + expect(fishScript).toContain( + "-l file -d 'Read comment text from file (supports multiline markdown)'", + ); + expect(fishScript).toContain( + "-l edit -d 'Replace the comment at 1-based index", + ); + expect(fishScript).toContain( + "-l delete -d 'Delete the comment at 1-based index'", + ); }); - - it("includes files/docs add-glob flag in bash completion", () => { const script = generateBashScript(); expect(script).toContain("--add"); @@ -304,7 +335,9 @@ describe("generateBashScript", () => { it("includes package lifecycle completions in bash with package-only declarative scaffolding", () => { const script = generateBashScript(); expect(script).toContain("package|packages)"); - expect(script).toContain("init scaffold install uninstall explore manage describe reload doctor catalog adopt adopt-all activate deactivate"); + expect(script).toContain( + "init scaffold install uninstall explore manage describe reload doctor catalog adopt adopt-all activate deactivate", + ); expect(script).toContain("--declarative"); expect(script).toContain("--catalog"); }); @@ -312,13 +345,17 @@ describe("generateBashScript", () => { it("includes fail-on-empty-test-run in bash test completions", () => { const script = generateBashScript(); expect(script).toContain("--override-linked-pm-context"); - expect(script).toContain("--fail-on-skipped --fail-on-empty-test-run --require-assertions-for-pm"); + expect(script).toContain( + "--fail-on-skipped --fail-on-empty-test-run --require-assertions-for-pm", + ); expect(script).toContain("--check-context --auto-pm-context"); }); it("includes test-all pagination flags in bash completion", () => { const script = generateBashScript(); - expect(script).toContain("--status --limit --offset --background --timeout --progress"); + expect(script).toContain( + "--status --limit --offset --background --timeout --progress", + ); }); it("includes gc dry-run and scope flags in bash completion", () => { @@ -334,7 +371,9 @@ describe("generateBashScript", () => { const zshScript = generateZshScript(); expect(zshScript).toContain("delete)"); - expect(zshScript).toContain("--dry-run[Preview the item file that would be deleted without mutating]"); + expect(zshScript).toContain( + "--dry-run[Preview the item file that would be deleted without mutating]", + ); const fishScript = generateFishScript(); expect(fishScript).toContain("__fish_seen_subcommand_from delete"); @@ -368,17 +407,27 @@ describe("generateBashScript", () => { it("includes history-compact flags across completion scripts", () => { const bashScript = generateBashScript(); expect(bashScript).toContain("history-compact)"); - expect(bashScript).toContain("--before --ids --all-over --closed --all-streams --min-entries --dry-run --author --message --force"); + expect(bashScript).toContain( + "--before --ids --all-over --closed --all-streams --min-entries --dry-run --author --message --force", + ); const zshScript = generateZshScript(); expect(zshScript).toContain("history-compact)"); - expect(zshScript).toContain("--before[Compact entries strictly before this version number or ISO timestamp]:before"); - expect(zshScript).toContain("--all-over[Bulk: compact every stream with more than N entries]:all-over"); + expect(zshScript).toContain( + "--before[Compact entries strictly before this version number or ISO timestamp]:before", + ); + expect(zshScript).toContain( + "--all-over[Bulk: compact every stream with more than N entries]:all-over", + ); const fishScript = generateFishScript(); expect(fishScript).toContain("__fish_seen_subcommand_from history-compact"); - expect(fishScript).toContain("-l before -d 'Compact entries strictly before this version number or ISO timestamp'"); - expect(fishScript).toContain("-l all-streams -d 'Bulk: compact every history stream regardless of lifecycle state'"); + expect(fishScript).toContain( + "-l before -d 'Compact entries strictly before this version number or ISO timestamp'", + ); + expect(fishScript).toContain( + "-l all-streams -d 'Bulk: compact every history stream regardless of lifecycle state'", + ); }); it("includes get projection flags", () => { @@ -388,12 +437,16 @@ describe("generateBashScript", () => { const zshScript = generateZshScript(); expect(zshScript).toContain("get)"); - expect(zshScript).toContain("--depth[Detail depth]:(brief standard deep full)"); + expect(zshScript).toContain( + "--depth[Detail depth]:(brief standard deep full)", + ); expect(zshScript).toContain("--full[Explicit full item read]"); const fishScript = generateFishScript(); expect(fishScript).toContain("__fish_seen_subcommand_from get"); - expect(fishScript).toContain("-l depth -d 'Detail depth' -r -a 'brief standard deep full'"); + expect(fishScript).toContain( + "-l depth -d 'Detail depth' -r -a 'brief standard deep full'", + ); expect(fishScript).toContain("-l full -d 'Explicit full item read'"); }); @@ -436,7 +489,10 @@ describe("generateBashScript", () => { expect(fish, `fish should contain -l ${flag}`).toContain(`-l ${flag}`); } const zshListBlockStart = zsh.indexOf("list|list-all|list-draft"); - const zshListBlock = zsh.slice(zshListBlockStart, zsh.indexOf("aggregate)", zshListBlockStart)); + const zshListBlock = zsh.slice( + zshListBlockStart, + zsh.indexOf("aggregate)", zshListBlockStart), + ); expect(zshListBlock).toContain("--has-files["); expect(zshListBlock).toContain("--no-files["); expect(zshListBlock).toContain("--filter-files-missing["); @@ -545,17 +601,35 @@ describe("generateZshScript", () => { expect(script).toContain("packages:Alias for package"); expect(script).toContain("create:Create a new project management item"); expect(script).toContain("completion:Generate shell completion"); - expect(script).toContain("guide:Browse local progressive-disclosure guides"); - expect(script).toContain("contracts:Show machine-readable command and schema contracts"); - expect(script).toContain("start-task:Lifecycle alias to claim and set in_progress"); - expect(script).toContain("pause-task:Lifecycle alias to reopen and release claim"); - expect(script).toContain("close-task:Lifecycle alias to close and release claim"); - expect(script).toContain("calendar:Show calendar views for deadlines and reminders"); + expect(script).toContain( + "guide:Browse local progressive-disclosure guides", + ); + expect(script).toContain( + "contracts:Show machine-readable command and schema contracts", + ); + expect(script).toContain( + "start-task:Lifecycle alias to claim and set in_progress", + ); + expect(script).toContain( + "pause-task:Lifecycle alias to reopen and release claim", + ); + expect(script).toContain( + "close-task:Lifecycle alias to close and release claim", + ); + expect(script).toContain( + "calendar:Show calendar views for deadlines and reminders", + ); expect(script).toContain("cal:Alias for calendar"); - expect(script).toContain("context:Show a token-efficient project context snapshot"); + expect(script).toContain( + "context:Show a token-efficient project context snapshot", + ); expect(script).toContain("ctx:Alias for context"); - expect(script).toContain("history-compact:Compact history streams into a synthetic baseline + retained tail"); - expect(script).toContain("history-redact:Redact sensitive literals/patterns and recompute history hashes"); + expect(script).toContain( + "history-compact:Compact history streams into a synthetic baseline + retained tail", + ); + expect(script).toContain( + "history-redact:Redact sensitive literals/patterns and recompute history hashes", + ); expect(script).toContain("plan:Agent-optimized Plan item workflow"); expect(script).toContain("notes:List or add notes for an item"); expect(script).toContain("learnings:List or add learnings for an item"); @@ -565,7 +639,9 @@ describe("generateZshScript", () => { it("includes type completions for relevant flags", () => { const script = generateZshScript(); - expect(script).toContain("Epic Feature Task Chore Issue Decision Event Reminder Milestone Meeting Plan"); + expect(script).toContain( + "Epic Feature Task Chore Issue Decision Event Reminder Milestone Meeting Plan", + ); expect(script).toContain("0 1 2 3 4"); expect(script).toContain("keyword semantic hybrid"); expect(script).toContain("bash zsh fish"); @@ -596,9 +672,15 @@ describe("generateZshScript", () => { it("places atomic claim flags only in the zsh claim block", () => { const script = generateZshScript(); const updateStart = script.indexOf(" update)"); - const updateBlock = script.slice(updateStart, script.indexOf(" update-many)", updateStart)); + const updateBlock = script.slice( + updateStart, + script.indexOf(" update-many)", updateStart), + ); const claimStart = script.indexOf(" claim)"); - const claimBlock = script.slice(claimStart, script.indexOf(" release)", claimStart)); + const claimBlock = script.slice( + claimStart, + script.indexOf(" release)", claimStart), + ); expect(updateBlock).not.toContain("--if-available"); expect(updateBlock).not.toContain("--next["); expect(claimBlock).toContain("--if-available"); @@ -646,24 +728,44 @@ describe("generateZshScript", () => { it("includes strict health flags in zsh completion", () => { const script = generateZshScript(); - expect(script).toContain("--strict-directories[Treat optional item-type directories as required failures]"); - expect(script).toContain("--verbose-stale-items[Include full stale vectorization ID lists in health output]"); - expect(script).toContain("--brief[Emit compact health details for low-token agent checks]"); - expect(script).toContain("--summary[Emit one-line-style health status with check names and warning count]"); - expect(script).toContain("--strict-exit[Return non-zero exit when health warnings are present]"); + expect(script).toContain( + "--strict-directories[Treat optional item-type directories as required failures]", + ); + expect(script).toContain( + "--verbose-stale-items[Include full stale vectorization ID lists in health output]", + ); + expect(script).toContain( + "--brief[Emit compact health details for low-token agent checks]", + ); + expect(script).toContain( + "--summary[Emit one-line-style health status with check names and warning count]", + ); + expect(script).toContain( + "--strict-exit[Return non-zero exit when health warnings are present]", + ); expect(script).toContain("--fail-on-warn[Alias for --strict-exit]"); }); it("includes extension doctor strict flags in zsh completion", () => { const script = generateZshScript(); - expect(script).toContain("--trace[Include registration traces in doctor deep diagnostics]"); + expect(script).toContain( + "--trace[Include registration traces in doctor deep diagnostics]", + ); expect(script).toContain( "--capability[Capability the init scaffold targets]:capability:(commands hooks search importers schema profile renderers parser preflight services)", ); - expect(script).toContain("--runtime-probe[Opt-in runtime activation probe for manage output]"); - expect(script).toContain("--fix-managed-state[Adopt unmanaged extensions before diagnostics/update checks]"); - expect(script).toContain("--strict-exit[Return non-zero exit when doctor warnings are present]"); - expect(script).toContain("--fail-on-warn[Alias for --strict-exit (doctor)]"); + expect(script).toContain( + "--runtime-probe[Opt-in runtime activation probe for manage output]", + ); + expect(script).toContain( + "--fix-managed-state[Adopt unmanaged extensions before diagnostics/update checks]", + ); + expect(script).toContain( + "--strict-exit[Return non-zero exit when doctor warnings are present]", + ); + expect(script).toContain( + "--fail-on-warn[Alias for --strict-exit (doctor)]", + ); }); it("includes zsh package lifecycle completions with package-only declarative scaffolding", () => { @@ -672,20 +774,32 @@ describe("generateZshScript", () => { expect(script).toContain( "1:package_action:(init scaffold install uninstall explore manage describe reload doctor catalog adopt adopt-all activate deactivate)", ); - expect(script).toContain("--declarative[Generate a composeExtension blueprint starter]"); - expect(script).toContain("--catalog[List bundled first-party package catalog entries]"); + expect(script).toContain( + "--declarative[Generate a composeExtension blueprint starter]", + ); + expect(script).toContain( + "--catalog[List bundled first-party package catalog entries]", + ); }); it("includes fail-on-empty-test-run in zsh test completions", () => { const script = generateZshScript(); - expect(script).toContain("--override-linked-pm-context[Force run-level --pm-context over per-linked-test pm_context_mode metadata]"); - expect(script).toContain("--fail-on-empty-test-run[Treat empty linked-test selections as failures]"); + expect(script).toContain( + "--override-linked-pm-context[Force run-level --pm-context over per-linked-test pm_context_mode metadata]", + ); + expect(script).toContain( + "--fail-on-empty-test-run[Treat empty linked-test selections as failures]", + ); }); it("includes test-all pagination flags in zsh completion", () => { const script = generateZshScript(); - expect(script).toContain("--limit[Limit matching items before running linked tests]:number"); - expect(script).toContain("--offset[Skip matching items before running linked tests]:number"); + expect(script).toContain( + "--limit[Limit matching items before running linked tests]:number", + ); + expect(script).toContain( + "--offset[Skip matching items before running linked tests]:number", + ); }); it("includes deterministic tag choices for zsh --tag flags", () => { @@ -701,9 +815,17 @@ describe("generateZshScript", () => { it("does not split continued zsh _arguments blocks with blank lines", () => { const script = generateZshScript(); - for (const command of ["search)", "update)", "update-many)", "context|ctx)"]) { + for (const command of [ + "search)", + "update)", + "update-many)", + "context|ctx)", + ]) { const blockStart = script.indexOf(` ${command}`); - const block = script.slice(blockStart, script.indexOf(" ;;", blockStart)); + const block = script.slice( + blockStart, + script.indexOf(" ;;", blockStart), + ); expect(blockStart).toBeGreaterThan(-1); expect(block).not.toMatch(/\\\n\s*\n/u); } @@ -755,7 +877,10 @@ describe("generateFishScript", () => { ["health", "project tracker health"], ["stats", "project tracker statistics"], ["history-compact", "synthetic baseline + retained tail"], - ["history-redact", "Redact sensitive literals/patterns and recompute history hashes"], + [ + "history-redact", + "Redact sensitive literals/patterns and recompute history hashes", + ], ["plan", "Agent-optimized Plan workflow"], ] as [string, string][]) { expect(script).toContain(`-a ${cmd}`); @@ -778,7 +903,9 @@ describe("generateFishScript", () => { it("includes type and status value completions", () => { const script = generateFishScript(); - expect(script).toContain("Epic Feature Task Chore Issue Decision Event Reminder Milestone Meeting Plan"); + expect(script).toContain( + "Epic Feature Task Chore Issue Decision Event Reminder Milestone Meeting Plan", + ); expect(script).toContain("0 1 2 3 4"); }); @@ -836,10 +963,18 @@ describe("generateFishScript", () => { const script = generateFishScript(); expect(script).toContain("__fish_seen_subcommand_from $package_cmd"); expect(script).toContain("complete -c pm -n __pm_no_subcommand -a package"); - expect(script).toContain("complete -c pm -n __pm_no_subcommand -a packages"); - expect(script).toContain("-l declarative -d 'Generate a composeExtension blueprint starter'"); - expect(script).toContain("-l catalog -d 'List bundled first-party package catalog entries'"); - expect(script).toContain("-l fail-on-warn -d 'Alias for --strict-exit (doctor)'"); + expect(script).toContain( + "complete -c pm -n __pm_no_subcommand -a packages", + ); + expect(script).toContain( + "-l declarative -d 'Generate a composeExtension blueprint starter'", + ); + expect(script).toContain( + "-l catalog -d 'List bundled first-party package catalog entries'", + ); + expect(script).toContain( + "-l fail-on-warn -d 'Alias for --strict-exit (doctor)'", + ); }); it("includes strict health flags in fish completion", () => { @@ -866,8 +1001,12 @@ describe("generateFishScript", () => { it("includes test-all pagination flags in fish completion", () => { const script = generateFishScript(); expect(script).toContain("__fish_seen_subcommand_from test-all"); - expect(script).toContain("-l limit -d 'Limit matching items before running linked tests'"); - expect(script).toContain("-l offset -d 'Skip matching items before running linked tests'"); + expect(script).toContain( + "-l limit -d 'Limit matching items before running linked tests'", + ); + expect(script).toContain( + "-l offset -d 'Skip matching items before running linked tests'", + ); }); it("includes __pm_no_subcommand helper function", () => { @@ -926,8 +1065,12 @@ describe("generateFishScript", () => { it("includes deterministic tag choices for fish --tag flags", () => { const script = generateFishScript(["Task"], ["beta", "alpha", "alpha"]); - expect(script).toContain("-l tag -d 'Filter by tag' -r -a 'alpha beta'"); - expect(script).toContain("-l tags -d 'Alias for --tag' -r -a 'alpha beta'"); + expect(script).toContain( + "-l tag -d 'Filter by tag' -r -a 'alpha beta'", + ); + expect(script).toContain( + "-l tags -d 'Alias for --tag' -r -a 'alpha beta'", + ); }); }); @@ -1022,30 +1165,48 @@ describe("runCompletion", () => { expect(bashResult.script).toContain("pm completion-types"); expect(bashResult.script).toContain('resolved="Bug Task"'); expect(bashResult.script).toContain('resolved="draft qa_review"'); - expect(bashResult.script).toContain('compgen -W "$(_pm_completion_status_choices)"'); - expect(bashResult.script).toContain('compgen -W "$(_pm_completion_type_choices)"'); + expect(bashResult.script).toContain( + 'compgen -W "$(_pm_completion_status_choices)"', + ); + expect(bashResult.script).toContain( + 'compgen -W "$(_pm_completion_type_choices)"', + ); const zshResult = runCompletion("zsh", ["Task"], [], false, runtime); expect(zshResult.script).toContain("_pm_status_choices()"); expect(zshResult.script).toContain("pm completion-statuses"); expect(zshResult.script).toContain('resolved="draft qa_review"'); - expect(zshResult.script).toContain('--status[Filter by status]:(${(f)"$(_pm_status_choices)"})'); + expect(zshResult.script).toContain( + '--status[Filter by status]:(${(f)"$(_pm_status_choices)"})', + ); expect(zshResult.script).not.toContain("_pm_type_choices()"); - const alphaFlagIndex = zshResult.script.indexOf("--alpha-segment[Runtime schema field flag]:value"); - const customerFlagIndex = zshResult.script.indexOf("--customer-segment[Runtime schema field flag]:value"); + const alphaFlagIndex = zshResult.script.indexOf( + "--alpha-segment[Runtime schema field flag]:value", + ); + const customerFlagIndex = zshResult.script.indexOf( + "--customer-segment[Runtime schema field flag]:value", + ); expect(alphaFlagIndex).toBeGreaterThan(-1); expect(customerFlagIndex).toBeGreaterThan(-1); expect(alphaFlagIndex).toBeLessThan(customerFlagIndex); - expect(zshResult.script).toContain("--customer-segment[Runtime schema field flag]:value"); + expect(zshResult.script).toContain( + "--customer-segment[Runtime schema field flag]:value", + ); const fishResult = runCompletion("fish", ["Task"], [], false, runtime); expect(fishResult.script).toContain("function __pm_status_choices"); expect(fishResult.script).toContain("pm completion-statuses"); expect(fishResult.script).toContain("set resolved 'draft qa_review'"); - expect(fishResult.script).toContain("-l status -d 'Filter by status' -r -a '(__pm_status_choices)'"); + expect(fishResult.script).toContain( + "-l status -d 'Filter by status' -r -a '(__pm_status_choices)'", + ); expect(fishResult.script).not.toContain("function __pm_type_choices"); - expect(fishResult.script).toContain("-l alpha-segment -d 'Runtime schema field flag' -r"); - expect(fishResult.script).toContain("-l customer-segment -d 'Runtime schema field flag' -r"); + expect(fishResult.script).toContain( + "-l alpha-segment -d 'Runtime schema field flag' -r", + ); + expect(fishResult.script).toContain( + "-l customer-segment -d 'Runtime schema field flag' -r", + ); }); it("deduplicates runtime flags after underscore normalization", () => { @@ -1056,23 +1217,39 @@ describe("runCompletion", () => { } satisfies CompletionRuntimeConfig; const zshResult = runCompletion("zsh", ["Task"], [], false, runtime); - expect((zshResult.script.match(/--customer-segment\[Runtime schema field flag\]:value/g) ?? []).length).toBe(1); + expect( + ( + zshResult.script.match( + /--customer-segment\[Runtime schema field flag\]:value/g, + ) ?? [] + ).length, + ).toBe(1); expect(zshResult.script).not.toContain("--[Runtime schema field flag]"); const fishResult = runCompletion("fish", ["Task"], [], false, runtime); - expect((fishResult.script.match(/-l customer-segment -d 'Runtime schema field flag' -r/g) ?? []).length).toBe(1); + expect( + ( + fishResult.script.match( + /-l customer-segment -d 'Runtime schema field flag' -r/g, + ) ?? [] + ).length, + ).toBe(1); }); }); describe("pm completion CLI command", () => { - function installGuideShellPackage( - context: { - runCli: (args: string[], options?: { expectJson?: boolean; cwd?: string }) => { - code: number | null; - }; - }, - ): void { - const install = context.runCli(["install", "guide-shell", "--project", "--json"], { expectJson: true }); + function installGuideShellPackage(context: { + runCli: ( + args: string[], + options?: { expectJson?: boolean; cwd?: string }, + ) => { + code: number | null; + }; + }): void { + const install = context.runCli( + ["install", "guide-shell", "--project", "--json"], + { expectJson: true }, + ); expect(install.code).toBe(0); } @@ -1091,7 +1268,9 @@ describe("pm completion CLI command", () => { it("outputs JSON object with --json", async () => { await withTempPmPath(async (context) => { installGuideShellPackage(context); - const result = context.runCli(["completion", "bash", "--json"], { expectJson: true }); + const result = context.runCli(["completion", "bash", "--json"], { + expectJson: true, + }); expect(result.code).toBe(0); const json = result.json as CompletionResult; expect(json.shell).toBe("bash"); @@ -1122,11 +1301,15 @@ describe("pm completion CLI command", () => { const zshResult = context.runCli(["completion", "zsh"]); expect(zshResult.code).toBe(0); - expect(zshResult.stdout).toContain("--customer-segment[Runtime schema field flag]:value"); + expect(zshResult.stdout).toContain( + "--customer-segment[Runtime schema field flag]:value", + ); const fishResult = context.runCli(["completion", "fish"]); expect(fishResult.code).toBe(0); - expect(fishResult.stdout).toContain("-l customer-segment -d 'Runtime schema field flag' -r"); + expect(fishResult.stdout).toContain( + "-l customer-segment -d 'Runtime schema field flag' -r", + ); }); }); @@ -1169,13 +1352,21 @@ describe("pm completion CLI command", () => { const zshResult = context.runCli(["completion", "zsh"]); expect(zshResult.code).toBe(0); - expect(zshResult.stdout).toContain('--status[Filter by status]:(${(f)"$(_pm_status_choices)"})'); - expect(zshResult.stdout).toContain('--type[Filter by item type]:(${(f)"$(_pm_type_choices)"})'); + expect(zshResult.stdout).toContain( + '--status[Filter by status]:(${(f)"$(_pm_status_choices)"})', + ); + expect(zshResult.stdout).toContain( + '--type[Filter by item type]:(${(f)"$(_pm_type_choices)"})', + ); const fishResult = context.runCli(["completion", "fish"]); expect(fishResult.code).toBe(0); - expect(fishResult.stdout).toContain("-l status -d 'Filter by status' -r -a '(__pm_status_choices)'"); - expect(fishResult.stdout).toContain("-l type -d 'Filter by item type' -r -a '(__pm_type_choices)'"); + expect(fishResult.stdout).toContain( + "-l status -d 'Filter by status' -r -a '(__pm_status_choices)'", + ); + expect(fishResult.stdout).toContain( + "-l type -d 'Filter by item type' -r -a '(__pm_type_choices)'", + ); }); }, 90_000); @@ -1238,9 +1429,12 @@ describe("pm completion CLI command", () => { }); describe("schema subcommand completion drift", () => { - it("lists every SCHEMA_SUBCOMMANDS entry in bash, zsh, and fish scripts", async () => { - const { SCHEMA_SUBCOMMANDS } = await import("../../../src/cli/commands/schema.js"); - const scripts = [generateBashScript(), generateZshScript(), generateFishScript()]; + it("lists every SCHEMA_SUBCOMMANDS entry in bash, zsh, and fish scripts", () => { + const scripts = [ + generateBashScript(), + generateZshScript(), + generateFishScript(), + ]; for (const script of scripts) { for (const subcommand of SCHEMA_SUBCOMMANDS) { expect(script).toContain(subcommand); diff --git a/tests/unit/commands/init-command.spec.ts b/tests/unit/commands/init-command.spec.ts index d5c6f74ad..0f1d798ea 100644 --- a/tests/unit/commands/init-command.spec.ts +++ b/tests/unit/commands/init-command.spec.ts @@ -4,20 +4,38 @@ import path from "node:path"; import * as readline from "node:readline/promises"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runHealth } from "../../../src/cli/commands/health.js"; -import { _testOnly as initInternals, runInit, summarizeInitResult } from "../../../src/cli/commands/init.js"; +import { + _testOnly as initInternals, + runInit, + summarizeInitResult, +} from "../../../src/cli/commands/init.js"; +import { runPmCli } from "../../../src/cli/main.js"; import { _testOnly as initGuidanceInternals, runInitAgentGuidance, } from "../../../src/cli/commands/init-agent-guidance.js"; -import { EXIT_CODE, PM_REQUIRED_SUBDIRS } from "../../../src/core/shared/constants.js"; -import { clearActiveExtensionHooks, setActiveExtensionHooks, type ExtensionHookRegistry } from "../../../src/core/extensions/index.js"; +import { + EXIT_CODE, + PM_REQUIRED_SUBDIRS, +} from "../../../src/core/shared/constants.js"; +import { + clearActiveExtensionHooks, + setActiveExtensionHooks, + type ExtensionHookRegistry, +} from "../../../src/core/extensions/index.js"; import { PmCliError } from "../../../src/core/shared/errors.js"; import { readSettings } from "../../../src/core/store/settings.js"; describe("runInit", () => { beforeEach(() => { - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false }); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: false, + }); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: false, + }); }); afterEach(() => { @@ -25,35 +43,61 @@ describe("runInit", () => { initInternals.setInitReadlineFactoryForTests(undefined); initGuidanceInternals.setAgentGuidanceReadlineFactoryForTests(undefined); vi.restoreAllMocks(); - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false }); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: false, + }); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: false, + }); }); it("covers init option normalizers and governance application", async () => { initInternals.setInitReadlineFactoryForTests(undefined); initGuidanceInternals.setAgentGuidanceReadlineFactoryForTests(undefined); - expect(initInternals.normalizeInitGovernancePreset(undefined)).toBeUndefined(); + expect( + initInternals.normalizeInitGovernancePreset(undefined), + ).toBeUndefined(); expect(initInternals.normalizeInitGovernancePreset("lite")).toBe("minimal"); - expect(initInternals.normalizeInitGovernancePreset("minimum")).toBe("minimal"); - expect(initInternals.normalizeInitGovernancePreset("strict")).toBe("strict"); - expect(() => initInternals.normalizeInitGovernancePreset(" ")).toThrow(/--preset must not be empty/); - expect(() => initInternals.normalizeInitGovernancePreset("heavy")).toThrow(/Invalid --preset/); + expect(initInternals.normalizeInitGovernancePreset("minimum")).toBe( + "minimal", + ); + expect(initInternals.normalizeInitGovernancePreset("strict")).toBe( + "strict", + ); + expect(() => initInternals.normalizeInitGovernancePreset(" ")).toThrow( + /--preset must not be empty/, + ); + expect(() => initInternals.normalizeInitGovernancePreset("heavy")).toThrow( + /Invalid --preset/, + ); expect(initInternals.normalizeInitTypePreset(undefined)).toBeUndefined(); expect(initInternals.normalizeInitTypePreset("research")).toBe("research"); - expect(() => initInternals.normalizeInitTypePreset(" ")).toThrow(/--type-preset must not be empty/); - expect(() => initInternals.normalizeInitTypePreset("sales")).toThrow(/Invalid --type-preset/); + expect(() => initInternals.normalizeInitTypePreset(" ")).toThrow( + /--type-preset must not be empty/, + ); + expect(() => initInternals.normalizeInitTypePreset("sales")).toThrow( + /Invalid --type-preset/, + ); - expect(initInternals.normalizeOptionalInitAuthor(undefined)).toBeUndefined(); + expect( + initInternals.normalizeOptionalInitAuthor(undefined), + ).toBeUndefined(); expect(initInternals.normalizeOptionalInitAuthor(" agent ")).toBe("agent"); - expect(() => initInternals.normalizeOptionalInitAuthor(" ")).toThrow(/--author must not be empty/); + expect(() => initInternals.normalizeOptionalInitAuthor(" ")).toThrow( + /--author must not be empty/, + ); expect(initInternals.isPathLikeInitTarget(undefined)).toBe(false); expect(initInternals.isPathLikeInitTarget(" ")).toBe(false); expect(initInternals.isPathLikeInitTarget("acme")).toBe(false); expect(initInternals.isPathLikeInitTarget("./sandbox")).toBe(true); expect(initInternals.isPathLikeInitTarget("/tmp/pm-test")).toBe(true); - expect(initInternals.resolveInitInvocation("/repo", {}, "./sandbox")).toEqual({ + expect( + initInternals.resolveInitInvocation("/repo", {}, "./sandbox"), + ).toEqual({ pmRoot: path.resolve("/repo", "sandbox"), prefixArg: undefined, target: { @@ -61,7 +105,13 @@ describe("runInit", () => { tracker_root: path.resolve("/repo", "sandbox"), }, }); - expect(initInternals.resolveInitInvocation("/repo", { path: "/repo/.agents/pm" }, "./sandbox")).toEqual({ + expect( + initInternals.resolveInitInvocation( + "/repo", + { path: "/repo/.agents/pm" }, + "./sandbox", + ), + ).toEqual({ // resolvePmRoot delegates to path.resolve(cwd, explicitPath), so the // expectation must be platform-native (POSIX `/repo/.agents/pm`, // win32 `<drive>\repo\.agents\pm`) rather than a hardcoded forward-slash @@ -73,13 +123,35 @@ describe("runInit", () => { tracker_root: path.resolve("/repo", "/repo/.agents/pm"), }, }); - const discoveryInvocation = initInternals.resolveInitInvocation("/repo", {}, "acme"); + const discoveryInvocation = initInternals.resolveInitInvocation( + "/repo", + {}, + "acme", + ); expect(discoveryInvocation).toMatchObject({ prefixArg: "acme", target: { mode: "workspace-discovery", workspace_root: "/repo" }, }); - expect(discoveryInvocation.target.tracker_root).toBe(discoveryInvocation.pmRoot); - expect(initInternals.resolveInitInvocation("/repo", {}, "acme", "./workspace")).toEqual({ + expect(discoveryInvocation.target.tracker_root).toBe( + discoveryInvocation.pmRoot, + ); + expect( + initInternals.resolveInitPrefixInput(undefined, undefined), + ).toBeUndefined(); + expect(initInternals.resolveInitPrefixInput("app", undefined)).toBe("app"); + expect(initInternals.resolveInitPrefixInput(undefined, "ops")).toBe("ops"); + expect(initInternals.resolveInitPrefixInput("app", "app-")).toBe("app-"); + expect(() => initInternals.resolveInitPrefixInput(undefined, " ")).toThrow( + "--id-prefix must not be empty", + ); + expect(() => initInternals.resolveInitPrefixInput("app", "ops")).toThrow( + expect.objectContaining({ + context: expect.objectContaining({ code: "init_id_prefix_conflict" }), + }), + ); + expect( + initInternals.resolveInitInvocation("/repo", {}, "acme", "./workspace"), + ).toEqual({ pmRoot: path.resolve("/repo/workspace/.agents/pm"), prefixArg: "acme", target: { @@ -88,11 +160,25 @@ describe("runInit", () => { workspace_root: path.resolve("/repo/workspace"), }, }); - expect(() => initInternals.resolveInitInvocation("/repo", {}, undefined, " ")).toThrow("--workspace must not be empty"); - expect(() => initInternals.resolveInitInvocation("/repo", { path: "/tmp/pm" }, undefined, "./workspace")).toThrow( - "cannot be combined", - ); - expect(() => initInternals.resolveInitInvocation("/repo", {}, "./tracker", "./workspace")).toThrow("cannot be combined"); + expect(() => + initInternals.resolveInitInvocation("/repo", {}, undefined, " "), + ).toThrow("--workspace must not be empty"); + expect(() => + initInternals.resolveInitInvocation( + "/repo", + { path: "/tmp/pm" }, + undefined, + "./workspace", + ), + ).toThrow("cannot be combined"); + expect(() => + initInternals.resolveInitInvocation( + "/repo", + {}, + "./tracker", + "./workspace", + ), + ).toThrow("cannot be combined"); const discoverySteps = initInternals.buildInitNextSteps({ installBundledPackages: false, @@ -107,52 +193,95 @@ describe("runInit", () => { expect(discoverySteps).toContain("Run pm context before editing."); const scopedSteps = initInternals.buildInitNextSteps({ installBundledPackages: true, - registeredTypePreset: { preset: "agile", registered: [], updated: ["Story"] }, - agentGuidanceNextSteps: ["Run pm context before editing.", "Run pm context before editing."], - target: { mode: "tracker-path", tracker_root: path.resolve("/tmp/tracker") }, + registeredTypePreset: { + preset: "agile", + registered: [], + updated: ["Story"], + }, + agentGuidanceNextSteps: [ + "Run pm context before editing.", + "Run pm context before editing.", + ], + target: { + mode: "tracker-path", + tracker_root: path.resolve("/tmp/tracker"), + }, }); - expect(scopedSteps).toContain(`Run pm --pm-path ${path.resolve("/tmp/tracker")} context before editing.`); - expect(scopedSteps.filter((step) => step.includes("context before editing"))).toHaveLength(1); + expect(scopedSteps).toContain( + `Run pm --pm-path ${path.resolve("/tmp/tracker")} context before editing.`, + ); + expect( + scopedSteps.filter((step) => step.includes("context before editing")), + ).toHaveLength(1); expect(scopedSteps).toContain( `Inspect registered preset types: pm --pm-path ${path.resolve("/tmp/tracker")} schema list, pm --pm-path ${path.resolve("/tmp/tracker")} schema show Story`, ); const replacementSensitivePath = path.resolve("/tmp/tracker$&cash$$tail"); - expect(initInternals.buildInitNextSteps({ - installBundledPackages: true, - registeredTypePreset: undefined, - agentGuidanceNextSteps: ["Run pm context before editing."], - target: { mode: "tracker-path", tracker_root: replacementSensitivePath }, - })).toContain(`Run pm --pm-path "${replacementSensitivePath.replaceAll("$", "\\$")}" context before editing.`); + expect( + initInternals.buildInitNextSteps({ + installBundledPackages: true, + registeredTypePreset: undefined, + agentGuidanceNextSteps: ["Run pm context before editing."], + target: { + mode: "tracker-path", + tracker_root: replacementSensitivePath, + }, + }), + ).toContain( + `Run pm --pm-path "${replacementSensitivePath.replaceAll("$", "\\$")}" context before editing.`, + ); expect(initInternals.normalizeInitAgentGuidanceMode(undefined)).toBe("ask"); - expect(initInternals.normalizeInitAgentGuidanceMode("status")).toBe("status"); - expect(() => initInternals.normalizeInitAgentGuidanceMode(" ")).toThrow(/--agent-guidance must not be empty/); - expect(() => initInternals.normalizeInitAgentGuidanceMode("later")).toThrow(/Invalid --agent-guidance/); + expect(initInternals.normalizeInitAgentGuidanceMode("status")).toBe( + "status", + ); + expect(() => initInternals.normalizeInitAgentGuidanceMode(" ")).toThrow( + /--agent-guidance must not be empty/, + ); + expect(() => initInternals.normalizeInitAgentGuidanceMode("later")).toThrow( + /Invalid --agent-guidance/, + ); expect(initInternals.parseYesNoChoice("", true)).toBe(true); expect(initInternals.parseYesNoChoice("Y", false)).toBe(true); expect(initInternals.parseYesNoChoice("no", true)).toBe(false); expect(initInternals.parseYesNoChoice("maybe", false)).toBe(false); - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-governance-helper-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-governance-helper-"), + ); try { await runInit("pm", { path: tempRoot }, { defaults: true }); const settings = await readSettings(tempRoot); initInternals.applyGovernancePreset(settings, "strict"); expect(settings.governance.preset).toBe("strict"); - expect(settings.validation.parent_reference).toBe(settings.governance.parent_reference); - expect(settings.validation.metadata_profile).toBe(settings.governance.metadata_profile); + expect(settings.validation.parent_reference).toBe( + settings.governance.parent_reference, + ); + expect(settings.validation.metadata_profile).toBe( + settings.governance.metadata_profile, + ); } finally { await rm(tempRoot, { recursive: true, force: true }); } }); it("updates author on an existing tracker when init is forced", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-existing-author-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-existing-author-"), + ); try { - await runInit("pm", { path: tempRoot }, { defaults: true, author: "first-agent" }); + await runInit( + "pm", + { path: tempRoot }, + { defaults: true, author: "first-agent" }, + ); - const result = await runInit("pm", { path: tempRoot }, { defaults: true, force: true, author: "second-agent" }); + const result = await runInit( + "pm", + { path: tempRoot }, + { defaults: true, force: true, author: "second-agent" }, + ); const settings = await readSettings(tempRoot); expect(settings.author_default).toBe("second-agent"); @@ -199,18 +328,35 @@ describe("runInit", () => { }), ).toMatchObject({ installed_all: false, installed_count: 0, packages: [] }); - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-workspace-guard-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-workspace-guard-"), + ); try { expect(await initInternals.isLikelyWorkspaceRoot(tempRoot)).toBe(false); await writeFile(path.join(tempRoot, "package.json"), "{}\n", "utf8"); expect(await initInternals.isLikelyWorkspaceRoot(tempRoot)).toBe(true); - await expect(initInternals.assertExplicitTrackerPathIsNotWorkspaceRoot(tempRoot, true, false)).rejects.toHaveProperty( - "context.code", - "workspace_root_pm_path", - ); - await expect(initInternals.assertExplicitTrackerPathIsNotWorkspaceRoot(tempRoot, true, true)).resolves.toBeUndefined(); + await expect( + initInternals.assertExplicitTrackerPathIsNotWorkspaceRoot( + tempRoot, + true, + false, + ), + ).rejects.toHaveProperty("context.code", "workspace_root_pm_path"); + await expect( + initInternals.assertExplicitTrackerPathIsNotWorkspaceRoot( + tempRoot, + true, + true, + ), + ).resolves.toBeUndefined(); await runInit("pm", { path: tempRoot }, { defaults: true, force: true }); - await expect(initInternals.assertExplicitTrackerPathIsNotWorkspaceRoot(tempRoot, true, false)).resolves.toBeUndefined(); + await expect( + initInternals.assertExplicitTrackerPathIsNotWorkspaceRoot( + tempRoot, + true, + false, + ), + ).resolves.toBeUndefined(); } finally { await rm(tempRoot, { recursive: true, force: true }); } @@ -222,22 +368,46 @@ describe("runInit", () => { const claudePath = path.join(projectRoot, "CLAUDE.md"); const block = initGuidanceInternals.buildAgentGuidanceBlock("\r\n"); - expect(initGuidanceInternals.toPortableRelativePath(projectRoot, projectRoot)).toBe("project"); - expect(initGuidanceInternals.toPortableRelativePath(projectRoot, path.join(projectRoot, "nested", "AGENTS.md"))).toBe( - "nested/AGENTS.md", + expect( + initGuidanceInternals.toPortableRelativePath(projectRoot, projectRoot), + ).toBe("project"); + expect( + initGuidanceInternals.toPortableRelativePath( + projectRoot, + path.join(projectRoot, "nested", "AGENTS.md"), + ), + ).toBe("nested/AGENTS.md"); + expect(initGuidanceInternals.ensureTrailingNewline("missing")).toBe( + "missing\n", + ); + expect(initGuidanceInternals.ensureTrailingNewline("already\n")).toBe( + "already\n", ); - expect(initGuidanceInternals.ensureTrailingNewline("missing")).toBe("missing\n"); - expect(initGuidanceInternals.ensureTrailingNewline("already\n")).toBe("already\n"); expect(initGuidanceInternals.detectLineEnding(block)).toBe("\r\n"); - expect(initGuidanceInternals.findGuidanceBlockRange("plain text")).toBeNull(); - expect(initGuidanceInternals.findGuidanceBlockRange("<!-- pm-cli:agent-guidance:start:v1 -->\nmissing end")).toBeNull(); - expect(initGuidanceInternals.findGuidanceBlockRange(block)?.start_index).toBe(0); + expect( + initGuidanceInternals.findGuidanceBlockRange("plain text"), + ).toBeNull(); + expect( + initGuidanceInternals.findGuidanceBlockRange( + "<!-- pm-cli:agent-guidance:start:v1 -->\nmissing end", + ), + ).toBeNull(); + expect( + initGuidanceInternals.findGuidanceBlockRange(block)?.start_index, + ).toBe(0); - const inserted = initGuidanceInternals.upsertAgentGuidanceBlock("# Existing"); + const inserted = + initGuidanceInternals.upsertAgentGuidanceBlock("# Existing"); expect(inserted.changed).toBe(true); - expect(inserted.next_content).toContain("<!-- pm-cli:agent-guidance:start:v1 -->"); - const unchanged = initGuidanceInternals.upsertAgentGuidanceBlock(initGuidanceInternals.buildAgentGuidanceBlock("\n")); - expect(unchanged.next_content).toContain("Set `PM_AUTHOR=<stable-agent-id>` before mutation commands."); + expect(inserted.next_content).toContain( + "<!-- pm-cli:agent-guidance:start:v1 -->", + ); + const unchanged = initGuidanceInternals.upsertAgentGuidanceBlock( + initGuidanceInternals.buildAgentGuidanceBlock("\n"), + ); + expect(unchanged.next_content).toContain( + "Set `PM_AUTHOR=<stable-agent-id>` before mutation commands.", + ); const crlfTrimmed = initGuidanceInternals.upsertAgentGuidanceBlock( `${initGuidanceInternals.buildAgentGuidanceBlock("\r\n")}# Tail`, ); @@ -247,13 +417,30 @@ describe("runInit", () => { ); expect(plainTail.next_content).toContain("#tail"); - expect(initGuidanceInternals.resolveProjectRoot(path.join(projectRoot, ".agents", "pm"), process.cwd())).toBe(projectRoot); - expect(initGuidanceInternals.resolveProjectRoot("custom-pm", "/repo")).toBe(path.resolve("/repo", "custom-pm")); + expect( + initGuidanceInternals.resolveProjectRoot( + path.join(projectRoot, ".agents", "pm"), + process.cwd(), + ), + ).toBe(projectRoot); + expect(initGuidanceInternals.resolveProjectRoot("custom-pm", "/repo")).toBe( + path.resolve("/repo", "custom-pm"), + ); expect( initGuidanceInternals.resolveTargetGuidancePath( [ - { file_path: agentsPath, exists: false, has_guidance: false, has_marker: false }, - { file_path: claudePath, exists: true, has_guidance: false, has_marker: false }, + { + file_path: agentsPath, + exists: false, + has_guidance: false, + has_marker: false, + }, + { + file_path: claudePath, + exists: true, + has_guidance: false, + has_marker: false, + }, ], projectRoot, ), @@ -272,38 +459,60 @@ describe("runInit", () => { template_version: 0, last_checked_files: [" CLAUDE.md ", "", "AGENTS.md", "AGENTS.md"], }, - } as Parameters<typeof initGuidanceInternals.normalizeAgentGuidanceState>[0]; - expect(initGuidanceInternals.normalizeAgentGuidanceState(settings)).toEqual({ - prompt_completed: true, - declined: true, - declined_at: "2026-01-01T00:00:00.000Z", - template_version: 1, - last_checked_files: ["AGENTS.md", "CLAUDE.md"], - }); - expect(initGuidanceInternals.normalizeAgentGuidanceState({} as Parameters<typeof initGuidanceInternals.normalizeAgentGuidanceState>[0])).toEqual({ + } as Parameters< + typeof initGuidanceInternals.normalizeAgentGuidanceState + >[0]; + expect(initGuidanceInternals.normalizeAgentGuidanceState(settings)).toEqual( + { + prompt_completed: true, + declined: true, + declined_at: "2026-01-01T00:00:00.000Z", + template_version: 1, + last_checked_files: ["AGENTS.md", "CLAUDE.md"], + }, + ); + expect( + initGuidanceInternals.normalizeAgentGuidanceState( + {} as Parameters< + typeof initGuidanceInternals.normalizeAgentGuidanceState + >[0], + ), + ).toEqual({ prompt_completed: false, declined: false, declined_at: "", template_version: 1, last_checked_files: [], }); - const stateUpdate = initGuidanceInternals.applyAgentGuidanceState(settings, { - prompt_completed: false, - declined: false, - declined_at: "", - template_version: 1, - last_checked_files: ["AGENTS.md"], - }); + const stateUpdate = initGuidanceInternals.applyAgentGuidanceState( + settings, + { + prompt_completed: false, + declined: false, + declined_at: "", + template_version: 1, + last_checked_files: ["AGENTS.md"], + }, + ); expect(stateUpdate.changed).toBe(true); expect(settings.agent_guidance?.last_checked_files).toEqual(["AGENTS.md"]); - const nextSteps = ["Add workflow guidance later: pm init --agent-guidance add"]; - initGuidanceInternals.pushUnique(nextSteps, "Add workflow guidance later: pm init --agent-guidance add"); - expect(nextSteps).toEqual(["Add workflow guidance later: pm init --agent-guidance add"]); + const nextSteps = [ + "Add workflow guidance later: pm init --agent-guidance add", + ]; + initGuidanceInternals.pushUnique( + nextSteps, + "Add workflow guidance later: pm init --agent-guidance add", + ); + expect(nextSteps).toEqual([ + "Add workflow guidance later: pm init --agent-guidance add", + ]); }); it("covers stale-scan no-op add and ask guidance write branches", async () => { - const projectRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-guidance-stale-scan-")); + const projectRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-guidance-stale-scan-"), + ); const pmRoot = path.join(projectRoot, ".agents", "pm"); const guidancePath = path.join(projectRoot, "AGENTS.md"); const claudePath = path.join(projectRoot, "CLAUDE.md"); @@ -322,7 +531,9 @@ describe("runInit", () => { throw new Error(`unexpected read path: ${filePath}`); }); const writeFileMock = vi.fn(async () => undefined); - const pathExistsMock = vi.fn(async (filePath: string) => filePath === guidancePath); + const pathExistsMock = vi.fn( + async (filePath: string) => filePath === guidancePath, + ); vi.doMock("node:fs/promises", () => ({ default: { readFile: readFileMock, writeFile: writeFileMock }, @@ -337,16 +548,23 @@ describe("runInit", () => { })); try { - const mockedModule = await import("../../../src/cli/commands/init-agent-guidance.js"); + const mockedModule = + await import("../../../src/cli/commands/init-agent-guidance.js"); const addResult = await mockedModule.runInitAgentGuidance({ pm_root: pmRoot, cwd: projectRoot, mode: "add", interactive: false, - settings: {} as Parameters<typeof mockedModule.runInitAgentGuidance>[0]["settings"], + settings: {} as Parameters< + typeof mockedModule.runInitAgentGuidance + >[0]["settings"], }); expect(addResult.summary.applied).toBe(false); - expect(addResult.warnings.some((warning) => warning.startsWith("agent_guidance:added:"))).toBe(false); + expect( + addResult.warnings.some((warning) => + warning.startsWith("agent_guidance:added:"), + ), + ).toBe(false); expect(writeFileMock).not.toHaveBeenCalled(); } finally { vi.doUnmock("node:fs/promises"); @@ -367,8 +585,12 @@ describe("runInit", () => { throw new Error(`unexpected read path: ${filePath}`); }); const writeFileMock = vi.fn(async () => undefined); - const pathExistsMock = vi.fn(async (filePath: string) => filePath === guidancePath); - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const pathExistsMock = vi.fn( + async (filePath: string) => filePath === guidancePath, + ); + const writeSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); vi.doMock("node:fs/promises", () => ({ default: { readFile: readFileMock, writeFile: writeFileMock }, @@ -383,21 +605,31 @@ describe("runInit", () => { })); try { - const mockedModule = await import("../../../src/cli/commands/init-agent-guidance.js"); - mockedModule._testOnly.setAgentGuidanceReadlineFactoryForTests(() => ({ - question: vi.fn(async () => "yes"), - close: vi.fn(), - } as unknown as ReturnType<typeof readline.createInterface>)); + const mockedModule = + await import("../../../src/cli/commands/init-agent-guidance.js"); + mockedModule._testOnly.setAgentGuidanceReadlineFactoryForTests( + () => + ({ + question: vi.fn(async () => "yes"), + close: vi.fn(), + }) as unknown as ReturnType<typeof readline.createInterface>, + ); const askResult = await mockedModule.runInitAgentGuidance({ pm_root: pmRoot, cwd: projectRoot, mode: "ask", interactive: true, - settings: {} as Parameters<typeof mockedModule.runInitAgentGuidance>[0]["settings"], + settings: {} as Parameters< + typeof mockedModule.runInitAgentGuidance + >[0]["settings"], }); expect(askResult.summary.prompted).toBe(true); expect(askResult.summary.applied).toBe(false); - expect(askResult.warnings.some((warning) => warning.startsWith("agent_guidance:added:"))).toBe(false); + expect( + askResult.warnings.some((warning) => + warning.startsWith("agent_guidance:added:"), + ), + ).toBe(false); expect(writeFileMock).not.toHaveBeenCalled(); } finally { writeSpy.mockRestore(); @@ -413,9 +645,15 @@ describe("runInit", () => { }); it("preserves prior declined_at on explicit skip and keeps present guidance in ask mode", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-guidance-branch-state-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-guidance-branch-state-"), + ); try { - await writeFile(tempRoot + "/AGENTS.md", initGuidanceInternals.buildAgentGuidanceBlock("\n"), "utf8"); + await writeFile( + tempRoot + "/AGENTS.md", + initGuidanceInternals.buildAgentGuidanceBlock("\n"), + "utf8", + ); const askResult = await runInitAgentGuidance({ pm_root: tempRoot, @@ -471,7 +709,9 @@ describe("runInit", () => { prompt_completed: true, }, }); - expect(missingDeclinedAtSettings.agent_guidance?.declined_at).not.toBe(""); + expect(missingDeclinedAtSettings.agent_guidance?.declined_at).not.toBe( + "", + ); } finally { await rm(tempRoot, { recursive: true, force: true }); } @@ -484,9 +724,13 @@ describe("runInit", () => { expect(result.ok).toBe(true); expect(result.path).toBe(tempRoot); expect(result.settings.id_prefix).toBe("acme-"); - expect(result.created_dirs).toHaveLength(PM_REQUIRED_SUBDIRS.length - 1 + 4); + expect(result.created_dirs).toHaveLength( + PM_REQUIRED_SUBDIRS.length - 1 + 4, + ); expect(result.warnings).toContain(`already_exists:${tempRoot}`); - expect(result.warnings).toContain("agent_guidance:missing_non_interactive"); + expect(result.warnings).toContain( + "agent_guidance:missing_non_interactive", + ); expect(result.agent_guidance).toMatchObject({ mode: "ask", present: false, @@ -510,10 +754,38 @@ describe("runInit", () => { } }); + it("forwards the canonical id-prefix CLI flag through setup registration", async () => { + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-id-prefix-registration-"), + ); + const writeSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + try { + await runPmCli([ + "--pm-path", + tempRoot, + "init", + "--id-prefix", + "ops", + "--yes", + "--json", + ]); + expect((await readSettings(tempRoot)).id_prefix).toBe("ops-"); + } finally { + writeSpy.mockRestore(); + await rm(tempRoot, { recursive: true, force: true }); + } + }); + it("summarizes an init result into a concise projection without the full settings tree", async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-summary-")); try { - const result = await runInit("acme", { path: tempRoot }, { defaults: true }); + const result = await runInit( + "acme", + { path: tempRoot }, + { defaults: true }, + ); const summary = summarizeInitResult(result); // Drops the verbose settings tree but keeps the surfaced essentials. @@ -543,9 +815,15 @@ describe("runInit", () => { }); it("carries an installed-packages summary into the concise projection when present", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-summary-pkgs-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-summary-pkgs-"), + ); try { - const result = await runInit("pm", { path: tempRoot }, { defaults: true, withPackages: true }); + const result = await runInit( + "pm", + { path: tempRoot }, + { defaults: true, withPackages: true }, + ); const summary = summarizeInitResult(result); expect(summary.installed_packages).toEqual(result.installed_packages); expect(summary.installed_packages?.installed_all).toBe(true); @@ -555,9 +833,15 @@ describe("runInit", () => { }); it("registers domain type presets into runtime schema during initialization", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-type-preset-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-type-preset-"), + ); try { - const result = await runInit("pm", { path: tempRoot }, { defaults: true, typePreset: "agile" }); + const result = await runInit( + "pm", + { path: tempRoot }, + { defaults: true, typePreset: "agile" }, + ); expect(result.registered_type_preset).toMatchObject({ name: "agile", registered: ["Story", "Spike"], @@ -567,23 +851,47 @@ describe("runInit", () => { expect(result.next_steps).toContain( `Inspect registered preset types: pm --pm-path ${tempRoot} schema list, pm --pm-path ${tempRoot} schema show Story`, ); - expect((await stat(path.join(tempRoot, "stories"))).isDirectory()).toBe(true); - expect((await stat(path.join(tempRoot, "spikes"))).isDirectory()).toBe(true); + expect((await stat(path.join(tempRoot, "stories"))).isDirectory()).toBe( + true, + ); + expect((await stat(path.join(tempRoot, "spikes"))).isDirectory()).toBe( + true, + ); const summary = summarizeInitResult(result); - expect(summary.registered_type_preset).toEqual(result.registered_type_preset); + expect(summary.registered_type_preset).toEqual( + result.registered_type_preset, + ); - const types = JSON.parse(await readFile(path.join(tempRoot, "schema", "types.json"), "utf8")) as { - definitions: Array<{ name: string; aliases?: string[]; folder?: string }>; + const types = JSON.parse( + await readFile(path.join(tempRoot, "schema", "types.json"), "utf8"), + ) as { + definitions: Array<{ + name: string; + aliases?: string[]; + folder?: string; + }>; }; expect(types.definitions).toEqual( expect.arrayContaining([ - expect.objectContaining({ name: "Spike", folder: "spikes", aliases: ["research-spike"] }), - expect.objectContaining({ name: "Story", folder: "stories", aliases: ["user-story"] }), + expect.objectContaining({ + name: "Spike", + folder: "spikes", + aliases: ["research-spike"], + }), + expect.objectContaining({ + name: "Story", + folder: "stories", + aliases: ["user-story"], + }), ]), ); - const rerun = await runInit("pm", { path: tempRoot }, { defaults: true, typePreset: "agile" }); + const rerun = await runInit( + "pm", + { path: tempRoot }, + { defaults: true, typePreset: "agile" }, + ); expect(rerun.registered_type_preset).toMatchObject({ name: "agile", registered: [], @@ -595,17 +903,20 @@ describe("runInit", () => { }); it("repairs missing folders for runtime schema item types on re-run", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-schema-folders-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-schema-folders-"), + ); try { - await runInit("pm", { path: tempRoot }, { defaults: true, agentGuidance: "skip" }); + await runInit( + "pm", + { path: tempRoot }, + { defaults: true, agentGuidance: "skip" }, + ); await writeFile( path.join(tempRoot, "schema", "types.json"), `${JSON.stringify( { - definitions: [ - { name: "ShowType" }, - { name: "Type" }, - ], + definitions: [{ name: "ShowType" }, { name: "Type" }], }, null, 2, @@ -614,14 +925,28 @@ describe("runInit", () => { ); const before = await runHealth({ path: tempRoot }, { checkOnly: true }); - const beforeDirectories = before.checks.find((check) => check.name === "directories"); - expect(beforeDirectories?.details.missing_required).toEqual(["showtypes", "types"]); + const beforeDirectories = before.checks.find( + (check) => check.name === "directories", + ); + expect(beforeDirectories?.details.missing_required).toEqual([ + "showtypes", + "types", + ]); - const repaired = await runInit("pm", { path: tempRoot }, { defaults: true, agentGuidance: "skip" }); - expect(repaired.created_dirs).toEqual([path.join(tempRoot, "showtypes"), path.join(tempRoot, "types")]); + const repaired = await runInit( + "pm", + { path: tempRoot }, + { defaults: true, agentGuidance: "skip" }, + ); + expect(repaired.created_dirs).toEqual([ + path.join(tempRoot, "showtypes"), + path.join(tempRoot, "types"), + ]); const after = await runHealth({ path: tempRoot }, { checkOnly: true }); - const afterDirectories = after.checks.find((check) => check.name === "directories"); + const afterDirectories = after.checks.find( + (check) => check.name === "directories", + ); expect(afterDirectories?.details.missing_required).toEqual([]); } finally { await rm(tempRoot, { recursive: true, force: true }); @@ -635,7 +960,9 @@ describe("runInit", () => { expect(initial.settings.id_prefix).toBe("pm-"); const expectedSettingsPath = path.join(tempRoot, "settings.json"); - await expect(runInit("next", { path: tempRoot })).rejects.toMatchObject<PmCliError>({ + await expect( + runInit("next", { path: tempRoot }), + ).rejects.toMatchObject<PmCliError>({ context: expect.objectContaining({ code: "init_existing_settings_requires_force", required: "--force for id_prefix", @@ -643,18 +970,28 @@ describe("runInit", () => { }); expect((await readSettings(tempRoot)).id_prefix).toBe("pm-"); - const updated = await runInit("next", { path: tempRoot }, { force: true }); + const updated = await runInit( + "next", + { path: tempRoot }, + { force: true }, + ); expect(updated.created_dirs).toEqual([]); expect(updated.settings.id_prefix).toBe("next-"); - expect(updated.warnings).toContain(`already_exists:${expectedSettingsPath}`); - expect(updated.warnings).toContain("updated:id_prefix:next-"); - expect(updated.warnings.filter((warning) => warning.startsWith("already_exists:"))).toHaveLength( - PM_REQUIRED_SUBDIRS.length + 1, + expect(updated.warnings).toContain( + `already_exists:${expectedSettingsPath}`, ); + expect(updated.warnings).toContain("updated:id_prefix:next-"); + expect( + updated.warnings.filter((warning) => + warning.startsWith("already_exists:"), + ), + ).toHaveLength(PM_REQUIRED_SUBDIRS.length + 1); const unchanged = await runInit("next", { path: tempRoot }); - expect(unchanged.warnings).toContain(`already_exists:${expectedSettingsPath}`); + expect(unchanged.warnings).toContain( + `already_exists:${expectedSettingsPath}`, + ); expect(unchanged.warnings).not.toContain("updated:id_prefix:next-"); const persisted = await readSettings(tempRoot); @@ -665,9 +1002,15 @@ describe("runInit", () => { }); it("persists explicit skip state and supports idempotent explicit guidance add", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-agent-guidance-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-agent-guidance-"), + ); try { - const skipped = await runInit("pm", { path: tempRoot }, { defaults: true, agentGuidance: "skip" }); + const skipped = await runInit( + "pm", + { path: tempRoot }, + { defaults: true, agentGuidance: "skip" }, + ); expect(skipped.agent_guidance).toMatchObject({ mode: "skip", present: false, @@ -677,7 +1020,11 @@ describe("runInit", () => { }); expect(skipped.warnings).toContain("agent_guidance:explicit_skip"); - const added = await runInit("pm", { path: tempRoot }, { agentGuidance: "add" }); + const added = await runInit( + "pm", + { path: tempRoot }, + { agentGuidance: "add" }, + ); const guidancePath = path.join(tempRoot, "AGENTS.md"); const firstGuidance = await readFile(guidancePath, "utf8"); expect(added.agent_guidance).toMatchObject({ @@ -687,16 +1034,26 @@ describe("runInit", () => { declined: false, prompt_completed: true, }); - expect(firstGuidance).toContain("<!-- pm-cli:agent-guidance:start:v1 -->"); + expect(firstGuidance).toContain( + "<!-- pm-cli:agent-guidance:start:v1 -->", + ); expect(firstGuidance).toContain("pm context --limit 10"); expect(firstGuidance).toContain("PM_AUTHOR"); - const readded = await runInit("pm", { path: tempRoot }, { agentGuidance: "add" }); + const readded = await runInit( + "pm", + { path: tempRoot }, + { agentGuidance: "add" }, + ); const secondGuidance = await readFile(guidancePath, "utf8"); expect(readded.agent_guidance.applied).toBe(false); expect(secondGuidance).toBe(firstGuidance); - const status = await runInit("pm", { path: tempRoot }, { agentGuidance: "status" }); + const status = await runInit( + "pm", + { path: tempRoot }, + { agentGuidance: "status" }, + ); expect(status.agent_guidance).toMatchObject({ mode: "status", present: true, @@ -708,9 +1065,15 @@ describe("runInit", () => { }); it("reports guidance status, prior decline skip, and existing CLAUDE guidance", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-agent-guidance-status-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-agent-guidance-status-"), + ); try { - const statusMissing = await runInit("pm", { path: tempRoot }, { defaults: true, agentGuidance: "status" }); + const statusMissing = await runInit( + "pm", + { path: tempRoot }, + { defaults: true, agentGuidance: "status" }, + ); expect(statusMissing.agent_guidance).toMatchObject({ mode: "status", present: false, @@ -720,11 +1083,19 @@ describe("runInit", () => { `Add workflow guidance later: pm --pm-path ${tempRoot} init --agent-guidance add`, ); - const skipped = await runInit("pm", { path: tempRoot }, { defaults: true, agentGuidance: "skip" }); + const skipped = await runInit( + "pm", + { path: tempRoot }, + { defaults: true, agentGuidance: "skip" }, + ); const declinedAt = skipped.settings.agent_guidance.declined_at; expect(declinedAt).not.toBe(""); - const skippedAgain = await runInit("pm", { path: tempRoot }, { defaults: true }); + const skippedAgain = await runInit( + "pm", + { path: tempRoot }, + { defaults: true }, + ); expect(skippedAgain.agent_guidance).toMatchObject({ mode: "ask", skipped: true, @@ -732,7 +1103,9 @@ describe("runInit", () => { prompt_completed: true, }); expect(skippedAgain.settings.agent_guidance.declined_at).toBe(declinedAt); - expect(skippedAgain.warnings).toContain("agent_guidance:skipped_declined"); + expect(skippedAgain.warnings).toContain( + "agent_guidance:skipped_declined", + ); await writeFile( path.join(tempRoot, "CLAUDE.md"), @@ -752,20 +1125,28 @@ describe("runInit", () => { ].join("\n"), "utf8", ); - const detected = await runInit("pm", { path: tempRoot }, { defaults: true }); + const detected = await runInit( + "pm", + { path: tempRoot }, + { defaults: true }, + ); expect(detected.agent_guidance).toMatchObject({ present: true, declined: false, prompt_completed: true, }); - expect(detected.agent_guidance.files_with_guidance).toEqual(["CLAUDE.md"]); + expect(detected.agent_guidance.files_with_guidance).toEqual([ + "CLAUDE.md", + ]); } finally { await rm(tempRoot, { recursive: true, force: true }); } }); it("appends guidance to existing AGENTS files and preserves CRLF endings", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-agent-guidance-replace-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-agent-guidance-replace-"), + ); try { const guidancePath = path.join(tempRoot, "AGENTS.md"); await writeFile( @@ -788,14 +1169,20 @@ describe("runInit", () => { }; setActiveExtensionHooks(hooks); - const result = await runInit("pm", { path: path.join(tempRoot, ".agents", "pm") }, { defaults: true, agentGuidance: "add" }); + const result = await runInit( + "pm", + { path: path.join(tempRoot, ".agents", "pm") }, + { defaults: true, agentGuidance: "add" }, + ); const guidance = await readFile(guidancePath, "utf8"); expect(result.agent_guidance).toMatchObject({ present: true, applied: true, }); expect(guidance).toContain("<!-- pm-cli:agent-guidance:start:v1 -->\r\n"); - expect(guidance).toContain("Set `PM_AUTHOR=<stable-agent-id>` before mutation commands."); + expect(guidance).toContain( + "Set `PM_AUTHOR=<stable-agent-id>` before mutation commands.", + ); expect(guidance).toContain("# Existing\r\n"); } finally { await rm(tempRoot, { recursive: true, force: true }); @@ -803,27 +1190,71 @@ describe("runInit", () => { }); it("validates init option values before writing tracker settings", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-invalid-options-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-invalid-options-"), + ); try { - await expect(runInit("pm", { path: path.join(tempRoot, "preset") }, { preset: " " })).rejects.toMatchObject<PmCliError>({ + await expect( + runInit( + "pm", + { path: path.join(tempRoot, "preset") }, + { preset: " " }, + ), + ).rejects.toMatchObject<PmCliError>({ message: expect.stringContaining("--preset must not be empty"), }); - await expect(runInit("pm", { path: path.join(tempRoot, "preset-bad") }, { preset: "heavy" })).rejects.toMatchObject<PmCliError>({ + await expect( + runInit( + "pm", + { path: path.join(tempRoot, "preset-bad") }, + { preset: "heavy" }, + ), + ).rejects.toMatchObject<PmCliError>({ message: expect.stringContaining("Invalid --preset value"), }); - await expect(runInit("pm", { path: path.join(tempRoot, "type") }, { typePreset: " " })).rejects.toMatchObject<PmCliError>({ + await expect( + runInit( + "pm", + { path: path.join(tempRoot, "type") }, + { typePreset: " " }, + ), + ).rejects.toMatchObject<PmCliError>({ message: expect.stringContaining("--type-preset must not be empty"), }); - await expect(runInit("pm", { path: path.join(tempRoot, "type-bad") }, { typePreset: "sales" })).rejects.toMatchObject<PmCliError>({ + await expect( + runInit( + "pm", + { path: path.join(tempRoot, "type-bad") }, + { typePreset: "sales" }, + ), + ).rejects.toMatchObject<PmCliError>({ message: expect.stringContaining("Invalid --type-preset value"), }); - await expect(runInit("pm", { path: path.join(tempRoot, "author") }, { author: " " })).rejects.toMatchObject<PmCliError>({ + await expect( + runInit( + "pm", + { path: path.join(tempRoot, "author") }, + { author: " " }, + ), + ).rejects.toMatchObject<PmCliError>({ message: expect.stringContaining("--author must not be empty"), }); - await expect(runInit("pm", { path: path.join(tempRoot, "guidance") }, { agentGuidance: " " })).rejects.toMatchObject<PmCliError>({ + await expect( + runInit( + "pm", + { path: path.join(tempRoot, "guidance") }, + { agentGuidance: " " }, + ), + ).rejects.toMatchObject<PmCliError>({ message: expect.stringContaining("--agent-guidance must not be empty"), }); - await expect(runInit("pm", { path: path.join(tempRoot, "guidance-bad") }, { agentGuidance: "maybe" })).rejects.toMatchObject<PmCliError>({ + await expect( + runInit( + "pm", + { path: path.join(tempRoot, "guidance-bad") }, + { agentGuidance: "maybe" }, + ), + ).rejects.toMatchObject<PmCliError>({ message: expect.stringContaining("Invalid --agent-guidance value"), }); } finally { @@ -832,9 +1263,15 @@ describe("runInit", () => { }); it("applies governance presets through init options for new and existing trackers", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-governance-preset-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-governance-preset-"), + ); try { - const strictInit = await runInit("pm", { path: tempRoot }, { preset: "strict" }); + const strictInit = await runInit( + "pm", + { path: tempRoot }, + { preset: "strict" }, + ); expect(strictInit.governance_preset).toBe("strict"); expect(strictInit.wizard_used).toBe(false); expect(strictInit.settings.governance).toMatchObject({ @@ -844,16 +1281,24 @@ describe("runInit", () => { close_validation_default: "strict", }); - await expect(runInit("pm", { path: tempRoot }, { preset: "minimal" })).rejects.toMatchObject<PmCliError>({ + await expect( + runInit("pm", { path: tempRoot }, { preset: "minimal" }), + ).rejects.toMatchObject<PmCliError>({ context: expect.objectContaining({ code: "init_existing_settings_requires_force", required: "--force for governance_preset", }), }); - const minimalInit = await runInit("pm", { path: tempRoot }, { preset: "minimal", force: true }); + const minimalInit = await runInit( + "pm", + { path: tempRoot }, + { preset: "minimal", force: true }, + ); expect(minimalInit.governance_preset).toBe("minimal"); - expect(minimalInit.warnings).toContain("updated:governance_preset:minimal"); + expect(minimalInit.warnings).toContain( + "updated:governance_preset:minimal", + ); expect(minimalInit.settings.governance).toMatchObject({ preset: "minimal", ownership_enforcement: "none", @@ -866,18 +1311,26 @@ describe("runInit", () => { }); it("can install bundled first-party packages during initialization", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-with-packages-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-with-packages-"), + ); try { - const result = await runInit("pm", { path: tempRoot }, { defaults: true, author: "init-agent", withPackages: true }); + const result = await runInit( + "pm", + { path: tempRoot }, + { defaults: true, author: "init-agent", withPackages: true }, + ); expect(result.ok).toBe(true); expect(result.installed_packages).toMatchObject({ installed_all: true, }); - expect(result.installed_packages?.installed_count).toBeGreaterThanOrEqual(8); - expect(result.installed_packages?.packages.map((entry) => entry.alias)).toEqual( - expect.arrayContaining(["calendar", "templates"]), + expect(result.installed_packages?.installed_count).toBeGreaterThanOrEqual( + 8, ); + expect( + result.installed_packages?.packages.map((entry) => entry.alias), + ).toEqual(expect.arrayContaining(["calendar", "templates"])); const persisted = await readSettings(tempRoot); expect(persisted.author_default).toBe("init-agent"); @@ -900,9 +1353,9 @@ describe("runInit", () => { warnings: [], })); vi.doMock("../../../src/cli/commands/extension.js", async () => { - const actual = await vi.importActual<typeof import("../../../src/cli/commands/extension.js")>( - "../../../src/cli/commands/extension.js", - ); + const actual = await vi.importActual< + typeof import("../../../src/cli/commands/extension.js") + >("../../../src/cli/commands/extension.js"); return { ...actual, runExtension: runExtensionMock, @@ -911,13 +1364,21 @@ describe("runInit", () => { try { const initModule = await import("../../../src/cli/commands/init.js"); - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-with-packages-fail-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-with-packages-fail-"), + ); try { await expect( - initModule.runInit("pm", { path: tempRoot }, { defaults: true, withPackages: true, agentGuidance: "skip" }), + initModule.runInit( + "pm", + { path: tempRoot }, + { defaults: true, withPackages: true, agentGuidance: "skip" }, + ), ).rejects.toMatchObject({ exitCode: EXIT_CODE.GENERIC_FAILURE, - message: expect.stringContaining("pm init --with-packages did not install all bundled packages successfully: calendar"), + message: expect.stringContaining( + "pm init --with-packages did not install all bundled packages successfully: calendar", + ), }); } finally { await rm(tempRoot, { recursive: true, force: true }); @@ -930,10 +1391,16 @@ describe("runInit", () => { details: { installed_all: false, installed_count: 0, packages: [] }, warnings: [], }); - const emptyFailureRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-with-packages-empty-fail-")); + const emptyFailureRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-with-packages-empty-fail-"), + ); try { await expect( - initModule.runInit("pm", { path: emptyFailureRoot }, { defaults: true, withPackages: true, agentGuidance: "skip" }), + initModule.runInit( + "pm", + { path: emptyFailureRoot }, + { defaults: true, withPackages: true, agentGuidance: "skip" }, + ), ).rejects.toMatchObject({ exitCode: EXIT_CODE.GENERIC_FAILURE, message: expect.stringMatching(/successfully\.$/), @@ -976,7 +1443,9 @@ describe("runInit", () => { setActiveExtensionHooks(hooks); const result = await runInit("pm", { path: tempRoot }); - const expectedTargets = PM_REQUIRED_SUBDIRS.map((subdir) => (subdir ? path.join(tempRoot, subdir) : tempRoot)); + const expectedTargets = PM_REQUIRED_SUBDIRS.map((subdir) => + subdir ? path.join(tempRoot, subdir) : tempRoot, + ); const settingsPath = path.join(tempRoot, "settings.json"); const expectedSchemaFiles = [ path.join(tempRoot, "schema", "types.json"), @@ -988,11 +1457,16 @@ describe("runInit", () => { expect(trace).toEqual([ ...expectedTargets.map((target) => `init:ensure_dir:${target}`), `settings:write:${settingsPath}`, - ...expectedSchemaFiles.map((target) => `init:runtime_schema_file:${target}`), + ...expectedSchemaFiles.map( + (target) => `init:runtime_schema_file:${target}`, + ), ]); expect(result.warnings).toContain(`already_exists:${tempRoot}`); expect( - result.warnings.filter((warning) => warning === "extension_hook_failed:project:init-write-boom:onWrite"), + result.warnings.filter( + (warning) => + warning === "extension_hook_failed:project:init-write-boom:onWrite", + ), ).toHaveLength(PM_REQUIRED_SUBDIRS.length + 4); } finally { await rm(tempRoot, { recursive: true, force: true }); @@ -1002,18 +1476,24 @@ describe("runInit", () => { it("covers the init wizard choices through a mocked readline prompt", async () => { const writes: string[] = []; const close = vi.fn(); - const question = vi.fn() + const question = vi + .fn() .mockResolvedValueOnce(" Ticket ") .mockResolvedValueOnce("strict") .mockResolvedValueOnce("n"); - initInternals.setInitReadlineFactoryForTests(() => ({ - question, - close, - } as unknown as ReturnType<typeof readline.createInterface>)); - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { - writes.push(String(chunk)); - return true; - }); + initInternals.setInitReadlineFactoryForTests( + () => + ({ + question, + close, + }) as unknown as ReturnType<typeof readline.createInterface>, + ); + const writeSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }); try { const choices = await initInternals.runInitWizard("pm-", true); @@ -1024,8 +1504,12 @@ describe("runInit", () => { telemetry_enabled: false, }); expect(question).toHaveBeenCalledWith("Item ID prefix [pm-]: "); - expect(question).toHaveBeenCalledWith("Governance preset [minimal/default/strict] (default: minimal): "); - expect(question).toHaveBeenCalledWith("Enable telemetry for this project? [Y/n] "); + expect(question).toHaveBeenCalledWith( + "Governance preset [minimal/default/strict] (default: minimal): ", + ); + expect(question).toHaveBeenCalledWith( + "Enable telemetry for this project? [Y/n] ", + ); expect(writes.join("")).toContain("pm init setup wizard"); expect(close).toHaveBeenCalledTimes(1); } finally { @@ -1036,23 +1520,31 @@ describe("runInit", () => { it("defaults an unrecognized governance preset answer to minimal instead of aborting the wizard", async () => { const writes: string[] = []; const close = vi.fn(); - const question = vi.fn() + const question = vi + .fn() .mockResolvedValueOnce("pm-") .mockResolvedValueOnce("totally-bogus-preset") .mockResolvedValueOnce("y"); - initInternals.setInitReadlineFactoryForTests(() => ({ - question, - close, - } as unknown as ReturnType<typeof readline.createInterface>)); - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { - writes.push(String(chunk)); - return true; - }); + initInternals.setInitReadlineFactoryForTests( + () => + ({ + question, + close, + }) as unknown as ReturnType<typeof readline.createInterface>, + ); + const writeSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }); try { const choices = await initInternals.runInitWizard("pm-", true); expect(choices.preset).toBe("minimal"); - expect(writes.join("")).toContain('Unrecognized governance preset "totally-bogus-preset"; using minimal.'); + expect(writes.join("")).toContain( + 'Unrecognized governance preset "totally-bogus-preset"; using minimal.', + ); expect(close).toHaveBeenCalledTimes(1); } finally { writeSpy.mockRestore(); @@ -1062,15 +1554,21 @@ describe("runInit", () => { it("falls back to wizard defaults on blank answers and restores readline fallback factory", async () => { const close = vi.fn(); - const question = vi.fn() + const question = vi + .fn() .mockResolvedValueOnce(" ") .mockResolvedValueOnce(" ") .mockResolvedValueOnce(" "); - initInternals.setInitReadlineFactoryForTests(() => ({ - question, - close, - } as unknown as ReturnType<typeof readline.createInterface>)); - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + initInternals.setInitReadlineFactoryForTests( + () => + ({ + question, + close, + }) as unknown as ReturnType<typeof readline.createInterface>, + ); + const writeSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); try { const choices = await initInternals.runInitWizard("pm-", false); @@ -1079,7 +1577,9 @@ describe("runInit", () => { preset: "minimal", telemetry_enabled: false, }); - expect(question).toHaveBeenCalledWith("Enable telemetry for this project? [y/N] "); + expect(question).toHaveBeenCalledWith( + "Enable telemetry for this project? [y/N] ", + ); expect(close).toHaveBeenCalledTimes(1); } finally { writeSpy.mockRestore(); @@ -1090,7 +1590,8 @@ describe("runInit", () => { it("uses the default readline factory when no test factory is configured", async () => { vi.resetModules(); const close = vi.fn(); - const question = vi.fn() + const question = vi + .fn() .mockResolvedValueOnce("pm-") .mockResolvedValueOnce("minimal") .mockResolvedValueOnce("y") @@ -1106,18 +1607,27 @@ describe("runInit", () => { createInterface: createInterfaceMock, }, })); - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const writeSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); try { - const mockedInitModule = await import("../../../src/cli/commands/init.js"); - const firstChoices = await mockedInitModule._testOnly.runInitWizard("pm-", true); + const mockedInitModule = + await import("../../../src/cli/commands/init.js"); + const firstChoices = await mockedInitModule._testOnly.runInitWizard( + "pm-", + true, + ); expect(firstChoices).toEqual({ prefix: "pm-", preset: "minimal", telemetry_enabled: true, }); mockedInitModule._testOnly.setInitReadlineFactoryForTests(undefined); - const secondChoices = await mockedInitModule._testOnly.runInitWizard("pm-", true); + const secondChoices = await mockedInitModule._testOnly.runInitWizard( + "pm-", + true, + ); expect(secondChoices).toEqual({ prefix: "pm-", preset: "minimal", @@ -1135,7 +1645,8 @@ describe("runInit", () => { it("uses default agent-guidance readline fallback and no-op write path", async () => { vi.resetModules(); const close = vi.fn(); - const question = vi.fn() + const question = vi + .fn() .mockResolvedValueOnce("no") .mockResolvedValueOnce("yes"); const createInterfaceMock = vi.fn(() => ({ @@ -1147,23 +1658,40 @@ describe("runInit", () => { createInterface: createInterfaceMock, }, })); - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-guidance-default-factory-")); + const writeSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-guidance-default-factory-"), + ); try { - const mockedGuidanceModule = await import("../../../src/cli/commands/init-agent-guidance.js"); - const declinedPrompt = await mockedGuidanceModule._testOnly.promptForGuidanceWrite("AGENTS.md"); + const mockedGuidanceModule = + await import("../../../src/cli/commands/init-agent-guidance.js"); + const declinedPrompt = + await mockedGuidanceModule._testOnly.promptForGuidanceWrite( + "AGENTS.md", + ); expect(declinedPrompt).toBe(false); - mockedGuidanceModule._testOnly.setAgentGuidanceReadlineFactoryForTests(undefined); - const acceptedPrompt = await mockedGuidanceModule._testOnly.promptForGuidanceWrite("AGENTS.md"); + mockedGuidanceModule._testOnly.setAgentGuidanceReadlineFactoryForTests( + undefined, + ); + const acceptedPrompt = + await mockedGuidanceModule._testOnly.promptForGuidanceWrite( + "AGENTS.md", + ); expect(acceptedPrompt).toBe(true); expect(createInterfaceMock).toHaveBeenCalledTimes(2); expect(close).toHaveBeenCalledTimes(2); const guidancePath = path.join(tempRoot, "AGENTS.md"); - const normalizedGuidance = mockedGuidanceModule._testOnly.upsertAgentGuidanceBlock("# Existing header").next_content; + const normalizedGuidance = + mockedGuidanceModule._testOnly.upsertAgentGuidanceBlock( + "# Existing header", + ).next_content; await writeFile(guidancePath, normalizedGuidance, "utf8"); - const noChangeWrite = await mockedGuidanceModule._testOnly.writeGuidanceFile(guidancePath); + const noChangeWrite = + await mockedGuidanceModule._testOnly.writeGuidanceFile(guidancePath); expect(noChangeWrite).toEqual({ changed: false, warnings: [] }); } finally { await rm(tempRoot, { recursive: true, force: true }); @@ -1174,22 +1702,38 @@ describe("runInit", () => { }); it("uses the init wizard when running interactively without defaults", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-interactive-wizard-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-interactive-wizard-"), + ); const close = vi.fn(); - const question = vi.fn() + const question = vi + .fn() .mockResolvedValueOnce(" Ticket ") .mockResolvedValueOnce("strict") .mockResolvedValueOnce("n"); - initInternals.setInitReadlineFactoryForTests(() => ({ - question, - close, - } as unknown as ReturnType<typeof readline.createInterface>)); - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); + initInternals.setInitReadlineFactoryForTests( + () => + ({ + question, + close, + }) as unknown as ReturnType<typeof readline.createInterface>, + ); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); vi.spyOn(process.stdout, "write").mockImplementation(() => true); try { - const result = await runInit("pm", { path: tempRoot }, { agentGuidance: "skip" }); + const result = await runInit( + "pm", + { path: tempRoot }, + { agentGuidance: "skip" }, + ); expect(result.wizard_used).toBe(true); expect(result.settings.id_prefix).toBe("ticket-"); @@ -1203,17 +1747,28 @@ describe("runInit", () => { }); it("prompts for missing agent guidance interactively and records accept/decline state", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-guidance-interactive-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-guidance-interactive-"), + ); try { - await runInit("pm", { path: tempRoot }, { defaults: true, agentGuidance: "status" }); + await runInit( + "pm", + { path: tempRoot }, + { defaults: true, agentGuidance: "status" }, + ); const acceptedSettings = await readSettings(tempRoot); const acceptClose = vi.fn(); const acceptQuestion = vi.fn().mockResolvedValue("yes"); - initGuidanceInternals.setAgentGuidanceReadlineFactoryForTests(() => ({ - question: acceptQuestion, - close: acceptClose, - } as unknown as ReturnType<typeof readline.createInterface>)); - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + initGuidanceInternals.setAgentGuidanceReadlineFactoryForTests( + () => + ({ + question: acceptQuestion, + close: acceptClose, + }) as unknown as ReturnType<typeof readline.createInterface>, + ); + const writeSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); const accepted = await runInitAgentGuidance({ pm_root: tempRoot, @@ -1231,17 +1786,28 @@ describe("runInit", () => { prompt_completed: true, }); expect(accepted.warnings).toContain("agent_guidance:added:AGENTS.md"); - expect(acceptQuestion).toHaveBeenCalledWith("Add a compact pm workflow section to AGENTS.md? [Y/n] "); + expect(acceptQuestion).toHaveBeenCalledWith( + "Add a compact pm workflow section to AGENTS.md? [Y/n] ", + ); expect(acceptClose).toHaveBeenCalledTimes(1); - const declinedRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-guidance-decline-")); - await runInit("pm", { path: declinedRoot }, { defaults: true, agentGuidance: "status" }); + const declinedRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-guidance-decline-"), + ); + await runInit( + "pm", + { path: declinedRoot }, + { defaults: true, agentGuidance: "status" }, + ); const declinedSettings = await readSettings(declinedRoot); const declineQuestion = vi.fn().mockResolvedValue("no"); - initGuidanceInternals.setAgentGuidanceReadlineFactoryForTests(() => ({ - question: declineQuestion, - close: vi.fn(), - } as unknown as ReturnType<typeof readline.createInterface>)); + initGuidanceInternals.setAgentGuidanceReadlineFactoryForTests( + () => + ({ + question: declineQuestion, + close: vi.fn(), + }) as unknown as ReturnType<typeof readline.createInterface>, + ); const declined = await runInitAgentGuidance({ pm_root: declinedRoot, @@ -1259,7 +1825,9 @@ describe("runInit", () => { prompt_completed: true, }); expect(declined.warnings).toContain("agent_guidance:declined"); - expect(declined.next_steps).toContain("Add workflow guidance later: pm init --agent-guidance add"); + expect(declined.next_steps).toContain( + "Add workflow guidance later: pm init --agent-guidance add", + ); await rm(declinedRoot, { recursive: true, force: true }); writeSpy.mockRestore(); } finally { @@ -1285,14 +1853,16 @@ describe("runInit", () => { missing_files: ["AGENTS.md", "CLAUDE.md"], }, warnings: [], - next_steps: ["Set PM_AUTHOR=<your-agent-id> so mutations attribute to the right caller."], + next_steps: [ + "Set PM_AUTHOR=<your-agent-id> so mutations attribute to the right caller.", + ], settings_changed: false, })); vi.doMock("../../../src/cli/commands/init-agent-guidance.js", async () => { - const actual = await vi.importActual<typeof import("../../../src/cli/commands/init-agent-guidance.js")>( - "../../../src/cli/commands/init-agent-guidance.js", - ); + const actual = await vi.importActual< + typeof import("../../../src/cli/commands/init-agent-guidance.js") + >("../../../src/cli/commands/init-agent-guidance.js"); return { ...actual, runInitAgentGuidance: runInitAgentGuidanceMock, @@ -1301,12 +1871,20 @@ describe("runInit", () => { try { const initModule = await import("../../../src/cli/commands/init.js"); - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-next-step-dedupe-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-next-step-dedupe-"), + ); try { - const result = await initModule.runInit("pm", { path: tempRoot }, { defaults: true, agentGuidance: "status" }); + const result = await initModule.runInit( + "pm", + { path: tempRoot }, + { defaults: true, agentGuidance: "status" }, + ); expect( result.next_steps.filter( - (step) => step === "Set PM_AUTHOR=<your-agent-id> so mutations attribute to the right caller.", + (step) => + step === + "Set PM_AUTHOR=<your-agent-id> so mutations attribute to the right caller.", ), ).toHaveLength(1); } finally { @@ -1319,9 +1897,15 @@ describe("runInit", () => { }); it("keeps agent guidance unchanged when add mode runs on an already-upserted file", async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pm-init-guidance-noop-")); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "pm-init-guidance-noop-"), + ); try { - await runInit("pm", { path: tempRoot }, { defaults: true, agentGuidance: "status" }); + await runInit( + "pm", + { path: tempRoot }, + { defaults: true, agentGuidance: "status" }, + ); const settings = await readSettings(tempRoot); const firstAdd = await runInitAgentGuidance({ diff --git a/tests/unit/mcp/mcp-server-branch-residual.spec.ts b/tests/unit/mcp/mcp-server-branch-residual.spec.ts index e4199d50d..22b56ab59 100644 --- a/tests/unit/mcp/mcp-server-branch-residual.spec.ts +++ b/tests/unit/mcp/mcp-server-branch-residual.spec.ts @@ -22,6 +22,7 @@ const FILES_SDK_MODULE = "../../../src/sdk/files.js"; const HISTORY_COMPACT_SDK_MODULE = "../../../src/sdk/history-compact.js"; const HISTORY_REDACT_SDK_MODULE = "../../../src/sdk/history-redact.js"; const HISTORY_REPAIR_SDK_MODULE = "../../../src/sdk/history-repair.js"; +const SCHEMA_SDK_MODULE = "../../../src/sdk/schema.js"; const PACKAGE_ROOT_MODULE = "../../../src/core/packages/root.js"; const EXTENSIONS_MODULE = "../../../src/core/extensions/index.js"; const TOOLS_MODULE = "../../../src/mcp/tool-definitions.js"; @@ -133,6 +134,18 @@ async function importServerWithCommandMocks( runHistoryRepair: commandMocks.runHistoryRepair, runHistoryRepairAll: commandMocks.runHistoryRepairAll, })); + vi.doMock(SCHEMA_SDK_MODULE, async () => { + const actual = + await vi.importActual<typeof import("../../../src/sdk/schema.js")>( + SCHEMA_SDK_MODULE, + ); + return { + ...actual, + runSchemaAddStatus: commandMocks.runSchemaAddStatus, + runSchemaAddType: commandMocks.runSchemaAddType, + runSchemaInferTypes: commandMocks.runSchemaInferTypes, + }; + }); return import("../../../src/mcp/server.js"); } @@ -146,6 +159,7 @@ describe("mcp server branch residual coverage", () => { vi.doUnmock(HISTORY_COMPACT_SDK_MODULE); vi.doUnmock(HISTORY_REDACT_SDK_MODULE); vi.doUnmock(HISTORY_REPAIR_SDK_MODULE); + vi.doUnmock(SCHEMA_SDK_MODULE); vi.doUnmock(PACKAGE_ROOT_MODULE); vi.doUnmock(EXTENSIONS_MODULE); vi.doUnmock(TOOLS_MODULE); From 8b2f6a2536622840d6c191b21bfdb496ed13aeda Mon Sep 17 00:00:00 2001 From: Stefan Preu <stefan@preu.at> Date: Tue, 14 Jul 2026 22:45:38 +0200 Subject: [PATCH 2/3] fix(init): harden prefix inputs and consolidate schema guards Reject path-like --id-prefix values with structured recovery guidance, centralize schema initialization checks, and deduplicate stable warning finalization across all mutation results. Record round-one Greptile and CodeRabbit decisions in pm, including evidence that the --prefix alias is correctly forwarded. Verified focused tests, static quality, and all 5,701 tests at exact 100/100/100/100 coverage. --- .agents/pm/features/pm-3mna.toon | 5 +- .agents/pm/history/pm-3mna.jsonl | 5 ++ .agents/pm/history/pm-nmzx.jsonl | 6 +++ .agents/pm/issues/pm-nmzx.toon | 13 ++--- src/cli/commands/init.ts | 15 ++++++ src/sdk/schema.ts | 53 ++++++------------- .../init-path-guard.integration.spec.ts | 14 +++++ tests/unit/commands/init-command.spec.ts | 7 +++ 8 files changed, 74 insertions(+), 44 deletions(-) diff --git a/.agents/pm/features/pm-3mna.toon b/.agents/pm/features/pm-3mna.toon index 97e25a5cb..1183d9b18 100644 --- a/.agents/pm/features/pm-3mna.toon +++ b/.agents/pm/features/pm-3mna.toon @@ -6,7 +6,7 @@ status: open priority: 2 tags[4]: architecture,"area:config","area:schema","area:sdk" created_at: "2026-07-05T04:54:11.971Z" -updated_at: "2026-07-14T20:19:54.802Z" +updated_at: "2026-07-14T20:44:57.242Z" author: maintainer-agent estimated_minutes: 360 acceptance_criteria: SDK exports typed schema/config/profile/init primitives with the same validation + never-block errors as the CLI; corresponding commands consume only these primitives; docstrings + linked tests + SDK reference docs; zero behavior change @@ -17,7 +17,7 @@ dependencies[3]{id,kind,created_at,author,source_kind}: pm-lodl,blocked_by,"2026-07-05T04:55:07.462Z",null,null pm-muhw,blocked_by,"2026-07-05T04:55:08.057Z",null,null pm-69nl,related,"2026-07-10T17:32:56.925Z",null,null -comments[12]{created_at,author,text}: +comments[13]{created_at,author,text}: "2026-07-07T20:44:47.549Z",codex-agent,"Implementation start: bundled with pm-zwpp, pm-oxrw, pm-m4iu, pm-cbwg, pm-w89s, and pm-usfg. Live duplicate/context check found no closed schema/config/profile SDK primitive completion; prior read/lifecycle/package primitives remain separate shipped or in-progress domains." "2026-07-07T22:45:27.701Z",claude-code-agent,"Evidence: post-review docs/API cleanup keeps package authors on the correct context-management primitive shapes: linked file/doc adds use string arrays and top-level note, while SchemaResult documents the generic schema customization result union. Verified through docs gate, typecheck, focused SDK tests, and packed installed SDK proof." "2026-07-07T23:52:21.378Z",claude-code-agent,"Review follow-up evidence: the generic SDK schema() return type now explicitly includes all concrete inspect results as well as schema mutation results, preserving compile-time safety for customization primitive consumers. Focused SDK test and full coverage passed." @@ -30,6 +30,7 @@ comments[12]{created_at,author,text}: "2026-07-14T19:24:55.099Z",codex-gpt5,"Implementation start 2026-07-14: live full-tracker/all-status and GitHub reconciliation selected the schema domain extraction as the broad SDK-first lane. This run will move schema primitive/types ownership out of src/cli, make CLI registration a presentation adapter, update public SDK docs/tests, and preserve behavior." "2026-07-14T19:40:59.373Z",codex-gpt5,"Progress evidence: schema inspection/mutation ownership now lives in src/sdk/schema.ts; the CLI module is a compatibility re-export and SDK runtime imports schema primitives directly. Focused schema/SDK tests, 100% docstring static gate, package boundary, token budget, docs/skills, and governed contract checks pass." "2026-07-14T20:19:47.964Z",codex-root,"Final branch evidence: schema inspection/mutation ownership moved from the CLI into src/sdk/schema.ts; the CLI is now a compatibility re-export and SDK runtime/MCP consume the SDK-owned engine. The import-boundary ratchet removed 17 schema edges, stale suppressions were deleted, and the hard suppression budget fell 174 -> 170. Exact verification on the final source: 290 files / 5,701 tests; 36,848/36,848 statements, 26,463/26,463 branches, 7,438/7,438 functions, 35,963/35,963 lines; typecheck, strict lint, zero-clone, 100% module/export/member docstrings, package boundary, token budget, docs, contracts, security, version, packed npm smoke, 88-command dogfood, and fresh external PmClient schema/custom-type lifecycle all pass. Broader config/profile/init implementation ownership remains for this feature, so it returns to open." + "2026-07-14T20:44:53.761Z",codex-root,"PR #563 round-1 review: accepted both CodeRabbit maintainability findings. runSchemaAddType and loadSchemaInspectionContext now share ensureInitialized; all eight mutation results use finalizeWarnings for stable dedupe/sort. Focused schema/init/MCP tests, typecheck, static quality, and exact 100/100/100/100 coverage pass. The CodeRabbit external docstring heuristic warning is contradicted by the repository's mandatory 100% module/export/member docstring gate, which remains green." notes[3]{created_at,author,text}: "2026-07-10T17:32:56.449Z",maintainer-agent,"Implementation slicing 2026-07-10 (grounded in current src state). Already public — do NOT redo: profile read-side (presets/plan/describe/lint exported via src/sdk/index.ts), schema runtime registries (resolveRuntimeFieldRegistry/resolveRuntimeStatusRegistry via src/sdk/runtime.ts), and generic PmClient.runTyped wrappers for the schema/config/profile/init actions. The actual gap: domain logic lives in CLI command modules — src/cli/commands/schema.ts (1973 lines), config.ts (1576), init.ts (955) + init-agent-guidance.ts (566), ~5.1k lines total — so SDK consumers reach these domains only through generic action dispatch, not typed primitives. Suggested slices: S1 schema mutation primitives (~150m) — extract add-type/add-field/status-def write paths into src/core/schema (fields-file/item-types-file/status-defs-file already own the file formats); command becomes parse+render. S2 config/settings primitives (~90m) — extract read/write/validate onto src/core/config/nested-settings; preserve the settings validator's unknown-key-stripping semantics (PR#314). S3 init + workspace-discovery primitive (~90m) — single source of truth for discovery; this is where the GH-501/pm-69nl class (init target invisible to discovery) gets fixed at the primitive level — coordinate with pm-69nl, do not duplicate its fix. S4 typed PmClient facades + SDK reference docs/tests (~60m) — replace generic payloads with typed inputs/outputs; each primitive must match the equivalent runAction result shape (semantics oracle, pm-usfg 07-06 note). Order: S1 first (schema gates the pm-w89s universality story); S2/S3 independent; S4 last." "2026-07-12T06:11:44.315Z",maintainer-agent,"Adjacent ship, scope intact: PR#520 (2026-07-12) promoted the FIRST workspace-customization primitive through the public SDK (ensurePmGitignore/getPmGitignoreBlock in src/sdk/workspace.ts) and made init consume it — proof the init-adjacent SDK boundary works end-to-end (packed-tarball external-consumer proof in PR verification). This does NOT deliver S1: schema/config/profile/init command families are still CLI-owned. Use src/sdk/workspace.ts as the pattern exemplar when starting S1." diff --git a/.agents/pm/history/pm-3mna.jsonl b/.agents/pm/history/pm-3mna.jsonl index 7386b5a41..c2ff63b57 100644 --- a/.agents/pm/history/pm-3mna.jsonl +++ b/.agents/pm/history/pm-3mna.jsonl @@ -53,3 +53,8 @@ {"ts":"2026-07-14T20:19:48.566Z","author":"codex-root","op":"learning_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:48.566Z"},{"op":"add","path":"/metadata/learnings","value":[{"created_at":"2026-07-14T20:19:48.566Z","author":"codex-root","text":"Moving a CLI command engine into the SDK must also migrate its lint baseline, residual test mocks, and runtime imports; keeping a tiny CLI re-export preserves compatibility while the SDK becomes the single implementation owner."}]}],"before_hash":"d8b4a70eea24de3d4cbea22e5ba5fd8bff7e42f8a9d9dfdeaf87c50554df82ab","after_hash":"b4510c4f99b1485d35e84f31075e2070b6afcdb25cea7add57119b7cc3a86e25"} {"ts":"2026-07-14T20:19:53.955Z","author":"codex-root","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:53.955Z"},{"op":"replace","path":"/metadata/status","value":"open"}],"before_hash":"b4510c4f99b1485d35e84f31075e2070b6afcdb25cea7add57119b7cc3a86e25","after_hash":"dd533a0a2fa4edb96b0bed3d205bae59ecdc4b34cea56cf9882904a54b733032","message":"Schema ownership slice complete; return broader config/profile/init SDK program to open"} {"ts":"2026-07-14T20:19:54.802Z","author":"codex-root","op":"release","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:54.802Z"}],"before_hash":"dd533a0a2fa4edb96b0bed3d205bae59ecdc4b34cea56cf9882904a54b733032","after_hash":"1e2724c12e269e3e70d7087175b01d9fd6ebd2f681f9e4680d7135728bf20dcc","message":"Release after verified schema ownership slice"} +{"ts":"2026-07-14T20:38:43.534Z","author":"codex-root","op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:38:43.534Z"},{"op":"add","path":"/metadata/assignee","value":"codex-root"}],"before_hash":"1e2724c12e269e3e70d7087175b01d9fd6ebd2f681f9e4680d7135728bf20dcc","after_hash":"aba757704779d15d7f62788afbdfd4392e3295865441662cdbeb016f7f47b926"} +{"ts":"2026-07-14T20:38:44.846Z","author":"codex-root","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:38:44.846Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"aba757704779d15d7f62788afbdfd4392e3295865441662cdbeb016f7f47b926","after_hash":"168b7857a5b2c4539430cd9c69d006d55e5c1670ceccd1ce0dd9c311fca4c69e","message":"Address CodeRabbit schema consolidation findings on PR #563"} +{"ts":"2026-07-14T20:44:53.762Z","author":"codex-root","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/12","value":{"created_at":"2026-07-14T20:44:53.761Z","author":"codex-root","text":"PR #563 round-1 review: accepted both CodeRabbit maintainability findings. runSchemaAddType and loadSchemaInspectionContext now share ensureInitialized; all eight mutation results use finalizeWarnings for stable dedupe/sort. Focused schema/init/MCP tests, typecheck, static quality, and exact 100/100/100/100 coverage pass. The CodeRabbit external docstring heuristic warning is contradicted by the repository's mandatory 100% module/export/member docstring gate, which remains green."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:44:53.762Z"}],"before_hash":"168b7857a5b2c4539430cd9c69d006d55e5c1670ceccd1ce0dd9c311fca4c69e","after_hash":"79892d8ae5fb1a66484665196db3f64ed588fb709f5d773ed3fd06dca8d10345"} +{"ts":"2026-07-14T20:44:56.492Z","author":"codex-root","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:44:56.492Z"},{"op":"replace","path":"/metadata/status","value":"open"}],"before_hash":"79892d8ae5fb1a66484665196db3f64ed588fb709f5d773ed3fd06dca8d10345","after_hash":"ca051aa1e8bf353f4e9208019732c41e90687ee327137255f87a6529fbeac127","message":"Round-1 schema consolidation complete; return broader SDK program to open"} +{"ts":"2026-07-14T20:44:57.242Z","author":"codex-root","op":"release","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:44:57.242Z"}],"before_hash":"ca051aa1e8bf353f4e9208019732c41e90687ee327137255f87a6529fbeac127","after_hash":"006ca22b36fff3291abe1501dfaabd281b2fd60be3fcf26809e767173c9e9195","message":"Release after PR review hardening"} diff --git a/.agents/pm/history/pm-nmzx.jsonl b/.agents/pm/history/pm-nmzx.jsonl index 3507cd4a7..21d4c80e1 100644 --- a/.agents/pm/history/pm-nmzx.jsonl +++ b/.agents/pm/history/pm-nmzx.jsonl @@ -21,3 +21,9 @@ {"ts":"2026-07-14T20:19:51.329Z","author":"codex-root","op":"learning_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:51.329Z"},{"op":"add","path":"/metadata/learnings","value":[{"created_at":"2026-07-14T20:19:51.329Z","author":"codex-root","text":"Commander stores the canonical property from the long flag declaration, so --prefix, --id-prefix <value> forwards through options.idPrefix; resolving positional and flag forms in one normalizer keeps compatibility and conflict behavior deterministic."}]}],"before_hash":"fe51796aaba8f8b5e0c273533acf8d282b462a27fc8edfc1c5c278af790f2e83","after_hash":"d7a1250f9c080b202b3a65e14f78099ead54457bbc391e53add53ad744b57b30"} {"ts":"2026-07-14T20:19:52.247Z","author":"codex-root","op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:52.247Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-14T20:19:52.246Z"},{"op":"add","path":"/metadata/resolution","value":"completed"},{"op":"add","path":"/metadata/expected_result","value":"pm init accepts a discoverable id-prefix flag without breaking positional compatibility and rejects ambiguous dual inputs."},{"op":"add","path":"/metadata/actual_result","value":"Both --id-prefix and --prefix initialize normalized prefixes, equivalent positional/flag values compose, conflicts fail with code 2 and guidance, and all focused/full/package proofs pass."},{"op":"add","path":"/metadata/close_reason","value":"Implemented GH-562 end to end with canonical --id-prefix, --prefix alias, conflict validation, completions, contracts, docs, and installed temp-workspace proof."}],"before_hash":"d7a1250f9c080b202b3a65e14f78099ead54457bbc391e53add53ad744b57b30","after_hash":"81c2e6c2f3eade7e60628ec90382b7cad2e41f1b8a642f93706d10c51b07b86f"} {"ts":"2026-07-14T20:19:53.171Z","author":"codex-root","op":"release","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:19:53.171Z"}],"before_hash":"81c2e6c2f3eade7e60628ec90382b7cad2e41f1b8a642f93706d10c51b07b86f","after_hash":"871c32bef96a628ae9b2fb47f0fa64e159c3c6d243e29ef8eb0eaab780ae96e4","message":"Release after verified closure"} +{"ts":"2026-07-14T20:38:41.516Z","author":"codex-root","op":"update","patch":[{"op":"remove","path":"/metadata/close_reason"},{"op":"remove","path":"/metadata/closed_at"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:38:41.516Z"},{"op":"replace","path":"/metadata/status","value":"open"}],"before_hash":"871c32bef96a628ae9b2fb47f0fa64e159c3c6d243e29ef8eb0eaab780ae96e4","after_hash":"8bebb2714d75a2f57bce634438b284c5d4788dad5ed747bd5aeb2731e4d2f4b2","message":"Reopen for actionable PR #563 review hardening"} +{"ts":"2026-07-14T20:38:42.151Z","author":"codex-root","op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:38:42.151Z"},{"op":"add","path":"/metadata/assignee","value":"codex-root"}],"before_hash":"8bebb2714d75a2f57bce634438b284c5d4788dad5ed747bd5aeb2731e4d2f4b2","after_hash":"19d27e8090b11095be8b6914228981fa9a6afd3ce8ad8a3cf966ad5ce6e1be4e"} +{"ts":"2026-07-14T20:38:42.834Z","author":"codex-root","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:38:42.834Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"19d27e8090b11095be8b6914228981fa9a6afd3ce8ad8a3cf966ad5ce6e1be4e","after_hash":"b8a5cd584dbeb8208106de4460a2a0f969b77405a7781b0e8946a54263cb827f","message":"Address Greptile path-like id-prefix finding"} +{"ts":"2026-07-14T20:44:53.145Z","author":"codex-root","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/4","value":{"created_at":"2026-07-14T20:44:53.144Z","author":"codex-root","text":"PR #563 round-1 review: accepted Greptile's path-like flagged-prefix finding and added init_id_prefix_path_like refusal with actionable positional/global-path guidance plus unit/integration coverage. Rejected the separate alias-drop claim: Commander, repository integration tests, and Greptile T-Rex all prove --prefix ops persists ops-. All four inline bot comments were reacted to and answered in their real threads. Post-fix full suite remains 5,701 tests with 36,850/36,850 statements, 26,465/26,465 branches, 7,438/7,438 functions, and 35,965/35,965 lines."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:44:53.145Z"}],"before_hash":"b8a5cd584dbeb8208106de4460a2a0f969b77405a7781b0e8946a54263cb827f","after_hash":"c045a4526f1068b9f44f2976ab13eaebbb1156e1c55bf2e41032f26660ee804c"} +{"ts":"2026-07-14T20:44:54.830Z","author":"codex-root","op":"close","patch":[{"op":"replace","path":"/metadata/actual_result","value":"Both flag aliases persist normalized prefixes; equivalent dual inputs compose; conflicts, empty values, and path-like flagged values fail with structured code-2 guidance; all focused/full/package proofs pass."},{"op":"replace","path":"/metadata/expected_result","value":"pm init accepts a discoverable id-prefix flag without breaking positional compatibility, rejects ambiguous dual inputs, and never stores tracker paths as item prefixes."},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:44:54.830Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-14T20:44:54.820Z"},{"op":"add","path":"/metadata/close_reason","value":"Implemented and review-hardened GH-562 with canonical --id-prefix, working --prefix alias, path-like value refusal, conflict validation, completions, contracts, docs, and installed proof."}],"before_hash":"c045a4526f1068b9f44f2976ab13eaebbb1156e1c55bf2e41032f26660ee804c","after_hash":"19fe193dc625a78ee3418a523605cf881bd5e6274f89082e2aaba55d6bdab9ff"} +{"ts":"2026-07-14T20:44:55.773Z","author":"codex-root","op":"release","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:44:55.773Z"}],"before_hash":"19fe193dc625a78ee3418a523605cf881bd5e6274f89082e2aaba55d6bdab9ff","after_hash":"d13015974d9ad0051d0d7fb26290c76cd8d38acf2199adcf0b6ff9c8c34dc8f9","message":"Release after PR review hardening"} diff --git a/.agents/pm/issues/pm-nmzx.toon b/.agents/pm/issues/pm-nmzx.toon index 693beb382..234aa2e40 100644 --- a/.agents/pm/issues/pm-nmzx.toon +++ b/.agents/pm/issues/pm-nmzx.toon @@ -6,8 +6,8 @@ status: closed priority: 2 tags[4]: agent-ux,cli,github-issue,init created_at: "2026-07-14T19:04:33.488Z" -updated_at: "2026-07-14T20:19:53.171Z" -closed_at: "2026-07-14T20:19:52.246Z" +updated_at: "2026-07-14T20:44:55.773Z" +closed_at: "2026-07-14T20:44:54.820Z" author: maintainer-agent estimated_minutes: 60 acceptance_criteria: "pm init accepts --id-prefix <value> as an alias for the positional prefix argument (positional form stays supported); conflicting positional + flag values fail fast with a clear message; help text, completion (bash+zsh+fish), contracts (cli-contracts.ts flag contracts + MCP schema), and docs updated per the new-flag wiring checklist; regression spec covers flag form, positional form, and the conflict case" @@ -15,16 +15,17 @@ parent: pm-mpbb risk: low confidence: high resolution: completed -expected_result: pm init accepts a discoverable id-prefix flag without breaking positional compatibility and rejects ambiguous dual inputs. -actual_result: "Both --id-prefix and --prefix initialize normalized prefixes, equivalent positional/flag values compose, conflicts fail with code 2 and guidance, and all focused/full/package proofs pass." +expected_result: "pm init accepts a discoverable id-prefix flag without breaking positional compatibility, rejects ambiguous dual inputs, and never stores tracker paths as item prefixes." +actual_result: "Both flag aliases persist normalized prefixes; equivalent dual inputs compose; conflicts, empty values, and path-like flagged values fail with structured code-2 guidance; all focused/full/package proofs pass." dependencies[2]{id,kind,created_at,author,source_kind}: pm-3mna,related,"2026-07-14T19:04:33.488Z",null,null pm-kl19,related,"2026-07-14T19:04:33.488Z",null,null -comments[4]{created_at,author,text}: +comments[5]{created_at,author,text}: "2026-07-14T19:25:01.765Z",codex-gpt5,"Implementation start 2026-07-14: bundled with pm-3mna because init prefix input is part of the same public workspace-customization contract. Scope includes conflict validation, help/contracts/completions/docs, CLI regression tests, and installed temp-workspace proof." "2026-07-14T19:41:01.717Z",codex-gpt5,"Progress evidence: init now accepts --id-prefix with --prefix alias, preserves positional compatibility, accepts normalized equivalent dual inputs, and rejects empty/conflicting values deterministically. Help/contracts/completions/docs and focused regression coverage are green." "2026-07-14T20:17:42.518Z",codex-root,"Linked-test diagnostic: the first acceptance run passed 102 init/completion tests but the contract snapshot check failed only because pm test correctly sandboxed PM_PATH with zero tracker items/extensions, while contracts:check intentionally snapshots the real project package surface. Direct pnpm contracts:check on the real tracker is current. Re-running the linked set with explicit tracker context records the intended package-aware acceptance evidence." "2026-07-14T20:19:50.622Z",codex-root,"Final evidence: canonical --id-prefix and --prefix alias work with flag-only, positional-only, normalized equivalent dual input, empty-input refusal, and deterministic conflict guidance. Bash/zsh/fish completions, runtime contracts, docs, direct registration forwarding, integration coverage, and packed installed-CLI temp-workspace proof pass. Linked acceptance rerun is green (102 init/completion tests plus current project contract snapshot). Full repository verification is 5,701 tests at literal 100/100/100/100 coverage." + "2026-07-14T20:44:53.144Z",codex-root,"PR #563 round-1 review: accepted Greptile's path-like flagged-prefix finding and added init_id_prefix_path_like refusal with actionable positional/global-path guidance plus unit/integration coverage. Rejected the separate alias-drop claim: Commander, repository integration tests, and Greptile T-Rex all prove --prefix ops persists ops-. All four inline bot comments were reacted to and answered in their real threads. Post-fix full suite remains 5,701 tests with 36,850/36,850 statements, 26,465/26,465 branches, 7,438/7,438 functions, and 35,965/35,965 lines." learnings[1]{created_at,author,text}: "2026-07-14T20:19:51.329Z",codex-root,"Commander stores the canonical property from the long flag declaration, so --prefix, --id-prefix <value> forwards through options.idPrefix; resolving positional and flag forms in one normalizer keeps compatibility and conflict behavior deterministic." files[7]{path,scope,note}: @@ -44,5 +45,5 @@ test_runs[2]{run_id,kind,status,started_at,finished_at,recorded_at,passed,failed docs[2]{path,scope,note}: docs/COMMANDS.md,project,documents canonical flag alias compatibility and conflict behavior docs/SDK.md,project,documents shared customization primitive ownership -close_reason: "Implemented GH-562 end to end with canonical --id-prefix, --prefix alias, conflict validation, completions, contracts, docs, and installed temp-workspace proof." +close_reason: "Implemented and review-hardened GH-562 with canonical --id-prefix, working --prefix alias, path-like value refusal, conflict validation, completions, contracts, docs, and installed proof." body: "" diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index eb697502c..a62b62718 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -418,6 +418,21 @@ function resolveInitPrefixInput( if (flaggedPrefix.trim().length === 0) { throw new PmCliError("--id-prefix must not be empty", EXIT_CODE.USAGE); } + if (isPathLikeInitTarget(flaggedPrefix)) { + throw new PmCliError( + `--id-prefix accepts an item ID prefix, not a tracker path: "${flaggedPrefix}".`, + EXIT_CODE.USAGE, + { + code: "init_id_prefix_path_like", + required: + "Pass the tracker path positionally or with the global --path option.", + examples: [ + `pm init ${flaggedPrefix}`, + `pm --path ${flaggedPrefix} init`, + ], + }, + ); + } if ( positionalPrefix !== undefined && normalizePrefix(positionalPrefix) !== normalizePrefix(flaggedPrefix) diff --git a/src/sdk/schema.ts b/src/sdk/schema.ts index d1aed04e3..b383ad0eb 100644 --- a/src/sdk/schema.ts +++ b/src/sdk/schema.ts @@ -573,12 +573,7 @@ export async function runSchemaAddType( global: GlobalOptions, ): Promise<SchemaAddTypeResult> { const pmRoot = resolvePmRoot(process.cwd(), global.path); - if (!(await pathExists(getSettingsPath(pmRoot)))) { - throw new PmCliError( - `Tracker is not initialized at ${pmRoot}. Run pm init first.`, - EXIT_CODE.NOT_FOUND, - ); - } + await ensureInitialized(pmRoot); let normalized; try { @@ -699,9 +694,7 @@ export async function runSchemaAddType( path: typesPath, definitions: upsert.file.definitions.length, }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), + warnings: finalizeWarnings(warnings), generated_at: nowIso(), }; } @@ -715,6 +708,13 @@ async function ensureInitialized(pmRoot: string): Promise<void> { } } +/** Deduplicates schema mutation warnings and returns their stable locale-aware order. */ +function finalizeWarnings(warnings: string[]): string[] { + return [...new Set(warnings)].sort((left, right) => + left.localeCompare(right), + ); +} + /** Counts items whose resolved type matches `typeName` (case-insensitive). Uses the lightest existing read path (listAllItemMetadataLight skips the heavy collections cache). All items are counted — not just open ones — so the advisory warning surfaces every item the removed definition would orphan; the count is non-blocking. The caller passes its already-loaded `settings` so we never re-read settings.json from disk here. */ async function countItemsUsingType( pmRoot: string, @@ -915,9 +915,7 @@ export async function runSchemaRemoveType( path: typesPath, definitions: removal.file.definitions.length, }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), + warnings: finalizeWarnings(warnings), generated_at: nowIso(), }; } @@ -1153,9 +1151,7 @@ export async function runSchemaAddStatus( path: statusesPath, statuses: upsert.file.statuses.length, }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), + warnings: finalizeWarnings(warnings), generated_at: nowIso(), }; } @@ -1247,9 +1243,7 @@ export async function runSchemaRemoveStatus( path: statusesPath, statuses: removal.file.statuses.length, }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), + warnings: finalizeWarnings(warnings), generated_at: nowIso(), }; } @@ -1374,12 +1368,7 @@ async function loadSchemaInspectionContext(global: GlobalOptions): Promise<{ schema: ReturnType<typeof normalizeRuntimeSchemaSettings>; }> { const pmRoot = resolvePmRoot(process.cwd(), global.path); - if (!(await pathExists(getSettingsPath(pmRoot)))) { - throw new PmCliError( - `Tracker is not initialized at ${pmRoot}. Run pm init first.`, - EXIT_CODE.NOT_FOUND, - ); - } + await ensureInitialized(pmRoot); const settings = await readSettings(pmRoot); const schema = normalizeRuntimeSchemaSettings(settings.schema); const typesPath = filePathForSchemaSection( @@ -1626,9 +1615,7 @@ export async function runSchemaAddField( path: fieldsPath, fields: upsert.file.fields.length, }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), + warnings: finalizeWarnings(warnings), generated_at: nowIso(), }; } @@ -1751,9 +1738,7 @@ export async function runSchemaRemoveField( path: fieldsPath, fields: removal.file.fields.length, }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), + warnings: finalizeWarnings(warnings), generated_at: nowIso(), }; } @@ -1901,9 +1886,7 @@ export async function runSchemaApplyPreset( path: typesPath, definitions: definitionsCount, }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), + warnings: finalizeWarnings(warnings), generated_at: nowIso(), }; } @@ -2062,9 +2045,7 @@ export async function runSchemaInferTypes( path: typesPath, definitions: definitionsCount, }, - warnings: [...new Set(warnings)].sort((left, right) => - left.localeCompare(right), - ), + warnings: finalizeWarnings(warnings), generated_at: nowIso(), }; } diff --git a/tests/integration/init-path-guard.integration.spec.ts b/tests/integration/init-path-guard.integration.spec.ts index efca8ecd0..5d04c44f1 100644 --- a/tests/integration/init-path-guard.integration.spec.ts +++ b/tests/integration/init-path-guard.integration.spec.ts @@ -85,6 +85,20 @@ describe("init tracker-path guardrails", () => { ]); expect(empty.code).toBe(2); expect(empty.stderr).toContain("--id-prefix must not be empty"); + + const pathLike = context.runCli([ + "init", + "--id-prefix", + "./nested-tracker", + "--json", + "--yes", + ]); + expect(pathLike.code).toBe(2); + expectJsonErrorEnvelope(pathLike.stderr, { + code: "init_id_prefix_path_like", + exit_code: 2, + }); + expect(pathLike.stderr).toContain("not a tracker path"); }); }); diff --git a/tests/unit/commands/init-command.spec.ts b/tests/unit/commands/init-command.spec.ts index 0f1d798ea..08a278cae 100644 --- a/tests/unit/commands/init-command.spec.ts +++ b/tests/unit/commands/init-command.spec.ts @@ -144,6 +144,13 @@ describe("runInit", () => { expect(() => initInternals.resolveInitPrefixInput(undefined, " ")).toThrow( "--id-prefix must not be empty", ); + expect(() => + initInternals.resolveInitPrefixInput(undefined, "./tracker"), + ).toThrow( + expect.objectContaining({ + context: expect.objectContaining({ code: "init_id_prefix_path_like" }), + }), + ); expect(() => initInternals.resolveInitPrefixInput("app", "ops")).toThrow( expect.objectContaining({ context: expect.objectContaining({ code: "init_id_prefix_conflict" }), From 4c2786c56f30375ce0a7332fcb12e345673caf8a Mon Sep 17 00:00:00 2001 From: Stefan Preu <stefan@preu.at> Date: Tue, 14 Jul 2026 23:04:05 +0200 Subject: [PATCH 3/3] fix(init): reject drive-relative prefix paths Treat Windows drive-relative values such as C:tracker as path-shaped init input, keep structured error guidance consistent across platforms, and add unit plus built-CLI integration coverage. Record the second PR review round and exact 100/100/100/100 repository coverage in pm-nmzx before closing and releasing the actively worked item. --- .agents/pm/history/pm-nmzx.jsonl | 6 ++++++ .agents/pm/issues/pm-nmzx.toon | 13 +++++++------ src/cli/commands/init.ts | 1 + .../integration/init-path-guard.integration.spec.ts | 13 +++++++++++++ tests/unit/commands/init-command.spec.ts | 8 ++++++++ 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.agents/pm/history/pm-nmzx.jsonl b/.agents/pm/history/pm-nmzx.jsonl index 21d4c80e1..1de8965ce 100644 --- a/.agents/pm/history/pm-nmzx.jsonl +++ b/.agents/pm/history/pm-nmzx.jsonl @@ -27,3 +27,9 @@ {"ts":"2026-07-14T20:44:53.145Z","author":"codex-root","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/4","value":{"created_at":"2026-07-14T20:44:53.144Z","author":"codex-root","text":"PR #563 round-1 review: accepted Greptile's path-like flagged-prefix finding and added init_id_prefix_path_like refusal with actionable positional/global-path guidance plus unit/integration coverage. Rejected the separate alias-drop claim: Commander, repository integration tests, and Greptile T-Rex all prove --prefix ops persists ops-. All four inline bot comments were reacted to and answered in their real threads. Post-fix full suite remains 5,701 tests with 36,850/36,850 statements, 26,465/26,465 branches, 7,438/7,438 functions, and 35,965/35,965 lines."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:44:53.145Z"}],"before_hash":"b8a5cd584dbeb8208106de4460a2a0f969b77405a7781b0e8946a54263cb827f","after_hash":"c045a4526f1068b9f44f2976ab13eaebbb1156e1c55bf2e41032f26660ee804c"} {"ts":"2026-07-14T20:44:54.830Z","author":"codex-root","op":"close","patch":[{"op":"replace","path":"/metadata/actual_result","value":"Both flag aliases persist normalized prefixes; equivalent dual inputs compose; conflicts, empty values, and path-like flagged values fail with structured code-2 guidance; all focused/full/package proofs pass."},{"op":"replace","path":"/metadata/expected_result","value":"pm init accepts a discoverable id-prefix flag without breaking positional compatibility, rejects ambiguous dual inputs, and never stores tracker paths as item prefixes."},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:44:54.830Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-14T20:44:54.820Z"},{"op":"add","path":"/metadata/close_reason","value":"Implemented and review-hardened GH-562 with canonical --id-prefix, working --prefix alias, path-like value refusal, conflict validation, completions, contracts, docs, and installed proof."}],"before_hash":"c045a4526f1068b9f44f2976ab13eaebbb1156e1c55bf2e41032f26660ee804c","after_hash":"19fe193dc625a78ee3418a523605cf881bd5e6274f89082e2aaba55d6bdab9ff"} {"ts":"2026-07-14T20:44:55.773Z","author":"codex-root","op":"release","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:44:55.773Z"}],"before_hash":"19fe193dc625a78ee3418a523605cf881bd5e6274f89082e2aaba55d6bdab9ff","after_hash":"d13015974d9ad0051d0d7fb26290c76cd8d38acf2199adcf0b6ff9c8c34dc8f9","message":"Release after PR review hardening"} +{"ts":"2026-07-14T20:57:02.685Z","author":"codex-root","op":"update","patch":[{"op":"remove","path":"/metadata/close_reason"},{"op":"remove","path":"/metadata/closed_at"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:57:02.685Z"},{"op":"replace","path":"/metadata/status","value":"open"}],"before_hash":"d13015974d9ad0051d0d7fb26290c76cd8d38acf2199adcf0b6ff9c8c34dc8f9","after_hash":"c2eaa97720a086f2c5da5537c2f536eb4f32a34a8fde4a95e438828e824ba386","message":"Reopen for Greptile round-2 Windows drive-relative prefix hardening"} +{"ts":"2026-07-14T20:57:03.438Z","author":"codex-root","op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:57:03.438Z"},{"op":"add","path":"/metadata/assignee","value":"codex-root"}],"before_hash":"c2eaa97720a086f2c5da5537c2f536eb4f32a34a8fde4a95e438828e824ba386","after_hash":"d3d657c73d8487e4759927b60d5118693d48ccf729d6f7fa8ce7c5748a2c3052"} +{"ts":"2026-07-14T20:57:04.312Z","author":"codex-root","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T20:57:04.312Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"d3d657c73d8487e4759927b60d5118693d48ccf729d6f7fa8ce7c5748a2c3052","after_hash":"34cb70ad1fe61c92ae09712efc888cf4732a1519cab3276601781c63bf2eceeb","message":"Reject drive-relative Windows path spellings in --id-prefix"} +{"ts":"2026-07-14T21:03:30.601Z","author":"codex-root","op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/5","value":{"created_at":"2026-07-14T21:03:30.601Z","author":"codex-root","text":"PR #563 round-2 review evidence: accepted Greptile's Windows drive-relative path finding and hardened init prefix validation so targets such as C:tracker are rejected with structured init_id_prefix_path_like guidance. Added direct predicate, resolver, and built-CLI integration coverage. Verification passed: 290 test files, 5,701 tests, statements 36,850/36,850, branches 26,466/26,466, functions 7,438/7,438, lines 35,965/35,965 (exact 100/100/100/100); pnpm typecheck; pnpm quality:static. Reacted to and replied on the finding thread; final resolution follows after the pushed-head review loop."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T21:03:30.601Z"}],"before_hash":"34cb70ad1fe61c92ae09712efc888cf4732a1519cab3276601781c63bf2eceeb","after_hash":"f9ad889bbb69f40a354f023a6bbe523d8e26cf50f4bff65f5b24653590748a51"} +{"ts":"2026-07-14T21:03:31.607Z","author":"codex-root","op":"close","patch":[{"op":"replace","path":"/metadata/actual_result","value":"Verified the release dependency update and delivered canonical --id-prefix with --prefix compatibility, structured conflicts/errors, docs/contracts/completions, external packed proof, and cross-platform path rejection at exact 100/100/100/100 coverage."},{"op":"replace","path":"/metadata/expected_result","value":"Dependabot release PR is verified and init exposes a stable canonical project-id prefix contract that rejects path-shaped values across supported platforms."},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T21:03:31.607Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-14T21:03:31.606Z"},{"op":"add","path":"/metadata/close_reason","value":"Completed GH-562 follow-up plus both PR #563 review rounds: dependency release verification, canonical init prefix contract, path-like prefix rejection including Windows drive-relative targets, focused and full exact-coverage verification, and review evidence."}],"before_hash":"f9ad889bbb69f40a354f023a6bbe523d8e26cf50f4bff65f5b24653590748a51","after_hash":"fae1ba684b27cf8a61f6324202775514c6f2ee34f317d6d43a23b66c9ca0a83f"} +{"ts":"2026-07-14T21:03:32.870Z","author":"codex-root","op":"release","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-14T21:03:32.870Z"}],"before_hash":"fae1ba684b27cf8a61f6324202775514c6f2ee34f317d6d43a23b66c9ca0a83f","after_hash":"c0abadeb93d569e6508f2b3b1a4bdc0026563617af02dd6f404f0e6f476745d8","message":"Released after round-2 review fix verification; no further local edits are active on this item."} diff --git a/.agents/pm/issues/pm-nmzx.toon b/.agents/pm/issues/pm-nmzx.toon index 234aa2e40..ba93a3ce2 100644 --- a/.agents/pm/issues/pm-nmzx.toon +++ b/.agents/pm/issues/pm-nmzx.toon @@ -6,8 +6,8 @@ status: closed priority: 2 tags[4]: agent-ux,cli,github-issue,init created_at: "2026-07-14T19:04:33.488Z" -updated_at: "2026-07-14T20:44:55.773Z" -closed_at: "2026-07-14T20:44:54.820Z" +updated_at: "2026-07-14T21:03:32.870Z" +closed_at: "2026-07-14T21:03:31.606Z" author: maintainer-agent estimated_minutes: 60 acceptance_criteria: "pm init accepts --id-prefix <value> as an alias for the positional prefix argument (positional form stays supported); conflicting positional + flag values fail fast with a clear message; help text, completion (bash+zsh+fish), contracts (cli-contracts.ts flag contracts + MCP schema), and docs updated per the new-flag wiring checklist; regression spec covers flag form, positional form, and the conflict case" @@ -15,17 +15,18 @@ parent: pm-mpbb risk: low confidence: high resolution: completed -expected_result: "pm init accepts a discoverable id-prefix flag without breaking positional compatibility, rejects ambiguous dual inputs, and never stores tracker paths as item prefixes." -actual_result: "Both flag aliases persist normalized prefixes; equivalent dual inputs compose; conflicts, empty values, and path-like flagged values fail with structured code-2 guidance; all focused/full/package proofs pass." +expected_result: Dependabot release PR is verified and init exposes a stable canonical project-id prefix contract that rejects path-shaped values across supported platforms. +actual_result: "Verified the release dependency update and delivered canonical --id-prefix with --prefix compatibility, structured conflicts/errors, docs/contracts/completions, external packed proof, and cross-platform path rejection at exact 100/100/100/100 coverage." dependencies[2]{id,kind,created_at,author,source_kind}: pm-3mna,related,"2026-07-14T19:04:33.488Z",null,null pm-kl19,related,"2026-07-14T19:04:33.488Z",null,null -comments[5]{created_at,author,text}: +comments[6]{created_at,author,text}: "2026-07-14T19:25:01.765Z",codex-gpt5,"Implementation start 2026-07-14: bundled with pm-3mna because init prefix input is part of the same public workspace-customization contract. Scope includes conflict validation, help/contracts/completions/docs, CLI regression tests, and installed temp-workspace proof." "2026-07-14T19:41:01.717Z",codex-gpt5,"Progress evidence: init now accepts --id-prefix with --prefix alias, preserves positional compatibility, accepts normalized equivalent dual inputs, and rejects empty/conflicting values deterministically. Help/contracts/completions/docs and focused regression coverage are green." "2026-07-14T20:17:42.518Z",codex-root,"Linked-test diagnostic: the first acceptance run passed 102 init/completion tests but the contract snapshot check failed only because pm test correctly sandboxed PM_PATH with zero tracker items/extensions, while contracts:check intentionally snapshots the real project package surface. Direct pnpm contracts:check on the real tracker is current. Re-running the linked set with explicit tracker context records the intended package-aware acceptance evidence." "2026-07-14T20:19:50.622Z",codex-root,"Final evidence: canonical --id-prefix and --prefix alias work with flag-only, positional-only, normalized equivalent dual input, empty-input refusal, and deterministic conflict guidance. Bash/zsh/fish completions, runtime contracts, docs, direct registration forwarding, integration coverage, and packed installed-CLI temp-workspace proof pass. Linked acceptance rerun is green (102 init/completion tests plus current project contract snapshot). Full repository verification is 5,701 tests at literal 100/100/100/100 coverage." "2026-07-14T20:44:53.144Z",codex-root,"PR #563 round-1 review: accepted Greptile's path-like flagged-prefix finding and added init_id_prefix_path_like refusal with actionable positional/global-path guidance plus unit/integration coverage. Rejected the separate alias-drop claim: Commander, repository integration tests, and Greptile T-Rex all prove --prefix ops persists ops-. All four inline bot comments were reacted to and answered in their real threads. Post-fix full suite remains 5,701 tests with 36,850/36,850 statements, 26,465/26,465 branches, 7,438/7,438 functions, and 35,965/35,965 lines." + "2026-07-14T21:03:30.601Z",codex-root,"PR #563 round-2 review evidence: accepted Greptile's Windows drive-relative path finding and hardened init prefix validation so targets such as C:tracker are rejected with structured init_id_prefix_path_like guidance. Added direct predicate, resolver, and built-CLI integration coverage. Verification passed: 290 test files, 5,701 tests, statements 36,850/36,850, branches 26,466/26,466, functions 7,438/7,438, lines 35,965/35,965 (exact 100/100/100/100); pnpm typecheck; pnpm quality:static. Reacted to and replied on the finding thread; final resolution follows after the pushed-head review loop." learnings[1]{created_at,author,text}: "2026-07-14T20:19:51.329Z",codex-root,"Commander stores the canonical property from the long flag declaration, so --prefix, --id-prefix <value> forwards through options.idPrefix; resolving positional and flag forms in one normalizer keeps compatibility and conflict behavior deterministic." files[7]{path,scope,note}: @@ -45,5 +46,5 @@ test_runs[2]{run_id,kind,status,started_at,finished_at,recorded_at,passed,failed docs[2]{path,scope,note}: docs/COMMANDS.md,project,documents canonical flag alias compatibility and conflict behavior docs/SDK.md,project,documents shared customization primitive ownership -close_reason: "Implemented and review-hardened GH-562 with canonical --id-prefix, working --prefix alias, path-like value refusal, conflict validation, completions, contracts, docs, and installed proof." +close_reason: "Completed GH-562 follow-up plus both PR #563 review rounds: dependency release verification, canonical init prefix contract, path-like prefix rejection including Windows drive-relative targets, focused and full exact-coverage verification, and review evidence." body: "" diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index a62b62718..226d89cd4 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -345,6 +345,7 @@ function isPathLikeInitTarget(rawValue: string | undefined): boolean { } return ( path.isAbsolute(trimmed) || + /^[a-z]:/iu.test(trimmed) || trimmed.startsWith(".") || trimmed.includes("/") || trimmed.includes("\\") diff --git a/tests/integration/init-path-guard.integration.spec.ts b/tests/integration/init-path-guard.integration.spec.ts index 5d04c44f1..53462c5fc 100644 --- a/tests/integration/init-path-guard.integration.spec.ts +++ b/tests/integration/init-path-guard.integration.spec.ts @@ -99,6 +99,19 @@ describe("init tracker-path guardrails", () => { exit_code: 2, }); expect(pathLike.stderr).toContain("not a tracker path"); + + const driveRelative = context.runCli([ + "init", + "--id-prefix", + "C:tracker", + "--json", + "--yes", + ]); + expect(driveRelative.code).toBe(2); + expectJsonErrorEnvelope(driveRelative.stderr, { + code: "init_id_prefix_path_like", + exit_code: 2, + }); }); }); diff --git a/tests/unit/commands/init-command.spec.ts b/tests/unit/commands/init-command.spec.ts index 08a278cae..97c16d7ca 100644 --- a/tests/unit/commands/init-command.spec.ts +++ b/tests/unit/commands/init-command.spec.ts @@ -95,6 +95,7 @@ describe("runInit", () => { expect(initInternals.isPathLikeInitTarget("acme")).toBe(false); expect(initInternals.isPathLikeInitTarget("./sandbox")).toBe(true); expect(initInternals.isPathLikeInitTarget("/tmp/pm-test")).toBe(true); + expect(initInternals.isPathLikeInitTarget("C:tracker")).toBe(true); expect( initInternals.resolveInitInvocation("/repo", {}, "./sandbox"), ).toEqual({ @@ -151,6 +152,13 @@ describe("runInit", () => { context: expect.objectContaining({ code: "init_id_prefix_path_like" }), }), ); + expect(() => + initInternals.resolveInitPrefixInput(undefined, "C:tracker"), + ).toThrow( + expect.objectContaining({ + context: expect.objectContaining({ code: "init_id_prefix_path_like" }), + }), + ); expect(() => initInternals.resolveInitPrefixInput("app", "ops")).toThrow( expect.objectContaining({ context: expect.objectContaining({ code: "init_id_prefix_conflict" }),