From 9f8f3045bebf3828f612097f555c5162ce7e987e Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:24:08 +0200 Subject: [PATCH 1/6] Scope preflight override to owned commands registerPreflight used the bare-function form, which the pm runtime treats as a global override. Two globally-scoped overrides genuinely contend: pm health reports extension_preflight_override_collision for every installed pair, so all six fleet packages installed together produce 15 warnings and ok: false. Rewrite the registration to the scoped object form, declaring exactly the mutating linear command paths commandMutatesLinear recognizes. The runtime matches a command against the commands array by exact normalized path, so the array lists the full command paths (linear sync, linear import, linear export) rather than a bare top-level name: a bare 'linear' never matches 'linear sync' and would silently disable the credential gate. The authoritative gate (sentinel option consumed by the handlers) stays in the override body; this only narrows its scope so it no longer contends with other packages' overrides. Verified: with two of these packages installed, pm health reported ok: false with one collision warning; after scoping both, ok: true with zero warnings and the guarded command still runs. --- .agents/pm/history/pm-linear-hwol.jsonl | 6 ++++ .agents/pm/issues/pm-linear-hwol.toon | 19 +++++++++++++ CHANGELOG.md | 6 ++++ index.ts | 37 +++++++++++++++---------- test/smoke.test.ts | 20 +++++++++++++ 5 files changed, 73 insertions(+), 15 deletions(-) create mode 100644 .agents/pm/history/pm-linear-hwol.jsonl create mode 100644 .agents/pm/issues/pm-linear-hwol.toon diff --git a/.agents/pm/history/pm-linear-hwol.jsonl b/.agents/pm/history/pm-linear-hwol.jsonl new file mode 100644 index 0000000..b6ca32a --- /dev/null +++ b/.agents/pm/history/pm-linear-hwol.jsonl @@ -0,0 +1,6 @@ +{"ts":"2026-08-14T06:22:39.772Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"56b7d1a35febb62ac00aa45f","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-linear-hwol"},{"op":"add","path":"/metadata/title","value":"Scope preflight override to pm-linear's owned commands"},{"op":"add","path":"/metadata/description","value":"registerPreflight used the bare-function (global) form, which collides pairwise with every other installed package's preflight override (pm health reports extension_preflight_override_collision). Rewrite it to the scoped object form declaring exactly the mutating linear command paths commandMutatesLinear recognizes (linear sync, linear import, linear export). The runtime matches a command against the commands array by exact normalized path, so the array lists full command paths, not bare top-level names (which would never match linear sync and silently disable the credential gate). The authoritative gate (sentinel option consumed by the handlers) stays in the override body; this only narrows its scope."},{"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":[]},{"op":"add","path":"/metadata/created_at","value":"2026-08-14T06:22:39.772Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-14T06:22:39.772Z"},{"op":"add","path":"/metadata/author","value":"pi-agent"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"5b0717ff6cb5f6cfa0f443b1c9411c8a81d637244cb301239bdda09056a19162","message":""} +{"ts":"2026-08-14T06:23:14.421Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"56b7d1a35febb62ac00aa45f","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:23:14.421Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"5b0717ff6cb5f6cfa0f443b1c9411c8a81d637244cb301239bdda09056a19162","after_hash":"abf295446bd104d18498007656ffed2722b9463619b8a9712483492adacf1d7d"} +{"ts":"2026-08-14T06:23:15.433Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"56b7d1a35febb62ac00aa45f","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:23:15.433Z"},{"op":"add","path":"/metadata/files","value":[{"path":"index.ts","scope":"project"}]}],"before_hash":"abf295446bd104d18498007656ffed2722b9463619b8a9712483492adacf1d7d","after_hash":"6b188e3dfeedfdd33ea208d8813563700cd9fc21293929cbc1d7bcd9105fb84a"} +{"ts":"2026-08-14T06:23:16.420Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"56b7d1a35febb62ac00aa45f","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"files_add","patch":[{"op":"add","path":"/metadata/files/1","value":{"path":"test/smoke.test.ts","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:23:16.420Z"}],"before_hash":"6b188e3dfeedfdd33ea208d8813563700cd9fc21293929cbc1d7bcd9105fb84a","after_hash":"f7adf74c74b1582d1ead3d64bd637f1fd6127c3dabb985e9f731184aa94a5ca5"} +{"ts":"2026-08-14T06:23:17.611Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"56b7d1a35febb62ac00aa45f","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:23:17.611Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"npm test","path":"test/smoke.test.ts","scope":"project","note":"Asserts registerPreflight is a scoped object whose commands array equals pm-linear's owned mutating command paths (linear sync, linear import, linear export) and run is a function. Test title: preflight override is scoped to pm-linear's owned command paths"}]}],"before_hash":"f7adf74c74b1582d1ead3d64bd637f1fd6127c3dabb985e9f731184aa94a5ca5","after_hash":"41dd2a24ea75f7462e2840697db221e85e588aa9b7f7407efb4432ac95587391"} +{"ts":"2026-08-14T06:23:38.379Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"56b7d1a35febb62ac00aa45f","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:23:38.379Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-14T06:23:38.357Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-14T06:23:38.357Z"},{"op":"add","path":"/metadata/close_reason","value":"completed"}],"before_hash":"41dd2a24ea75f7462e2840697db221e85e588aa9b7f7407efb4432ac95587391","after_hash":"4508c73055a970f76adadcce86d7708aede372dfe06914f2cdfc80c99dea1d05"} diff --git a/.agents/pm/issues/pm-linear-hwol.toon b/.agents/pm/issues/pm-linear-hwol.toon new file mode 100644 index 0000000..c3dcc72 --- /dev/null +++ b/.agents/pm/issues/pm-linear-hwol.toon @@ -0,0 +1,19 @@ +id: pm-linear-hwol +title: Scope preflight override to pm-linear's owned commands +description: "registerPreflight used the bare-function (global) form, which collides pairwise with every other installed package's preflight override (pm health reports extension_preflight_override_collision). Rewrite it to the scoped object form declaring exactly the mutating linear command paths commandMutatesLinear recognizes (linear sync, linear import, linear export). The runtime matches a command against the commands array by exact normalized path, so the array lists full command paths, not bare top-level names (which would never match linear sync and silently disable the credential gate). The authoritative gate (sentinel option consumed by the handlers) stays in the override body; this only narrows its scope." +type: Issue +status: closed +priority: 2 +tags: [] +created_at: "2026-08-14T06:22:39.772Z" +updated_at: "2026-08-14T06:23:38.379Z" +closed_at: "2026-08-14T06:23:38.357Z" +completed_at: "2026-08-14T06:23:38.357Z" +author: pi-agent +files[2]{path,scope}: + index.ts,project + test/smoke.test.ts,project +tests[1]{command,path,scope,note}: + npm test,test/smoke.test.ts,project,"Asserts registerPreflight is a scoped object whose commands array equals pm-linear's owned mutating command paths (linear sync, linear import, linear export) and run is a function. Test title: preflight override is scoped to pm-linear's owned command paths" +close_reason: completed +body: "" diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e20c1..bacee33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- Scope preflight override to pm-linear's owned commands ([pm-linear-hwol](https://github.com/unbraind/pm-linear/blob/main/.agents/pm/issues/pm-linear-hwol.toon)) + ## 2026.8.10 - 2026-08-10 ### Fixed diff --git a/index.ts b/index.ts index cfc9e67..d639dfd 100644 --- a/index.ts +++ b/index.ts @@ -2534,22 +2534,29 @@ export default defineExtension({ activate(api: ExtensionApi) { // ----------------------------------------------------------------------- // preflight — validate credentials + reachability before any mutating - // Linear command runs. On failure it injects a sentinel option (it cannot - // abort by throwing) that the handlers convert into a clean USAGE error. + // Linear command runs, scoped to the command paths pm-linear owns so it + // cannot contend with another package's preflight override (an unscoped / + // global override collides pairwise with every other installed package's + // override; pm health reports extension_preflight_override_collision). On + // failure it injects a sentinel option (it cannot abort by throwing) that + // the handlers convert into a clean USAGE error. // ----------------------------------------------------------------------- - api.registerPreflight(async (ctx: PreflightOverrideContext) => { - if (!commandMutatesLinear(ctx.command, ctx.options)) return {}; - // Reachability uses the network; allow opting out (CI/offline/tests). - // pm strips a leading `--no-` as boolean negation, so the user-facing flag - // is `--skip-preflight-network` (the legacy `no-preflight-network` key is - // still honored for back-compat with any existing scripts/config). - const checkReachability = - !readBooleanOption(ctx.options, "skip-preflight-network") && - !readBooleanOption(ctx.options, "no-preflight-network") && - process.env["LINEAR_PREFLIGHT_NO_NETWORK"] !== "1"; - const error = await preflightLinear(checkReachability); - if (!error) return {}; - return { options: { ...ctx.options, [PREFLIGHT_ERROR_OPTION]: error } }; + api.registerPreflight({ + commands: ["linear sync", "linear import", "linear export"], + run: async (ctx: PreflightOverrideContext) => { + if (!commandMutatesLinear(ctx.command, ctx.options)) return {}; + // Reachability uses the network; allow opting out (CI/offline/tests). + // pm strips a leading `--no-` as boolean negation, so the user-facing flag + // is `--skip-preflight-network` (the legacy `no-preflight-network` key is + // still honored for back-compat with any existing scripts/config). + const checkReachability = + !readBooleanOption(ctx.options, "skip-preflight-network") && + !readBooleanOption(ctx.options, "no-preflight-network") && + process.env["LINEAR_PREFLIGHT_NO_NETWORK"] !== "1"; + const error = await preflightLinear(checkReachability); + if (!error) return {}; + return { options: { ...ctx.options, [PREFLIGHT_ERROR_OPTION]: error } }; + }, }); // ----------------------------------------------------------------------- diff --git a/test/smoke.test.ts b/test/smoke.test.ts index 53031ac..a352ce0 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -75,6 +75,26 @@ test("extension activates cleanly and registers expected capabilities", async () linearHarness.assertFlags({ targetCommand: "linear export", flags: ["--push", "--dry-run"] }); }); +test("preflight override is scoped to pm-linear's owned command paths", async () => { + // The override MUST register as a scoped object (commands + run), not a bare + // function: a global (unscoped) override collides pairwise with every other + // installed package's preflight override (pm health reports + // extension_preflight_override_collision). The runtime matches a command + // against `commands` by exact normalized path, so the array lists the full + // mutating command paths commandMutatesLinear recognizes. + const override = linearHarness.assertPreflightOverride(); + assert.deepEqual( + override.commands, + ["linear sync", "linear import", "linear export"], + "preflight override must be scoped to exactly pm-linear's owned mutating command paths", + ); + assert.equal( + typeof override.run, + "function", + "scoped preflight override must expose a run function", + ); +}); + test("parseStatusMap preserves original key casing and ignores junk", () => { assert.deepEqual(parseStatusMap(undefined), {}); assert.deepEqual(parseStatusMap(""), {}); From 4ec5f22a1315ec66dcb55f0dd2099856387371b0 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:44:22 +0200 Subject: [PATCH 2/6] Keep the deprecated linear-sync alias inside the credential gate CodeRabbit raised this as a Major finding on the scoping PR and it is correct. Scoping registerPreflight to a command list narrows which commands the override runs for. The scope listed the three native paths, but registerImporter also exposes the deprecated linear-sync alias, which reaches the same write path -- the previously global registration had covered it by accident. Under the scoped form the alias ran with no credential validation, and its handler never called assertPreflightOk either, so a mutating legacy import could bypass the gate. Three changes, because one is not enough: the alias is now in the scope list, commandMutatesLinear classifies it as mutating on the same dry-run terms as linear import, and the importer handler asserts the preflight sentinel like every other mutating handler does. assertPreflightOk is a no-op unless the preflight set a sentinel, and the preflight only sets one for a mutating invocation, so an offline --dry-run import is unaffected. A loop test binds the scope list to the classifier: every command in the scope must be classified as mutating, so a future entry added to one and not the other fails rather than silently losing its gate. commandMutatesLinear is exported for that test, matching how pm-jira already exports isMutatingJiraInvocation. Tracked as pm-linear-ydo4. --- .agents/pm/history/pm-linear-ydo4.jsonl | 5 +++++ .agents/pm/issues/pm-linear-ydo4.toon | 20 ++++++++++++++++++++ CHANGELOG.md | 4 ++++ index.ts | 16 +++++++++++++--- test/smoke.test.ts | 15 ++++++++++++++- 5 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 .agents/pm/history/pm-linear-ydo4.jsonl create mode 100644 .agents/pm/issues/pm-linear-ydo4.toon diff --git a/.agents/pm/history/pm-linear-ydo4.jsonl b/.agents/pm/history/pm-linear-ydo4.jsonl new file mode 100644 index 0000000..db6e6ac --- /dev/null +++ b/.agents/pm/history/pm-linear-ydo4.jsonl @@ -0,0 +1,5 @@ +{"ts":"2026-08-14T06:44:19.151Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"70140e5dd5b320efbbb8d5d3","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-linear-ydo4"},{"op":"add","path":"/metadata/title","value":"The deprecated linear-sync importer alias loses its credential gate when the preflight override is scoped"},{"op":"add","path":"/metadata/description","value":"Scoping registerPreflight to a command list narrows which commands the override runs for. The scope listed linear sync, linear import and linear export, but registerImporter also exposes the deprecated linear-sync alias, which reaches the same write path. The previously global registration covered it by accident. Under the scoped form the alias ran with no credential validation, and its handler did not call assertPreflightOk either, so a mutating legacy import could bypass the gate entirely. The alias is now in the scope list, classified as mutating by commandMutatesLinear on the same dry-run terms as linear import, and its handler asserts the preflight sentinel like every other mutating handler. A test binds the scope list to the classifier so the two cannot drift apart again."},{"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":[]},{"op":"add","path":"/metadata/created_at","value":"2026-08-14T06:44:19.151Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-14T06:44:19.151Z"},{"op":"add","path":"/metadata/author","value":"claude"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"925fc1b5e6ccd4cf36a54132c2d5d480ff4486be567172cf37fc6c428d971c2e","item_hash_version":2,"message":"","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-14T06:44:19.702Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"70140e5dd5b320efbbb8d5d3","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:44:19.702Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"925fc1b5e6ccd4cf36a54132c2d5d480ff4486be567172cf37fc6c428d971c2e","after_hash":"2713fbac94a22c05ea72ca7264f7335a319ae1fd0cc1271b6d0783b93ceefdec","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-14T06:44:20.339Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"70140e5dd5b320efbbb8d5d3","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:44:20.339Z"},{"op":"add","path":"/metadata/files","value":[{"path":"index.ts","scope":"project"}]}],"before_hash":"2713fbac94a22c05ea72ca7264f7335a319ae1fd0cc1271b6d0783b93ceefdec","after_hash":"864d030f69da6bf531cb6461c5b05419a6dd221125b94f84d4207bde58e5ed78","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-14T06:44:20.853Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"70140e5dd5b320efbbb8d5d3","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"add","path":"/metadata/files/1","value":{"path":"test/smoke.test.ts","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:44:20.853Z"}],"before_hash":"864d030f69da6bf531cb6461c5b05419a6dd221125b94f84d4207bde58e5ed78","after_hash":"e754797f530ba2ea3f220b794d5fc96c9f06c8148040d1560b0d409d0ce54bb0","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-14T06:44:21.393Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"70140e5dd5b320efbbb8d5d3","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:44:21.393Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-14T06:44:21.377Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-14T06:44:21.377Z"},{"op":"add","path":"/metadata/resolution","value":"linear-sync import added to the preflight scope and to commandMutatesLinear, and the legacy importer handler now calls assertPreflightOk."},{"op":"add","path":"/metadata/expected_result","value":"A mutating legacy linear-sync import refuses without credentials, exactly as linear import does."},{"op":"add","path":"/metadata/actual_result","value":"Scope, classifier and handler all cover the alias; a loop test asserts every scoped command is classified mutating so the lists cannot drift."},{"op":"add","path":"/metadata/close_reason","value":"completed"}],"before_hash":"e754797f530ba2ea3f220b794d5fc96c9f06c8148040d1560b0d409d0ce54bb0","after_hash":"6a489b99336c852ee06fd397bb030f2da87834470a11330ef81b251caa70a967","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} diff --git a/.agents/pm/issues/pm-linear-ydo4.toon b/.agents/pm/issues/pm-linear-ydo4.toon new file mode 100644 index 0000000..b526a4a --- /dev/null +++ b/.agents/pm/issues/pm-linear-ydo4.toon @@ -0,0 +1,20 @@ +id: pm-linear-ydo4 +title: The deprecated linear-sync importer alias loses its credential gate when the preflight override is scoped +description: "Scoping registerPreflight to a command list narrows which commands the override runs for. The scope listed linear sync, linear import and linear export, but registerImporter also exposes the deprecated linear-sync alias, which reaches the same write path. The previously global registration covered it by accident. Under the scoped form the alias ran with no credential validation, and its handler did not call assertPreflightOk either, so a mutating legacy import could bypass the gate entirely. The alias is now in the scope list, classified as mutating by commandMutatesLinear on the same dry-run terms as linear import, and its handler asserts the preflight sentinel like every other mutating handler. A test binds the scope list to the classifier so the two cannot drift apart again." +type: Issue +status: closed +priority: 2 +tags: [] +created_at: "2026-08-14T06:44:19.151Z" +updated_at: "2026-08-14T06:44:21.393Z" +closed_at: "2026-08-14T06:44:21.377Z" +completed_at: "2026-08-14T06:44:21.377Z" +author: claude +resolution: "linear-sync import added to the preflight scope and to commandMutatesLinear, and the legacy importer handler now calls assertPreflightOk." +expected_result: "A mutating legacy linear-sync import refuses without credentials, exactly as linear import does." +actual_result: "Scope, classifier and handler all cover the alias; a loop test asserts every scoped command is classified mutating so the lists cannot drift." +files[2]{path,scope}: + index.ts,project + test/smoke.test.ts,project +close_reason: completed +body: "" diff --git a/CHANGELOG.md b/CHANGELOG.md index bacee33..7121674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Scope preflight override to pm-linear's owned commands ([pm-linear-hwol](https://github.com/unbraind/pm-linear/blob/main/.agents/pm/issues/pm-linear-hwol.toon)) +### Deprecated + +- The deprecated linear-sync importer alias loses its credential gate when the preflight override is scoped ([pm-linear-ydo4](https://github.com/unbraind/pm-linear/blob/main/.agents/pm/issues/pm-linear-ydo4.toon)) + ## 2026.8.10 - 2026-08-10 ### Fixed diff --git a/index.ts b/index.ts index d639dfd..e536c2f 100644 --- a/index.ts +++ b/index.ts @@ -2329,9 +2329,14 @@ const PREFLIGHT_ERROR_OPTION = "__linear_preflight_error"; * @param options - The raw flag bag, inspected for `--dry-run` and `--push`. * @returns True when the command needs a valid Linear API key. */ -function commandMutatesLinear(command: string, options: Record): boolean { +export function commandMutatesLinear(command: string, options: Record): boolean { const cmd = command.trim().toLowerCase(); - if (cmd === "linear sync" || cmd === "linear import") { + // `linear-sync import` is the deprecated importer alias. It reaches the same + // write path as `linear import`, so it has to be classified as mutating here + // too -- scoping the preflight override to a command list made this omission + // load-bearing, where the previously global registration had covered it by + // accident. + if (cmd === "linear sync" || cmd === "linear import" || cmd === "linear-sync import") { return !readBooleanOption(options, "dry-run"); } if (cmd === "linear export") { @@ -2542,7 +2547,7 @@ export default defineExtension({ // the handlers convert into a clean USAGE error. // ----------------------------------------------------------------------- api.registerPreflight({ - commands: ["linear sync", "linear import", "linear export"], + commands: ["linear sync", "linear import", "linear export", "linear-sync import"], run: async (ctx: PreflightOverrideContext) => { if (!commandMutatesLinear(ctx.command, ctx.options)) return {}; // Reachability uses the network; allow opting out (CI/offline/tests). @@ -3047,6 +3052,11 @@ export default defineExtension({ // Importer: linear-sync // ----------------------------------------------------------------------- api.registerImporter("linear-sync", async (ctx) => { + // The deprecated alias reaches the same write path as `linear import`, + // so it must clear the same credential gate. The runtime swallows throws + // from a preflight override, which is why every mutating handler asserts + // here rather than relying on the registration alone. + assertPreflightOk(ctx.options); const teamSelection = resolveTeamSelection(ctx.options); if (!teamSelection) { throw new CommandError( diff --git a/test/smoke.test.ts b/test/smoke.test.ts index a352ce0..ede406a 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { createExtensionTestHarness, type ExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing"; import extension, { + commandMutatesLinear, parseStatusMap, resolveStatus, buildProvenance, @@ -85,7 +86,7 @@ test("preflight override is scoped to pm-linear's owned command paths", async () const override = linearHarness.assertPreflightOverride(); assert.deepEqual( override.commands, - ["linear sync", "linear import", "linear export"], + ["linear sync", "linear import", "linear export", "linear-sync import"], "preflight override must be scoped to exactly pm-linear's owned mutating command paths", ); assert.equal( @@ -93,6 +94,18 @@ test("preflight override is scoped to pm-linear's owned command paths", async () "function", "scoped preflight override must expose a run function", ); + // Bind the scope to the classifier. Narrowing the override to a command list + // means an entry missing here is a command that silently loses its credential + // gate, so every path the classifier calls mutating must also be in scope. + // `linear export` is excluded because it only mutates with --push. + for (const command of override.commands ?? []) { + if (command === "linear export") continue; + assert.strictEqual( + commandMutatesLinear(command, {}), + true, + `${command} is in the preflight scope but the classifier does not treat it as mutating`, + ); + } }); test("parseStatusMap preserves original key casing and ignores junk", () => { From 6ee98385731115af1b2a97764915227602956dca Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:02:18 +0200 Subject: [PATCH 3/6] Retitle the preflight item to name the defect rather than the remedy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit's review of #67 was right: the item title read as the fix ("Scope preflight override to pm-linear's owned commands") rather than the condition being fixed. The changelog is generated from item titles, so the Fixed section described an action instead of the failure a reader is trying to match against their own symptom. The title now states the defect — a globally-scoped preflight override collides with every other installed package, so pm health can never be green — and the remedy stays where it belongs, in the description and the close reason. CHANGELOG.md regenerated with changelog:full so the check mode matches the generation mode. --- .agents/pm/history/pm-linear-hwol.jsonl | 1 + .agents/pm/issues/pm-linear-hwol.toon | 4 ++-- CHANGELOG.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.agents/pm/history/pm-linear-hwol.jsonl b/.agents/pm/history/pm-linear-hwol.jsonl index b6ca32a..c3bd67d 100644 --- a/.agents/pm/history/pm-linear-hwol.jsonl +++ b/.agents/pm/history/pm-linear-hwol.jsonl @@ -4,3 +4,4 @@ {"ts":"2026-08-14T06:23:16.420Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"56b7d1a35febb62ac00aa45f","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"files_add","patch":[{"op":"add","path":"/metadata/files/1","value":{"path":"test/smoke.test.ts","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:23:16.420Z"}],"before_hash":"6b188e3dfeedfdd33ea208d8813563700cd9fc21293929cbc1d7bcd9105fb84a","after_hash":"f7adf74c74b1582d1ead3d64bd637f1fd6127c3dabb985e9f731184aa94a5ca5"} {"ts":"2026-08-14T06:23:17.611Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"56b7d1a35febb62ac00aa45f","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:23:17.611Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"npm test","path":"test/smoke.test.ts","scope":"project","note":"Asserts registerPreflight is a scoped object whose commands array equals pm-linear's owned mutating command paths (linear sync, linear import, linear export) and run is a function. Test title: preflight override is scoped to pm-linear's owned command paths"}]}],"before_hash":"f7adf74c74b1582d1ead3d64bd637f1fd6127c3dabb985e9f731184aa94a5ca5","after_hash":"41dd2a24ea75f7462e2840697db221e85e588aa9b7f7407efb4432ac95587391"} {"ts":"2026-08-14T06:23:38.379Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"56b7d1a35febb62ac00aa45f","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:23:38.379Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-14T06:23:38.357Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-14T06:23:38.357Z"},{"op":"add","path":"/metadata/close_reason","value":"completed"}],"before_hash":"41dd2a24ea75f7462e2840697db221e85e588aa9b7f7407efb4432ac95587391","after_hash":"4508c73055a970f76adadcce86d7708aede372dfe06914f2cdfc80c99dea1d05"} +{"ts":"2026-08-15T10:01:57.163Z","author":"pi-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2a535af1e474c92d3fda7e13","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T10:01:57.163Z"},{"op":"replace","path":"/metadata/title","value":"A globally-scoped preflight override collides with every other installed package, so pm health can never be green"}],"before_hash":"4508c73055a970f76adadcce86d7708aede372dfe06914f2cdfc80c99dea1d05","after_hash":"3ed3df34b1bf155f6618a488c1548e6dfaf78534ea6b75c674e369ce4e65c18d"} diff --git a/.agents/pm/issues/pm-linear-hwol.toon b/.agents/pm/issues/pm-linear-hwol.toon index c3dcc72..5de51a9 100644 --- a/.agents/pm/issues/pm-linear-hwol.toon +++ b/.agents/pm/issues/pm-linear-hwol.toon @@ -1,12 +1,12 @@ id: pm-linear-hwol -title: Scope preflight override to pm-linear's owned commands +title: "A globally-scoped preflight override collides with every other installed package, so pm health can never be green" description: "registerPreflight used the bare-function (global) form, which collides pairwise with every other installed package's preflight override (pm health reports extension_preflight_override_collision). Rewrite it to the scoped object form declaring exactly the mutating linear command paths commandMutatesLinear recognizes (linear sync, linear import, linear export). The runtime matches a command against the commands array by exact normalized path, so the array lists full command paths, not bare top-level names (which would never match linear sync and silently disable the credential gate). The authoritative gate (sentinel option consumed by the handlers) stays in the override body; this only narrows its scope." type: Issue status: closed priority: 2 tags: [] created_at: "2026-08-14T06:22:39.772Z" -updated_at: "2026-08-14T06:23:38.379Z" +updated_at: "2026-08-15T10:01:57.163Z" closed_at: "2026-08-14T06:23:38.357Z" completed_at: "2026-08-14T06:23:38.357Z" author: pi-agent diff --git a/CHANGELOG.md b/CHANGELOG.md index 7121674..8213367 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Scope preflight override to pm-linear's owned commands ([pm-linear-hwol](https://github.com/unbraind/pm-linear/blob/main/.agents/pm/issues/pm-linear-hwol.toon)) +- A globally-scoped preflight override collides with every other installed package, so pm health can never be green ([pm-linear-hwol](https://github.com/unbraind/pm-linear/blob/main/.agents/pm/issues/pm-linear-hwol.toon)) ### Deprecated From 339e887d3af9d0c0bf829bd94b2503ff06274cae Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:15:12 +0200 Subject: [PATCH 4/6] Gate the Linear preflight on network access, not on mutation Review round 2 on PR #67 (CodeRabbit). The preflight guards LINEAR_API_KEY plus a reachability probe, but the predicate deciding whether to run it asked whether the command *writes*. That is the wrong question, and it was wrong at both edges in opposite directions: - linear export --push --dry-run writes nothing and is documented as making no network call, yet the mutation test saw --push and demanded an API key. The offline preview failed on any host without one. - linear sync|import --atomic --dry-run also writes nothing, but it is NOT offline: it fetches issues to build the atomic plan. The mutation test saw --dry-run and skipped the preflight, so a missing or invalid key surfaced as a raw fetch failure deep in the sync core instead of the clean USAGE error the preflight exists to produce. Renamed commandMutatesLinear to commandNeedsLinearAccess and rewrote it to answer the question the preflight actually asks. CodeRabbit reported the first edge; the second came out of checking whether the same reasoning error had a mirror image, which it did. The scope-to-classifier binding is now bidirectional. The previous loop walked override.commands only, so it could not detect the drift that matters most -- a command the classifier requires credentials for while the scope omits it, which silently loses its credential gate entirely. Thirteen invocations are now enumerated with their expected verdict, scope membership is asserted for every credential-requiring one, and every scoped command must have a declared expectation. Both edges verified non-vacuous: reverting either fails the test. The tracker item's description named three scoped paths and the old classifier name; corrected as an append-only comment rather than a rewrite, per review. 71/71 tests, release:check green. --- .agents/pm/history/pm-linear-hwol.jsonl | 4 ++ .agents/pm/issues/pm-linear-hwol.toon | 17 ++++++-- index.ts | 42 +++++++++++++------ test/smoke.test.ts | 56 ++++++++++++++++++++----- 4 files changed, 93 insertions(+), 26 deletions(-) diff --git a/.agents/pm/history/pm-linear-hwol.jsonl b/.agents/pm/history/pm-linear-hwol.jsonl index c3bd67d..aa0867a 100644 --- a/.agents/pm/history/pm-linear-hwol.jsonl +++ b/.agents/pm/history/pm-linear-hwol.jsonl @@ -5,3 +5,7 @@ {"ts":"2026-08-14T06:23:17.611Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"56b7d1a35febb62ac00aa45f","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:23:17.611Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"npm test","path":"test/smoke.test.ts","scope":"project","note":"Asserts registerPreflight is a scoped object whose commands array equals pm-linear's owned mutating command paths (linear sync, linear import, linear export) and run is a function. Test title: preflight override is scoped to pm-linear's owned command paths"}]}],"before_hash":"f7adf74c74b1582d1ead3d64bd637f1fd6127c3dabb985e9f731184aa94a5ca5","after_hash":"41dd2a24ea75f7462e2840697db221e85e588aa9b7f7407efb4432ac95587391"} {"ts":"2026-08-14T06:23:38.379Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"56b7d1a35febb62ac00aa45f","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:23:38.379Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-14T06:23:38.357Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-14T06:23:38.357Z"},{"op":"add","path":"/metadata/close_reason","value":"completed"}],"before_hash":"41dd2a24ea75f7462e2840697db221e85e588aa9b7f7407efb4432ac95587391","after_hash":"4508c73055a970f76adadcce86d7708aede372dfe06914f2cdfc80c99dea1d05"} {"ts":"2026-08-15T10:01:57.163Z","author":"pi-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2a535af1e474c92d3fda7e13","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T10:01:57.163Z"},{"op":"replace","path":"/metadata/title","value":"A globally-scoped preflight override collides with every other installed package, so pm health can never be green"}],"before_hash":"4508c73055a970f76adadcce86d7708aede372dfe06914f2cdfc80c99dea1d05","after_hash":"3ed3df34b1bf155f6618a488c1548e6dfaf78534ea6b75c674e369ce4e65c18d"} +{"ts":"2026-08-15T14:13:34.363Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T14:13:34.363Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-08-15T14:13:34.363Z","author":"claude-agent","text":"Correction to this item's description, recorded as an append-only event rather than a rewrite (CodeRabbit, PR #67).\n\nThe description above names the scoped command paths as \"linear sync, linear import, linear export\" and refers to the classifier as `commandMutatesLinear`. Both are now out of date:\n\n1. **The scope is four paths, not three.** `linear-sync import` — the deprecated importer alias — reaches the same write path as `linear import`. Under the previous global registration it was covered by accident; narrowing the override to a command list made the omission load-bearing, so it is explicitly in scope. The registered array is `[\"linear sync\", \"linear import\", \"linear export\", \"linear-sync import\"]`.\n\n2. **The classifier is now `commandNeedsLinearAccess`.** Review round 2 showed the mutation question was the wrong one to ask, and got both edges wrong in opposite directions — the preflight guards *credentials and reachability*, not writes:\n - `linear export --push --dry-run` writes nothing and is documented as making no network call, yet the mutation test saw `--push` and demanded `LINEAR_API_KEY`, so the offline preview failed on any host without a key.\n - `linear sync|import --atomic --dry-run` also writes nothing but is *not* offline — it fetches issues to build the atomic plan. The mutation test saw `--dry-run` and skipped the preflight, so a missing key surfaced as a raw fetch failure inside the sync core instead of the clean USAGE error.\n\n Renamed and rewritten to answer \"does this invocation reach Linear\", which is what the preflight actually gates.\n\n3. **The scope↔classifier binding is now bidirectional.** The original test walked `override.commands` only, so it could never detect the drift that matters most: a command the classifier requires credentials for while the scope omits it, which silently loses its credential gate entirely. The test now enumerates thirteen invocations with their expected verdict, asserts scope membership for every credential-requiring one, and asserts that every scoped command has a declared expectation.\n\nBoth classifier fixes verified non-vacuous: reverting either edge fails the test. 71/71 pass.\n"}]}],"before_hash":"3ed3df34b1bf155f6618a488c1548e6dfaf78534ea6b75c674e369ce4e65c18d","after_hash":"b563e64af2376f572e6a4eff608444f137806d831c7fd873509b4c6a7606bd20","item_hash_version":2,"message":"Correct the scope and classifier names recorded in the description","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-15T14:14:09.932Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"npx tsx --test test/smoke.test.ts","scope":"project","timeout_seconds":300,"assert_stdout_contains":["preflight override is scoped to pm-linear owned command paths"],"assert_stdout_regex":["fail 0$"],"note":"Bidirectional scope-classifier binding over 13 invocations. Non-vacuous: reverting either dry-run edge fails (verified 2026-08-15)."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T14:14:09.932Z"}],"before_hash":"b563e64af2376f572e6a4eff608444f137806d831c7fd873509b4c6a7606bd20","after_hash":"a9e7d879b8d74fc264d5a5a0231401271a502b9b4505d00503b0c60659ee326a","item_hash_version":2,"message":"Register the bidirectional preflight-scope acceptance test","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-15T14:14:26.158Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_remove","patch":[{"op":"remove","path":"/metadata/tests/1"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T14:14:26.158Z"}],"before_hash":"a9e7d879b8d74fc264d5a5a0231401271a502b9b4505d00503b0c60659ee326a","after_hash":"410e0cba33e8bbd18e57eb5a5bf61f25242e1f0e2d62e7feb85bb1eb88db7999","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-15T14:14:26.570Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"npx tsx --test test/smoke.test.ts","scope":"project","timeout_seconds":300,"assert_stdout_contains":["preflight override is scoped to"],"assert_stdout_regex":["fail 0$"],"note":"Bidirectional scope-classifier binding over 13 invocations. Non-vacuous: reverting either dry-run edge fails (verified 2026-08-15)."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T14:14:26.570Z"}],"before_hash":"410e0cba33e8bbd18e57eb5a5bf61f25242e1f0e2d62e7feb85bb1eb88db7999","after_hash":"b2b927cee392cf0cc7746c300e3e3ec42694bdcd511cbb25bf0b583c3e1fcde6","item_hash_version":2,"message":"Register the bidirectional preflight-scope acceptance test","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} diff --git a/.agents/pm/issues/pm-linear-hwol.toon b/.agents/pm/issues/pm-linear-hwol.toon index 5de51a9..9e85dfe 100644 --- a/.agents/pm/issues/pm-linear-hwol.toon +++ b/.agents/pm/issues/pm-linear-hwol.toon @@ -6,14 +6,25 @@ status: closed priority: 2 tags: [] created_at: "2026-08-14T06:22:39.772Z" -updated_at: "2026-08-15T10:01:57.163Z" +updated_at: "2026-08-15T14:14:26.570Z" closed_at: "2026-08-14T06:23:38.357Z" completed_at: "2026-08-14T06:23:38.357Z" author: pi-agent +comments[1]{created_at,author,text}: + "2026-08-15T14:13:34.363Z",claude-agent,"Correction to this item's description, recorded as an append-only event rather than a rewrite (CodeRabbit, PR #67).\n\nThe description above names the scoped command paths as \"linear sync, linear import, linear export\" and refers to the classifier as `commandMutatesLinear`. Both are now out of date:\n\n1. **The scope is four paths, not three.** `linear-sync import` — the deprecated importer alias — reaches the same write path as `linear import`. Under the previous global registration it was covered by accident; narrowing the override to a command list made the omission load-bearing, so it is explicitly in scope. The registered array is `[\"linear sync\", \"linear import\", \"linear export\", \"linear-sync import\"]`.\n\n2. **The classifier is now `commandNeedsLinearAccess`.** Review round 2 showed the mutation question was the wrong one to ask, and got both edges wrong in opposite directions — the preflight guards *credentials and reachability*, not writes:\n - `linear export --push --dry-run` writes nothing and is documented as making no network call, yet the mutation test saw `--push` and demanded `LINEAR_API_KEY`, so the offline preview failed on any host without a key.\n - `linear sync|import --atomic --dry-run` also writes nothing but is *not* offline — it fetches issues to build the atomic plan. The mutation test saw `--dry-run` and skipped the preflight, so a missing key surfaced as a raw fetch failure inside the sync core instead of the clean USAGE error.\n\n Renamed and rewritten to answer \"does this invocation reach Linear\", which is what the preflight actually gates.\n\n3. **The scope↔classifier binding is now bidirectional.** The original test walked `override.commands` only, so it could never detect the drift that matters most: a command the classifier requires credentials for while the scope omits it, which silently loses its credential gate entirely. The test now enumerates thirteen invocations with their expected verdict, asserts scope membership for every credential-requiring one, and asserts that every scoped command has a declared expectation.\n\nBoth classifier fixes verified non-vacuous: reverting either edge fails the test. 71/71 pass.\n" files[2]{path,scope}: index.ts,project test/smoke.test.ts,project -tests[1]{command,path,scope,note}: - npm test,test/smoke.test.ts,project,"Asserts registerPreflight is a scoped object whose commands array equals pm-linear's owned mutating command paths (linear sync, linear import, linear export) and run is a function. Test title: preflight override is scoped to pm-linear's owned command paths" +tests[2]: + - command: npm test + path: test/smoke.test.ts + scope: project + note: "Asserts registerPreflight is a scoped object whose commands array equals pm-linear's owned mutating command paths (linear sync, linear import, linear export) and run is a function. Test title: preflight override is scoped to pm-linear's owned command paths" + - command: npx tsx --test test/smoke.test.ts + scope: project + timeout_seconds: 300 + assert_stdout_contains[1]: preflight override is scoped to + assert_stdout_regex[1]: fail 0$ + note: "Bidirectional scope-classifier binding over 13 invocations. Non-vacuous: reverting either dry-run edge fails (verified 2026-08-15)." close_reason: completed body: "" diff --git a/index.ts b/index.ts index e536c2f..6d861fc 100644 --- a/index.ts +++ b/index.ts @@ -2319,28 +2319,46 @@ export function buildExportMutationPlan( const PREFLIGHT_ERROR_OPTION = "__linear_preflight_error"; /** - * Whether a command writes to Linear and so requires a credential preflight. + * Whether an invocation reaches Linear and so requires a credential preflight. * - * `sync` and `import` mutate unless run with `--dry-run`; `export` mutates only - * when `--push` is present. Every other command is read-only and skips the key - * check, so a `list` or `validate` never demands an API key it does not need. + * The question this answers is "does this invocation touch the Linear API", + * not "does it write" — because that is precisely what the preflight guards: + * {@link preflightLinear} demands `LINEAR_API_KEY` and then probes reachability. + * Asking the narrower mutation question got both edges wrong, in opposite + * directions: + * + * - `export --push --dry-run` writes nothing and is documented as making no + * network call, yet a mutation-shaped test saw `--push` and demanded an API + * key, so the offline preview failed on any host without one. + * - `sync`/`import` with `--atomic --dry-run` also writes nothing, but it is + * *not* offline: it fetches issues to build the atomic plan. A mutation-shaped + * test saw `--dry-run` and skipped the preflight, so a missing or invalid key + * surfaced as a raw fetch failure deep in the sync core instead of the clean + * USAGE error the preflight exists to produce. + * + * So: `--dry-run` alone is offline for `sync`, `import`, and `export`; combined + * with `--atomic` it still fetches. Every other command is read-only and skips + * the key check, so a `list` or `validate` never demands a key it does not need. * * @param command - The full command string (e.g. `linear sync`). - * @param options - The raw flag bag, inspected for `--dry-run` and `--push`. - * @returns True when the command needs a valid Linear API key. + * @param options - The raw flag bag, inspected for `--dry-run`, `--atomic`, and `--push`. + * @returns True when the invocation needs a valid, reachable Linear API key. */ -export function commandMutatesLinear(command: string, options: Record): boolean { +export function commandNeedsLinearAccess(command: string, options: Record): boolean { const cmd = command.trim().toLowerCase(); + const dryRun = readBooleanOption(options, "dry-run"); // `linear-sync import` is the deprecated importer alias. It reaches the same - // write path as `linear import`, so it has to be classified as mutating here - // too -- scoping the preflight override to a command list made this omission + // write path as `linear import`, so it has to be classified here too -- + // scoping the preflight override to a command list made this omission // load-bearing, where the previously global registration had covered it by // accident. if (cmd === "linear sync" || cmd === "linear import" || cmd === "linear-sync import") { - return !readBooleanOption(options, "dry-run"); + // `--atomic --dry-run` is a preview, but it builds that preview from issues + // it fetches, so it needs the key just as much as a real run. + return !dryRun || readBooleanOption(options, "atomic"); } if (cmd === "linear export") { - return readBooleanOption(options, "push"); + return readBooleanOption(options, "push") && !dryRun; } return false; } @@ -2549,7 +2567,7 @@ export default defineExtension({ api.registerPreflight({ commands: ["linear sync", "linear import", "linear export", "linear-sync import"], run: async (ctx: PreflightOverrideContext) => { - if (!commandMutatesLinear(ctx.command, ctx.options)) return {}; + if (!commandNeedsLinearAccess(ctx.command, ctx.options)) return {}; // Reachability uses the network; allow opting out (CI/offline/tests). // pm strips a leading `--no-` as boolean negation, so the user-facing flag // is `--skip-preflight-network` (the legacy `no-preflight-network` key is diff --git a/test/smoke.test.ts b/test/smoke.test.ts index ede406a..ec19bd5 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { createExtensionTestHarness, type ExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing"; import extension, { - commandMutatesLinear, + commandNeedsLinearAccess, parseStatusMap, resolveStatus, buildProvenance, @@ -82,7 +82,7 @@ test("preflight override is scoped to pm-linear's owned command paths", async () // installed package's preflight override (pm health reports // extension_preflight_override_collision). The runtime matches a command // against `commands` by exact normalized path, so the array lists the full - // mutating command paths commandMutatesLinear recognizes. + // command paths commandNeedsLinearAccess can require credentials for. const override = linearHarness.assertPreflightOverride(); assert.deepEqual( override.commands, @@ -94,18 +94,52 @@ test("preflight override is scoped to pm-linear's owned command paths", async () "function", "scoped preflight override must expose a run function", ); - // Bind the scope to the classifier. Narrowing the override to a command list - // means an entry missing here is a command that silently loses its credential - // gate, so every path the classifier calls mutating must also be in scope. - // `linear export` is excluded because it only mutates with --push. - for (const command of override.commands ?? []) { - if (command === "linear export") continue; + // Bind the scope to the classifier, in BOTH directions. A loop that only walks + // `override.commands` cannot detect the drift that matters most: a command the + // classifier says needs credentials while the scope omits it, which silently + // loses its credential gate. So the invocations are enumerated explicitly with + // their expected verdict, and the two sets are checked against each other. + // + // The `--dry-run` rows are the ones that were wrong before: `export --push + // --dry-run` is documented as making no network call yet demanded a key, and + // `--atomic --dry-run` does fetch issues yet skipped the preflight entirely. + const invocations: ReadonlyArray, boolean]> = [ + ["linear sync", {}, true], + ["linear sync", { "dry-run": true }, false], + ["linear sync", { "dry-run": true, atomic: true }, true], + ["linear import", {}, true], + ["linear import", { "dry-run": true }, false], + ["linear import", { "dry-run": true, atomic: true }, true], + ["linear-sync import", {}, true], + ["linear-sync import", { "dry-run": true }, false], + ["linear-sync import", { "dry-run": true, atomic: true }, true], + ["linear export", {}, false], + ["linear export", { push: true }, true], + ["linear export", { push: true, "dry-run": true }, false], + ["linear export", { "dry-run": true }, false], + ]; + const scope = new Set(override.commands ?? []); + for (const [command, options, needsAccess] of invocations) { assert.strictEqual( - commandMutatesLinear(command, {}), - true, - `${command} is in the preflight scope but the classifier does not treat it as mutating`, + commandNeedsLinearAccess(command, options), + needsAccess, + `commandNeedsLinearAccess(${command}, ${JSON.stringify(options)}) must be ${needsAccess}`, ); + if (needsAccess) { + assert.ok( + scope.has(command), + `${command} needs Linear credentials but is missing from the preflight scope, so it would run with no credential gate`, + ); + } } + // ...and the other direction: every scoped command must appear above, so a + // newly scoped command cannot sit here with no stated expectation. + const covered = new Set(invocations.map(([command]) => command)); + assert.deepEqual( + [...scope].filter((command) => !covered.has(command)), + [], + "every command in the preflight scope must have an expected classifier verdict declared above", + ); }); test("parseStatusMap preserves original key casing and ignores junk", () => { From fc94ef8ca02948088804f6fe163d07d2b0d1f3c8 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:59:40 +0200 Subject: [PATCH 5/6] Pin the host CLI exactly and declare the manifest floor where the loader reads it CI failed on 'Verify tracked pm project health' with history_drift_hash_mismatch and history_drift_chain_mismatch on pm-linear-hwol. The history is not corrupt: the append-only comment was written by pm 2026.8.15 while this package pinned ^2026.8.6 and CI resolved 2026.8.6, which cannot verify the newer hash chain. Reproduced both ways against the same bytes -- 2026.8.6 reports drift, 2026.8.15 reports none. Upstream has this class already (unbraind/pm-cli#976), so the fix is to agree on one CLI version. That turned up three stale host-compatibility declarations, each aimed at a different consumer: - devDependencies was ^2026.8.6, a range rather than a pin. That is how the 2026.8.14 truncated-list-all build reached packages with no diff to review, and here it also let CI disagree with the tracker's own history. Now exactly 2026.8.15. - peerDependencies was >=2026.7.29, below the fleet floor. Now >=2026.8.15. - manifest.json declared the floor as pm.pm_min_version, nested one level below where the host's version gate looks. Verified against the SDK's own checkExtensionManifestCompatibility: the manifest returned compatible: true for every host back to 2026.1.1, including 2026.8.14. The floor was not stale, it was never enforced. Hoisted to top level. This is the third distinct spelling of the same unenforced-floor defect found across the fleet today, after pm-context's compatibility:{pm:...} and pm-beads' stale top-level value. Every one of those suites was green. Reported upstream as unbraind/pm-cli#1032. Two regression tests added, matching pm-beads and pm-context: one binds the manifest floor to the peer floor and asserts the dev pin is exact, the other runs the SDK host gate against the manifest bytes on disk. pm health under the pinned 2026.8.15 reports no drift, --strict-exit returns 0, release:check green, 71/71 tests. --- .agents/pm/history/pm-linear-hwol.jsonl | 2 + .agents/pm/issues/pm-linear-hwol.toon | 10 ++-- .gitattributes | 6 ++- manifest.json | 4 +- package-lock.json | 72 ++++++++++++------------- package.json | 4 +- test/smoke.test.ts | 59 ++++++++++++++++++++ 7 files changed, 112 insertions(+), 45 deletions(-) diff --git a/.agents/pm/history/pm-linear-hwol.jsonl b/.agents/pm/history/pm-linear-hwol.jsonl index aa0867a..97968e0 100644 --- a/.agents/pm/history/pm-linear-hwol.jsonl +++ b/.agents/pm/history/pm-linear-hwol.jsonl @@ -9,3 +9,5 @@ {"ts":"2026-08-15T14:14:09.932Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"npx tsx --test test/smoke.test.ts","scope":"project","timeout_seconds":300,"assert_stdout_contains":["preflight override is scoped to pm-linear owned command paths"],"assert_stdout_regex":["fail 0$"],"note":"Bidirectional scope-classifier binding over 13 invocations. Non-vacuous: reverting either dry-run edge fails (verified 2026-08-15)."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T14:14:09.932Z"}],"before_hash":"b563e64af2376f572e6a4eff608444f137806d831c7fd873509b4c6a7606bd20","after_hash":"a9e7d879b8d74fc264d5a5a0231401271a502b9b4505d00503b0c60659ee326a","item_hash_version":2,"message":"Register the bidirectional preflight-scope acceptance test","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} {"ts":"2026-08-15T14:14:26.158Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_remove","patch":[{"op":"remove","path":"/metadata/tests/1"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T14:14:26.158Z"}],"before_hash":"a9e7d879b8d74fc264d5a5a0231401271a502b9b4505d00503b0c60659ee326a","after_hash":"410e0cba33e8bbd18e57eb5a5bf61f25242e1f0e2d62e7feb85bb1eb88db7999","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} {"ts":"2026-08-15T14:14:26.570Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"npx tsx --test test/smoke.test.ts","scope":"project","timeout_seconds":300,"assert_stdout_contains":["preflight override is scoped to"],"assert_stdout_regex":["fail 0$"],"note":"Bidirectional scope-classifier binding over 13 invocations. Non-vacuous: reverting either dry-run edge fails (verified 2026-08-15)."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T14:14:26.570Z"}],"before_hash":"410e0cba33e8bbd18e57eb5a5bf61f25242e1f0e2d62e7feb85bb1eb88db7999","after_hash":"b2b927cee392cf0cc7746c300e3e3ec42694bdcd511cbb25bf0b583c3e1fcde6","item_hash_version":2,"message":"Register the bidirectional preflight-scope acceptance test","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-15T14:59:28.053Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-08-15T14:59:28.053Z","author":"claude-agent","text":"CI failure on PR #67, and what it exposed.\n\nThe `test (22)` / `test (26)` jobs failed on `Verify tracked pm project health`, reporting:\n\n```\nhistory_drift_hash_mismatch:pm-linear-hwol\nhistory_drift_chain_mismatch:pm-linear-hwol\n```\n\nNot a corrupt history. The append-only comment recording the description correction was written by pm **2026.8.15** (the CLI on PATH), while this package pinned `@unbrained/pm-cli` as `^2026.8.6` and CI resolved **2026.8.6** — a version that cannot verify the newer hash chain. Reproduced both ways on this host against the same bytes:\n\n- `./node_modules/.bin/pm health` (2026.8.6) → both drift warnings\n- `pm health` (2026.8.15) → no drift\n\nUpstream has this class already (unbraind/pm-cli#976, closed), so the fix belongs on our side: agree on one CLI version.\n\nFixing it turned up that this package was behind the fleet convention in **three** places at once, each stating a host-compatibility fact to a different consumer, and all three stale:\n\n1. `devDependencies` was `^2026.8.6` — a caret range, not a pin. That is precisely how the 2026.8.14 truncated-`list-all` build reached packages with no diff to review, and here it also meant CI could disagree with the history the tracker writes. Now exactly `2026.8.15`.\n2. `peerDependencies` was `>=2026.7.29`, below the floor the fleet standardised on. Now `>=2026.8.15`.\n3. `manifest.json` declared the floor as `pm.pm_min_version` — nested one level below where the host's version gate looks, which reads top-level `pm_min_version`. Verified against the SDK's own `checkExtensionManifestCompatibility`: the manifest returned `compatible: true` for every host tested back to 2026.1.1, including 2026.8.14. **The floor was not merely stale, it was never enforced.** Hoisted to top level; the gate now refuses 2026.8.14 with `pm_min_version_unmet` and still accepts later hosts.\n\nThis is the third distinct spelling of the same unenforced-floor defect found across the fleet today — pm-context used `compatibility: { pm: ... }`, pm-beads had the right field with a stale value, pm-linear nested it under `pm`. All three packages' suites were fully green throughout. Reported upstream as unbraind/pm-cli#1032, asking for an advisory finding when a manifest declares no enforceable bound and for unrecognized manifest keys to be reported.\n\nTwo regression tests added, matching the ones now in pm-beads and pm-context: one binds `manifest.json` `pm_min_version` to the package.json peer floor and asserts the dev pin is exact rather than a range; the other runs the SDK host gate against the manifest bytes on disk so a floor moved back under a nested key fails loudly instead of shipping inert.\n\nVerified after the change: `pm health` under the pinned 2026.8.15 reports no drift and `--strict-exit` returns 0. `release:check` green, 71/71 tests.\n"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T14:59:28.053Z"}],"before_hash":"b2b927cee392cf0cc7746c300e3e3ec42694bdcd511cbb25bf0b583c3e1fcde6","after_hash":"9d86199c7ff0a7e94d419e6c558d33e3e34ecc83797fbadf2cac45120805600a","item_hash_version":2,"message":"Record the CI history-drift failure and the three stale compatibility declarations it exposed","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-15T14:59:28.509Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/files/1/path","value":"manifest.json"},{"op":"add","path":"/metadata/files/2","value":{"path":"package-lock.json","scope":"project"}},{"op":"add","path":"/metadata/files/3","value":{"path":"package.json","scope":"project"}},{"op":"add","path":"/metadata/files/4","value":{"path":"test/smoke.test.ts","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T14:59:28.509Z"}],"before_hash":"9d86199c7ff0a7e94d419e6c558d33e3e34ecc83797fbadf2cac45120805600a","after_hash":"29d5a604ae7c15ccf8f63659b69f66c0226ffa5c7a4678b2a64477bd11b78314","item_hash_version":2,"message":"Link the compatibility declaration files","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} diff --git a/.agents/pm/issues/pm-linear-hwol.toon b/.agents/pm/issues/pm-linear-hwol.toon index 9e85dfe..7b7eb9b 100644 --- a/.agents/pm/issues/pm-linear-hwol.toon +++ b/.agents/pm/issues/pm-linear-hwol.toon @@ -6,14 +6,18 @@ status: closed priority: 2 tags: [] created_at: "2026-08-14T06:22:39.772Z" -updated_at: "2026-08-15T14:14:26.570Z" +updated_at: "2026-08-15T14:59:28.509Z" closed_at: "2026-08-14T06:23:38.357Z" completed_at: "2026-08-14T06:23:38.357Z" author: pi-agent -comments[1]{created_at,author,text}: +comments[2]{created_at,author,text}: "2026-08-15T14:13:34.363Z",claude-agent,"Correction to this item's description, recorded as an append-only event rather than a rewrite (CodeRabbit, PR #67).\n\nThe description above names the scoped command paths as \"linear sync, linear import, linear export\" and refers to the classifier as `commandMutatesLinear`. Both are now out of date:\n\n1. **The scope is four paths, not three.** `linear-sync import` — the deprecated importer alias — reaches the same write path as `linear import`. Under the previous global registration it was covered by accident; narrowing the override to a command list made the omission load-bearing, so it is explicitly in scope. The registered array is `[\"linear sync\", \"linear import\", \"linear export\", \"linear-sync import\"]`.\n\n2. **The classifier is now `commandNeedsLinearAccess`.** Review round 2 showed the mutation question was the wrong one to ask, and got both edges wrong in opposite directions — the preflight guards *credentials and reachability*, not writes:\n - `linear export --push --dry-run` writes nothing and is documented as making no network call, yet the mutation test saw `--push` and demanded `LINEAR_API_KEY`, so the offline preview failed on any host without a key.\n - `linear sync|import --atomic --dry-run` also writes nothing but is *not* offline — it fetches issues to build the atomic plan. The mutation test saw `--dry-run` and skipped the preflight, so a missing key surfaced as a raw fetch failure inside the sync core instead of the clean USAGE error.\n\n Renamed and rewritten to answer \"does this invocation reach Linear\", which is what the preflight actually gates.\n\n3. **The scope↔classifier binding is now bidirectional.** The original test walked `override.commands` only, so it could never detect the drift that matters most: a command the classifier requires credentials for while the scope omits it, which silently loses its credential gate entirely. The test now enumerates thirteen invocations with their expected verdict, asserts scope membership for every credential-requiring one, and asserts that every scoped command has a declared expectation.\n\nBoth classifier fixes verified non-vacuous: reverting either edge fails the test. 71/71 pass.\n" -files[2]{path,scope}: + "2026-08-15T14:59:28.053Z",claude-agent,"CI failure on PR #67, and what it exposed.\n\nThe `test (22)` / `test (26)` jobs failed on `Verify tracked pm project health`, reporting:\n\n```\nhistory_drift_hash_mismatch:pm-linear-hwol\nhistory_drift_chain_mismatch:pm-linear-hwol\n```\n\nNot a corrupt history. The append-only comment recording the description correction was written by pm **2026.8.15** (the CLI on PATH), while this package pinned `@unbrained/pm-cli` as `^2026.8.6` and CI resolved **2026.8.6** — a version that cannot verify the newer hash chain. Reproduced both ways on this host against the same bytes:\n\n- `./node_modules/.bin/pm health` (2026.8.6) → both drift warnings\n- `pm health` (2026.8.15) → no drift\n\nUpstream has this class already (unbraind/pm-cli#976, closed), so the fix belongs on our side: agree on one CLI version.\n\nFixing it turned up that this package was behind the fleet convention in **three** places at once, each stating a host-compatibility fact to a different consumer, and all three stale:\n\n1. `devDependencies` was `^2026.8.6` — a caret range, not a pin. That is precisely how the 2026.8.14 truncated-`list-all` build reached packages with no diff to review, and here it also meant CI could disagree with the history the tracker writes. Now exactly `2026.8.15`.\n2. `peerDependencies` was `>=2026.7.29`, below the floor the fleet standardised on. Now `>=2026.8.15`.\n3. `manifest.json` declared the floor as `pm.pm_min_version` — nested one level below where the host's version gate looks, which reads top-level `pm_min_version`. Verified against the SDK's own `checkExtensionManifestCompatibility`: the manifest returned `compatible: true` for every host tested back to 2026.1.1, including 2026.8.14. **The floor was not merely stale, it was never enforced.** Hoisted to top level; the gate now refuses 2026.8.14 with `pm_min_version_unmet` and still accepts later hosts.\n\nThis is the third distinct spelling of the same unenforced-floor defect found across the fleet today — pm-context used `compatibility: { pm: ... }`, pm-beads had the right field with a stale value, pm-linear nested it under `pm`. All three packages' suites were fully green throughout. Reported upstream as unbraind/pm-cli#1032, asking for an advisory finding when a manifest declares no enforceable bound and for unrecognized manifest keys to be reported.\n\nTwo regression tests added, matching the ones now in pm-beads and pm-context: one binds `manifest.json` `pm_min_version` to the package.json peer floor and asserts the dev pin is exact rather than a range; the other runs the SDK host gate against the manifest bytes on disk so a floor moved back under a nested key fails loudly instead of shipping inert.\n\nVerified after the change: `pm health` under the pinned 2026.8.15 reports no drift and `--strict-exit` returns 0. `release:check` green, 71/71 tests.\n" +files[5]{path,scope}: index.ts,project + manifest.json,project + package-lock.json,project + package.json,project test/smoke.test.ts,project tests[2]: - command: npm test diff --git a/.gitattributes b/.gitattributes index fe696ab..46c775b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ -# pm-cli:merge-drivers:start +# pm-cli:merge-drivers:v2:start ".agents/pm/**/*.toon" merge=pm-item-toon ".agents/pm/**/*.md" merge=pm-item-markdown ".agents/pm/chores/*.toon" merge=pm-item-toon @@ -27,4 +27,6 @@ ".agents/pm/history/*.jsonl" merge=pm-history ".agents/pm/settings.json" merge=pm-json ".agents/pm/**/*.json" merge=pm-json -# pm-cli:merge-drivers:end +".agents/pm/extensions/**" -merge +".agents/pm/extensions/.managed-extensions.json" merge=pm-json +# pm-cli:merge-drivers:v2:end diff --git a/manifest.json b/manifest.json index 5e96086..fc57b3d 100644 --- a/manifest.json +++ b/manifest.json @@ -5,6 +5,7 @@ "author": "@unbraind", "entry": "./dist/index.js", "priority": 50, + "pm_min_version": "2026.8.15", "capabilities": [ "commands", "schema", @@ -12,7 +13,6 @@ "preflight" ], "pm": { - "compatibility": "v2", - "pm_min_version": "2026.7.10" + "compatibility": "v2" } } diff --git a/package-lock.json b/package-lock.json index 5128574..71a9e65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "devDependencies": { "@types/node": "^26.1.1", - "@unbrained/pm-cli": "^2026.8.6", + "@unbrained/pm-cli": "2026.8.15", "pm-changelog": "^2026.8.6", "pm-ops": "^2026.8.8", "typescript": "^7.0.2" @@ -19,7 +19,7 @@ "node": ">=22.18.0" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.7.29" + "@unbrained/pm-cli": ">=2026.8.15" } }, "node_modules/@apm-js-collab/code-transformer": { @@ -258,9 +258,9 @@ } }, "node_modules/@sentry/core": { - "version": "10.69.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.69.0.tgz", - "integrity": "sha512-+uuqVEeiDzYuAKjZLqsROKXvRTbl/QeH0gfGRtpYib1cud4rAFWRIkFmcR7Jb7JGFYwmReyQotiTj/hcDszTZg==", + "version": "10.70.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.70.0.tgz", + "integrity": "sha512-ozhCTDqg89oB4XmWfAwuHshABpvT7AkRpaPnogopPfMAaI61G1t8EKCJ4W7aum8JSBonlfyjPCyW5oYZFm0KvA==", "dev": true, "license": "MIT", "dependencies": { @@ -271,9 +271,9 @@ } }, "node_modules/@sentry/node": { - "version": "10.69.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.69.0.tgz", - "integrity": "sha512-xEXA1YGIiTZbrW6MWV34uS6JGQuQg2ijTI0zed+FsJb9JZKPYel/GZK8Km26vfTVb+yCXFmWZNBesKegNcVdzg==", + "version": "10.70.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.70.0.tgz", + "integrity": "sha512-SPOOVxmKTVIEtqvOKkQT163e/pOwucjS7OPsCHyRs8sFR4nfBNu0EThplyqnvqd5BWBMTPH6WTBQfo+QWHV+HA==", "dev": true, "license": "MIT", "dependencies": { @@ -281,10 +281,10 @@ "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.69.0", - "@sentry/node-core": "10.69.0", - "@sentry/opentelemetry": "10.69.0", - "@sentry/server-utils": "10.69.0", + "@sentry/core": "10.70.0", + "@sentry/node-core": "10.70.0", + "@sentry/opentelemetry": "10.70.0", + "@sentry/server-utils": "10.70.0", "import-in-the-middle": "^3.0.0" }, "engines": { @@ -292,15 +292,15 @@ } }, "node_modules/@sentry/node-core": { - "version": "10.69.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.69.0.tgz", - "integrity": "sha512-IgArHczrZJxkgxoffHscj0NxQrG6kCazgmGQnlf3j58J1ec21YaUu8Tu+7G4Lo5tCiW3teQnwlKW1ttMXSqWRw==", + "version": "10.70.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.70.0.tgz", + "integrity": "sha512-oPOEVVNxv5WHtckx2i06Wi9FLWyvOg/1DUeX732jZ4iqT2nupINaMH4nF4f4kSvUThFnxkFSRQxwqOxgzMKhKA==", "dev": true, "license": "MIT", "dependencies": { "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.69.0", - "@sentry/opentelemetry": "10.69.0", + "@sentry/core": "10.70.0", + "@sentry/opentelemetry": "10.70.0", "import-in-the-middle": "^3.0.0" }, "engines": { @@ -332,14 +332,14 @@ } }, "node_modules/@sentry/opentelemetry": { - "version": "10.69.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.69.0.tgz", - "integrity": "sha512-3FyWV6YcEJuvLrlaKGE1dHXCI+1YO0a62w7PkwlRg8yp6K6YXkmdwu9GjqaYD+Ju4tm7uC7mHIsGFQMm0M7pqQ==", + "version": "10.70.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.70.0.tgz", + "integrity": "sha512-UNV/2tqypcUK6FDzerAsFJn1Km/c4VZCYkUZDNbnV5S0cwAq2BYKMo4M5vovaLDBQlxA+Wk9ovbxi5wYjjl9fw==", "dev": true, "license": "MIT", "dependencies": { "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.69.0" + "@sentry/core": "10.70.0" }, "engines": { "node": ">=18" @@ -351,16 +351,16 @@ } }, "node_modules/@sentry/server-utils": { - "version": "10.69.0", - "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.69.0.tgz", - "integrity": "sha512-0MwHrA8+nNvMIsqf8m3cXwCBlUjr6AS7N6CZvHJtY1DkqEvQqEbD5VIrhzEyHN/KMZIgQ8XeDCQRhjnXFQGRhg==", + "version": "10.70.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.70.0.tgz", + "integrity": "sha512-rzegZjMFFgCp3o+N8+XU13rfSvz4B+f8rU0ijBGrQcHdMNyfsFDTu1UTm262JofmrV2u+s+D0u0vFTnqtOGkbA==", "dev": true, "license": "MIT", "dependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3", "@apm-js-collab/tracing-hooks": "^0.13.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.69.0", + "@sentry/core": "10.70.0", "meriyah": "^6.1.4" }, "engines": { @@ -368,9 +368,9 @@ } }, "node_modules/@toon-format/toon": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-4.1.0.tgz", - "integrity": "sha512-dBB3pkEx9QYvHnHR6rtkaBAh+7x4W/oA5ONur4G0fh7Ow69PbPuM7OFxzNRABqyxC0t6SZ3RixiGbCuaFjPDAQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-4.1.1.tgz", + "integrity": "sha512-SGCkS7IjVpwRmGPgnY8ENKpAf0EdAnZDOQkvFW0d2cgOpdn9FEFl7sTgryESyypXrWr0YajHGpwsAUX4zw9ZvA==", "dev": true, "license": "MIT" }, @@ -732,14 +732,14 @@ } }, "node_modules/@unbrained/pm-cli": { - "version": "2026.8.6", - "resolved": "https://registry.npmjs.org/@unbrained/pm-cli/-/pm-cli-2026.8.6.tgz", - "integrity": "sha512-98pay0mtwwcWhc9dORhPo3wAmcXV1E5snPv/QMrSd9CXNhCUB3mwYRf194iYR29ttUSfMfGtrF/G8tjbC+fPKQ==", + "version": "2026.8.15", + "resolved": "https://registry.npmjs.org/@unbrained/pm-cli/-/pm-cli-2026.8.15.tgz", + "integrity": "sha512-tQxSeOVLOjmbcwQp7GZ55NvtbMw6KJyVmPMpfCwSTnQiEq9GofGFwKqP8dzgoN5etrvyKA13XZZypM0Kja4x1A==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/node": "10.69.0", - "@toon-format/toon": "^4.1.0", + "@sentry/node": "10.70.0", + "@toon-format/toon": "^4.1.1", "@types/node": ">=22", "commander": "^15.0.0", "fast-glob": "^3.3.3", @@ -790,9 +790,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 055a5f4..3020886 100644 --- a/package.json +++ b/package.json @@ -54,11 +54,11 @@ "ignore": [] }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.7.29" + "@unbrained/pm-cli": ">=2026.8.15" }, "devDependencies": { "@types/node": "^26.1.1", - "@unbrained/pm-cli": "^2026.8.6", + "@unbrained/pm-cli": "2026.8.15", "pm-changelog": "^2026.8.6", "pm-ops": "^2026.8.8", "typescript": "^7.0.2" diff --git a/test/smoke.test.ts b/test/smoke.test.ts index ec19bd5..56c691f 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; +import { checkExtensionManifestCompatibility } from "@unbrained/pm-cli/sdk"; + import { createExtensionTestHarness, type ExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing"; import extension, { @@ -921,3 +924,59 @@ test("AuthHttpError carries its HTTP status and is non-retriable by name", () => assert.equal(e403.status, 403); assert.ok(e403.message.includes("403")); }); + +/** + * `package.json` and `manifest.json` state the same host-compatibility fact to + * two different installers: npm reads the `peerDependencies` floor, the pm host + * reads `manifest.json`'s `pm_min_version` when it loads the extension. Nothing + * bound them, and this package had drifted in three places at once — a caret dev + * range, a `>=2026.7.29` peer, and a floor written as `pm.pm_min_version`, nested + * one level below where the host's version gate looks. `checkExtensionManifestCompatibility` + * therefore returned `compatible: true` for every host tested back to 2026.1.1, + * including 2026.8.14, whose truncated `list-all` is the regression the fleet + * pinned away from. The floor is now in the field the gate reads, and bound here. + */ +test("the manifest host floor matches the package peer floor", () => { + const manifest = JSON.parse( + readFileSync(new URL("../manifest.json", import.meta.url), "utf8"), + ) as { pm_min_version?: string }; + const pkg = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { peerDependencies?: Record; devDependencies?: Record }; + const peer = pkg.peerDependencies?.["@unbrained/pm-cli"] ?? ""; + assert.match(peer, /^>=\d+\.\d+\.\d+$/, "the peer declaration must be a concrete >= floor"); + assert.equal( + manifest.pm_min_version, + peer.replace(/^>=/, ""), + `manifest.json pm_min_version "${manifest.pm_min_version}" must equal the peer floor "${peer}"`, + ); + // The dev pin is exact, not a range: it decides which CLI the gates run + // against, and a caret range is how the 2026.8.14 truncated-`list-all` build + // reached this package with no diff to review. It also has to agree with the + // history the tracker writes -- CI running an older CLI than the one that + // wrote `.agents/pm/history/*.jsonl` reports that history as drifted. + assert.match( + pkg.devDependencies?.["@unbrained/pm-cli"] ?? "", + /^\d+\.\d+\.\d+$/, + "the dev CLI must be pinned exactly, not to a range", + ); +}); + +/** + * The floor above is only worth stating if the host actually enforces it. This + * runs the SDK's own gate against the manifest bytes on disk, so a floor moved + * back under a nested key fails here rather than shipping as an inert claim. + */ +test("the pm host gate refuses every version below the declared floor", () => { + const manifest = JSON.parse( + readFileSync(new URL("../manifest.json", import.meta.url), "utf8"), + ) as Record; + const below = checkExtensionManifestCompatibility(manifest, { pmVersion: "2026.8.14" }); + assert.equal(below.compatible, false, "the known-bad 2026.8.14 host must be refused"); + assert.deepEqual(below.findings.map((f) => f.code), ["pm_min_version_unmet"]); + assert.equal( + checkExtensionManifestCompatibility(manifest, { pmVersion: "2027.1.1" }).compatible, + true, + "a floor must not reject later hosts: that is what an exact pin would do", + ); +}); From 95ae1362decb075801f39b84f0036a1805fc4ca5 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:47:34 +0200 Subject: [PATCH 6/6] State the dry-run network contract accurately in help, README and records Review round 3 on PR #67 (CodeRabbit). The preflight heading, both --dry-run flag descriptions, and the README 'Offline vs live' section all promised that every dry-run path is fully offline and needs no LINEAR_API_KEY. That is false for --atomic --dry-run, which fetches issues to build the plan it reports -- the very case round 2 added to the classifier. A user reading the flag help was told a command needs no key when it does. All four now state the split explicitly: dry-run never writes, but 'no writes' and 'no network' are not the same thing. Offline covers sync/import/linear-sync import with --dry-run alone and export --dry-run with or without --push; reaching Linear covers the real runs, export --push, validate --check-network, and --atomic --dry-run. Two tracker records were also stale. pm-linear-ydo4's resolution and expected_result described commandMutatesLinear and mutation-only scope behaviour, so its own subject -- the alias losing its credential gate -- was recorded against a contract that no longer exists; being in scope no longer implies being gated, since that now depends on the options. Corrected via pm update plus an appended comment, no history file edited. pm-linear-hwol's acceptance-test note still named three 'mutating command paths' and omitted linear-sync import; replaced with the four owned paths and the bidirectional binding it actually asserts. Both registered acceptance tests re-run green. release:check green. --- .agents/pm/history/pm-linear-hwol.jsonl | 2 ++ .agents/pm/history/pm-linear-ydo4.jsonl | 2 ++ .agents/pm/issues/pm-linear-hwol.toon | 11 +++++----- .agents/pm/issues/pm-linear-ydo4.toon | 8 +++++--- README.md | 27 ++++++++++++++++--------- index.ts | 12 +++++++---- 6 files changed, 41 insertions(+), 21 deletions(-) diff --git a/.agents/pm/history/pm-linear-hwol.jsonl b/.agents/pm/history/pm-linear-hwol.jsonl index 97968e0..55349d0 100644 --- a/.agents/pm/history/pm-linear-hwol.jsonl +++ b/.agents/pm/history/pm-linear-hwol.jsonl @@ -11,3 +11,5 @@ {"ts":"2026-08-15T14:14:26.570Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"npx tsx --test test/smoke.test.ts","scope":"project","timeout_seconds":300,"assert_stdout_contains":["preflight override is scoped to"],"assert_stdout_regex":["fail 0$"],"note":"Bidirectional scope-classifier binding over 13 invocations. Non-vacuous: reverting either dry-run edge fails (verified 2026-08-15)."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T14:14:26.570Z"}],"before_hash":"410e0cba33e8bbd18e57eb5a5bf61f25242e1f0e2d62e7feb85bb1eb88db7999","after_hash":"b2b927cee392cf0cc7746c300e3e3ec42694bdcd511cbb25bf0b583c3e1fcde6","item_hash_version":2,"message":"Register the bidirectional preflight-scope acceptance test","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} {"ts":"2026-08-15T14:59:28.053Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-08-15T14:59:28.053Z","author":"claude-agent","text":"CI failure on PR #67, and what it exposed.\n\nThe `test (22)` / `test (26)` jobs failed on `Verify tracked pm project health`, reporting:\n\n```\nhistory_drift_hash_mismatch:pm-linear-hwol\nhistory_drift_chain_mismatch:pm-linear-hwol\n```\n\nNot a corrupt history. The append-only comment recording the description correction was written by pm **2026.8.15** (the CLI on PATH), while this package pinned `@unbrained/pm-cli` as `^2026.8.6` and CI resolved **2026.8.6** — a version that cannot verify the newer hash chain. Reproduced both ways on this host against the same bytes:\n\n- `./node_modules/.bin/pm health` (2026.8.6) → both drift warnings\n- `pm health` (2026.8.15) → no drift\n\nUpstream has this class already (unbraind/pm-cli#976, closed), so the fix belongs on our side: agree on one CLI version.\n\nFixing it turned up that this package was behind the fleet convention in **three** places at once, each stating a host-compatibility fact to a different consumer, and all three stale:\n\n1. `devDependencies` was `^2026.8.6` — a caret range, not a pin. That is precisely how the 2026.8.14 truncated-`list-all` build reached packages with no diff to review, and here it also meant CI could disagree with the history the tracker writes. Now exactly `2026.8.15`.\n2. `peerDependencies` was `>=2026.7.29`, below the floor the fleet standardised on. Now `>=2026.8.15`.\n3. `manifest.json` declared the floor as `pm.pm_min_version` — nested one level below where the host's version gate looks, which reads top-level `pm_min_version`. Verified against the SDK's own `checkExtensionManifestCompatibility`: the manifest returned `compatible: true` for every host tested back to 2026.1.1, including 2026.8.14. **The floor was not merely stale, it was never enforced.** Hoisted to top level; the gate now refuses 2026.8.14 with `pm_min_version_unmet` and still accepts later hosts.\n\nThis is the third distinct spelling of the same unenforced-floor defect found across the fleet today — pm-context used `compatibility: { pm: ... }`, pm-beads had the right field with a stale value, pm-linear nested it under `pm`. All three packages' suites were fully green throughout. Reported upstream as unbraind/pm-cli#1032, asking for an advisory finding when a manifest declares no enforceable bound and for unrecognized manifest keys to be reported.\n\nTwo regression tests added, matching the ones now in pm-beads and pm-context: one binds `manifest.json` `pm_min_version` to the package.json peer floor and asserts the dev pin is exact rather than a range; the other runs the SDK host gate against the manifest bytes on disk so a floor moved back under a nested key fails loudly instead of shipping inert.\n\nVerified after the change: `pm health` under the pinned 2026.8.15 reports no drift and `--strict-exit` returns 0. `release:check` green, 71/71 tests.\n"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T14:59:28.053Z"}],"before_hash":"b2b927cee392cf0cc7746c300e3e3ec42694bdcd511cbb25bf0b583c3e1fcde6","after_hash":"9d86199c7ff0a7e94d419e6c558d33e3e34ecc83797fbadf2cac45120805600a","item_hash_version":2,"message":"Record the CI history-drift failure and the three stale compatibility declarations it exposed","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} {"ts":"2026-08-15T14:59:28.509Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/files/1/path","value":"manifest.json"},{"op":"add","path":"/metadata/files/2","value":{"path":"package-lock.json","scope":"project"}},{"op":"add","path":"/metadata/files/3","value":{"path":"package.json","scope":"project"}},{"op":"add","path":"/metadata/files/4","value":{"path":"test/smoke.test.ts","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T14:59:28.509Z"}],"before_hash":"9d86199c7ff0a7e94d419e6c558d33e3e34ecc83797fbadf2cac45120805600a","after_hash":"29d5a604ae7c15ccf8f63659b69f66c0226ffa5c7a4678b2a64477bd11b78314","item_hash_version":2,"message":"Link the compatibility declaration files","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-15T15:46:20.767Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_remove","patch":[{"op":"remove","path":"/metadata/tests/1"},{"op":"replace","path":"/metadata/tests/0/note","value":"Bidirectional scope-classifier binding over 13 invocations. Non-vacuous: reverting either dry-run edge fails (verified 2026-08-15)."},{"op":"remove","path":"/metadata/tests/0/path"},{"op":"replace","path":"/metadata/tests/0/command","value":"npx tsx --test test/smoke.test.ts"},{"op":"add","path":"/metadata/tests/0/timeout_seconds","value":300},{"op":"add","path":"/metadata/tests/0/assert_stdout_contains","value":["preflight override is scoped to"]},{"op":"add","path":"/metadata/tests/0/assert_stdout_regex","value":["fail 0$"]},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T15:46:20.767Z"}],"before_hash":"29d5a604ae7c15ccf8f63659b69f66c0226ffa5c7a4678b2a64477bd11b78314","after_hash":"a63b70cbbc5a0f6c2c717335f48eddd616dea599a55f869a192ee443849a814a","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-15T15:46:21.215Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"npm test","scope":"project","timeout_seconds":300,"assert_stdout_regex":["fail 0$"],"note":"Asserts registerPreflight is a scoped object whose commands array equals the four command paths pm-linear owns (linear sync, linear import, linear export, linear-sync import) and that run is a function. Scope membership is bound to commandNeedsLinearAccess in BOTH directions over 13 invocations, so a command the classifier requires credentials for cannot be missing from the scope and a scoped command cannot lack a declared verdict. Note the scope is ownership, not gating: whether the gate runs for a given invocation depends on the options, since --atomic --dry-run reaches Linear while --dry-run alone and export --push --dry-run do not. Test title: preflight override is scoped to pm-linear s owned command paths"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T15:46:21.215Z"}],"before_hash":"a63b70cbbc5a0f6c2c717335f48eddd616dea599a55f869a192ee443849a814a","after_hash":"e992fe11b364ea4a82b889892744bf6368ea0ef93f12075b65898289f318f4bb","item_hash_version":2,"message":"Correct the stale acceptance-test note to the access-based contract","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} diff --git a/.agents/pm/history/pm-linear-ydo4.jsonl b/.agents/pm/history/pm-linear-ydo4.jsonl index db6e6ac..5a621eb 100644 --- a/.agents/pm/history/pm-linear-ydo4.jsonl +++ b/.agents/pm/history/pm-linear-ydo4.jsonl @@ -3,3 +3,5 @@ {"ts":"2026-08-14T06:44:20.339Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"70140e5dd5b320efbbb8d5d3","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:44:20.339Z"},{"op":"add","path":"/metadata/files","value":[{"path":"index.ts","scope":"project"}]}],"before_hash":"2713fbac94a22c05ea72ca7264f7335a319ae1fd0cc1271b6d0783b93ceefdec","after_hash":"864d030f69da6bf531cb6461c5b05419a6dd221125b94f84d4207bde58e5ed78","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} {"ts":"2026-08-14T06:44:20.853Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"70140e5dd5b320efbbb8d5d3","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"add","path":"/metadata/files/1","value":{"path":"test/smoke.test.ts","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:44:20.853Z"}],"before_hash":"864d030f69da6bf531cb6461c5b05419a6dd221125b94f84d4207bde58e5ed78","after_hash":"e754797f530ba2ea3f220b794d5fc96c9f06c8148040d1560b0d409d0ce54bb0","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} {"ts":"2026-08-14T06:44:21.393Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"70140e5dd5b320efbbb8d5d3","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-14T06:44:21.393Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-14T06:44:21.377Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-14T06:44:21.377Z"},{"op":"add","path":"/metadata/resolution","value":"linear-sync import added to the preflight scope and to commandMutatesLinear, and the legacy importer handler now calls assertPreflightOk."},{"op":"add","path":"/metadata/expected_result","value":"A mutating legacy linear-sync import refuses without credentials, exactly as linear import does."},{"op":"add","path":"/metadata/actual_result","value":"Scope, classifier and handler all cover the alias; a loop test asserts every scoped command is classified mutating so the lists cannot drift."},{"op":"add","path":"/metadata/close_reason","value":"completed"}],"before_hash":"e754797f530ba2ea3f220b794d5fc96c9f06c8148040d1560b0d409d0ce54bb0","after_hash":"6a489b99336c852ee06fd397bb030f2da87834470a11330ef81b251caa70a967","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-15T15:45:29.345Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/expected_result","value":"A linear-sync import that reaches Linear refuses without credentials, exactly as linear import does. With --dry-run alone it is offline and runs without a key; with --atomic --dry-run it fetches issues and so refuses without credentials."},{"op":"replace","path":"/metadata/resolution","value":"linear-sync import is in the preflight scope and in the access classifier, and the legacy importer handler calls assertPreflightOk so the sentinel becomes a clean USAGE error. Review round 2 renamed the classifier from commandMutatesLinear to commandNeedsLinearAccess and changed what it asks: the preflight guards credentials and reachability, so the question is whether an invocation REACHES Linear, not whether it mutates. That distinction changes two edges. linear export --push --dry-run writes nothing and makes no network call, so it no longer demands a key. linear sync, linear import and linear-sync import with --atomic --dry-run also write nothing but DO fetch issues to build the plan, so they now require the gate they previously skipped. A scoped command is therefore not always gated: the gate depends on the option combination."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T15:45:29.345Z"}],"before_hash":"6a489b99336c852ee06fd397bb030f2da87834470a11330ef81b251caa70a967","after_hash":"a13db0051e516793cbd275a806f832866eed2ce3a3338a3f61df0ef49df382bb","item_hash_version":2,"message":"Correct the record to the access-based classifier contract","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-15T15:45:29.801Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2f5451bfe1c87748ced6bd1e","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-15T15:45:29.801Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-08-15T15:45:29.801Z","author":"claude-agent","text":"Correction to this item's recorded contract (CodeRabbit, PR #67) — appended, not rewritten.\n\nThe resolution and expected-result recorded here describe `commandMutatesLinear` and mutation-only scope behaviour. Both are superseded by review round 2 on PR #67, which renamed the classifier to `commandNeedsLinearAccess` and changed the question it asks.\n\nThe preflight guards CREDENTIALS AND REACHABILITY, so the right question is whether an invocation reaches Linear, not whether it mutates it. Asking the mutation question was wrong at both `--dry-run` edges, in opposite directions:\n\n- `linear export --push --dry-run` writes nothing and makes no network call, yet the mutation test saw `--push` and demanded `LINEAR_API_KEY`, so a documented-offline preview failed on any host without one.\n- `linear sync`, `linear import` and `linear-sync import` with `--atomic --dry-run` also write nothing, but they are NOT offline: they fetch issues to build the plan they report. The mutation test saw `--dry-run` and skipped the gate, so a missing key surfaced as a raw fetch failure inside the sync core instead of the clean USAGE error.\n\nThe consequence for this item's own subject: being inside the preflight scope no longer implies being gated. `linear-sync import` is in scope, and whether the gate runs for a given invocation now depends on the option combination — offline with `--dry-run` alone, gated with `--atomic --dry-run` or with neither.\n\nThe `--dry-run` flag help and the README \"Offline vs live\" section both promised offline behaviour unconditionally and have been corrected in the same commit, since a user reading them would have been told a command needs no key when it does.\n"}]}],"before_hash":"a13db0051e516793cbd275a806f832866eed2ce3a3338a3f61df0ef49df382bb","after_hash":"fb7ccb3c2918aca3270533d4963af6e1db574c3b949f76ea2a5c1c26d51b6a53","item_hash_version":2,"message":"Record the access-classifier correction","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} diff --git a/.agents/pm/issues/pm-linear-hwol.toon b/.agents/pm/issues/pm-linear-hwol.toon index 7b7eb9b..c1a0b59 100644 --- a/.agents/pm/issues/pm-linear-hwol.toon +++ b/.agents/pm/issues/pm-linear-hwol.toon @@ -6,7 +6,7 @@ status: closed priority: 2 tags: [] created_at: "2026-08-14T06:22:39.772Z" -updated_at: "2026-08-15T14:59:28.509Z" +updated_at: "2026-08-15T15:46:21.215Z" closed_at: "2026-08-14T06:23:38.357Z" completed_at: "2026-08-14T06:23:38.357Z" author: pi-agent @@ -20,15 +20,16 @@ files[5]{path,scope}: package.json,project test/smoke.test.ts,project tests[2]: - - command: npm test - path: test/smoke.test.ts - scope: project - note: "Asserts registerPreflight is a scoped object whose commands array equals pm-linear's owned mutating command paths (linear sync, linear import, linear export) and run is a function. Test title: preflight override is scoped to pm-linear's owned command paths" - command: npx tsx --test test/smoke.test.ts scope: project timeout_seconds: 300 assert_stdout_contains[1]: preflight override is scoped to assert_stdout_regex[1]: fail 0$ note: "Bidirectional scope-classifier binding over 13 invocations. Non-vacuous: reverting either dry-run edge fails (verified 2026-08-15)." + - command: npm test + scope: project + timeout_seconds: 300 + assert_stdout_regex[1]: fail 0$ + note: "Asserts registerPreflight is a scoped object whose commands array equals the four command paths pm-linear owns (linear sync, linear import, linear export, linear-sync import) and that run is a function. Scope membership is bound to commandNeedsLinearAccess in BOTH directions over 13 invocations, so a command the classifier requires credentials for cannot be missing from the scope and a scoped command cannot lack a declared verdict. Note the scope is ownership, not gating: whether the gate runs for a given invocation depends on the options, since --atomic --dry-run reaches Linear while --dry-run alone and export --push --dry-run do not. Test title: preflight override is scoped to pm-linear s owned command paths" close_reason: completed body: "" diff --git a/.agents/pm/issues/pm-linear-ydo4.toon b/.agents/pm/issues/pm-linear-ydo4.toon index b526a4a..707c54a 100644 --- a/.agents/pm/issues/pm-linear-ydo4.toon +++ b/.agents/pm/issues/pm-linear-ydo4.toon @@ -6,13 +6,15 @@ status: closed priority: 2 tags: [] created_at: "2026-08-14T06:44:19.151Z" -updated_at: "2026-08-14T06:44:21.393Z" +updated_at: "2026-08-15T15:45:29.801Z" closed_at: "2026-08-14T06:44:21.377Z" completed_at: "2026-08-14T06:44:21.377Z" author: claude -resolution: "linear-sync import added to the preflight scope and to commandMutatesLinear, and the legacy importer handler now calls assertPreflightOk." -expected_result: "A mutating legacy linear-sync import refuses without credentials, exactly as linear import does." +resolution: "linear-sync import is in the preflight scope and in the access classifier, and the legacy importer handler calls assertPreflightOk so the sentinel becomes a clean USAGE error. Review round 2 renamed the classifier from commandMutatesLinear to commandNeedsLinearAccess and changed what it asks: the preflight guards credentials and reachability, so the question is whether an invocation REACHES Linear, not whether it mutates. That distinction changes two edges. linear export --push --dry-run writes nothing and makes no network call, so it no longer demands a key. linear sync, linear import and linear-sync import with --atomic --dry-run also write nothing but DO fetch issues to build the plan, so they now require the gate they previously skipped. A scoped command is therefore not always gated: the gate depends on the option combination." +expected_result: "A linear-sync import that reaches Linear refuses without credentials, exactly as linear import does. With --dry-run alone it is offline and runs without a key; with --atomic --dry-run it fetches issues and so refuses without credentials." actual_result: "Scope, classifier and handler all cover the alias; a loop test asserts every scoped command is classified mutating so the lists cannot drift." +comments[1]{created_at,author,text}: + "2026-08-15T15:45:29.801Z",claude-agent,"Correction to this item's recorded contract (CodeRabbit, PR #67) — appended, not rewritten.\n\nThe resolution and expected-result recorded here describe `commandMutatesLinear` and mutation-only scope behaviour. Both are superseded by review round 2 on PR #67, which renamed the classifier to `commandNeedsLinearAccess` and changed the question it asks.\n\nThe preflight guards CREDENTIALS AND REACHABILITY, so the right question is whether an invocation reaches Linear, not whether it mutates it. Asking the mutation question was wrong at both `--dry-run` edges, in opposite directions:\n\n- `linear export --push --dry-run` writes nothing and makes no network call, yet the mutation test saw `--push` and demanded `LINEAR_API_KEY`, so a documented-offline preview failed on any host without one.\n- `linear sync`, `linear import` and `linear-sync import` with `--atomic --dry-run` also write nothing, but they are NOT offline: they fetch issues to build the plan they report. The mutation test saw `--dry-run` and skipped the gate, so a missing key surfaced as a raw fetch failure inside the sync core instead of the clean USAGE error.\n\nThe consequence for this item's own subject: being inside the preflight scope no longer implies being gated. `linear-sync import` is in scope, and whether the gate runs for a given invocation now depends on the option combination — offline with `--dry-run` alone, gated with `--atomic --dry-run` or with neither.\n\nThe `--dry-run` flag help and the README \"Offline vs live\" section both promised offline behaviour unconditionally and have been corrected in the same commit, since a user reading them would have been told a command needs no key when it does.\n" files[2]{path,scope}: index.ts,project test/smoke.test.ts,project diff --git a/README.md b/README.md index 8f9e5dc..ccab1c6 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,23 @@ Fetches issues from a Linear team and upserts them as pm items, keeping identifi | `importers` | `pm linear import` (+ legacy `linear-sync` importer) | | `importers` (exporter) | `pm linear export` | | `schema` | `linear_id`, `linear_url` item fields + command/importer/exporter flags | -| `preflight` | credential + reachability guard for mutating commands | - -> **Offline vs live.** Every `--dry-run` path (import **and** export) is fully -> **offline** — it builds and prints the exact GraphQL request/variables (import) -> or the would-be `issueCreate`/`issueUpdate` mutations (export) and makes **no** -> network call. Only the real (non-dry-run) `sync`/`import`, `export --push`, and -> `validate --check-network` reach the Linear API and require a live -> `LINEAR_API_KEY`. +| `preflight` | credential + reachability guard for commands that reach Linear | + +> **Offline vs live.** `--dry-run` never writes to Linear, but "no writes" and +> "no network" are not the same thing: +> +> - **Offline** (no `LINEAR_API_KEY` needed): `sync`/`import`/`linear-sync import` +> with `--dry-run` alone, and `export --dry-run` with or without `--push`. These +> build and print the exact GraphQL request/variables, or the would-be +> `issueCreate`/`issueUpdate` mutations, and make **no** network call. +> - **Reaches Linear** (live `LINEAR_API_KEY` required): the real (non-dry-run) +> `sync`/`import`, `export --push`, `validate --check-network`, and +> `sync`/`import` with **`--atomic --dry-run`** — the atomic preview fetches +> issues in order to build the plan it reports, so it needs credentials even +> though it writes nothing. +> +> The preflight gate follows exactly that split: it guards commands that *reach* +> Linear, not commands that *mutate* it. --- @@ -92,7 +101,7 @@ pm linear sync --team [options] | `--map` | string | — | Remap Linear→pm fields, e.g. `"identifier=ignore,priority=ignore"` | | `--project-map` | string | — | Tag items by Linear project name (additive). Bare flag tags with the verbatim name; `"Mobile=mobile,Web=web"` remaps | | `--limit` | number | `100` | Max issues to fetch | -| `--dry-run` | boolean | `false` | **Offline** — print the exact GraphQL request, no network/writes | +| `--dry-run` | boolean | `false` | Print the exact GraphQL request, no writes. **Offline** on its own; with `--atomic` it fetches issues to build the plan and needs `LINEAR_API_KEY` | | `--skip-preflight-network` | boolean | `false` | Skip the preflight reachability probe (offline/CI) | #### `--map` field remapping diff --git a/index.ts b/index.ts index 6d861fc..3b25b39 100644 --- a/index.ts +++ b/index.ts @@ -2556,8 +2556,12 @@ export default defineExtension({ activate(api: ExtensionApi) { // ----------------------------------------------------------------------- - // preflight — validate credentials + reachability before any mutating - // Linear command runs, scoped to the command paths pm-linear owns so it + // preflight — validate credentials + reachability before any Linear command + // that REACHES Linear runs. Note "reaches", not "mutates": the gate protects + // the network call, so `--atomic --dry-run` needs it (it fetches issues to + // build the plan) while `export --push --dry-run` does not (it writes and + // fetches nothing). See commandNeedsLinearAccess. Scoped to the command + // paths pm-linear owns so it // cannot contend with another package's preflight override (an unscoped / // global override collides pairwise with every other installed package's // override; pm health reports extension_preflight_override_collision). On @@ -2612,7 +2616,7 @@ export default defineExtension({ { long: "--project-map", value_name: "map", description: "Tag items by Linear project name. Bare flag tags with the verbatim name; \"Mobile=mobile,Web=web\" remaps. Optional." }, { long: "--limit", value_name: "n", description: "Maximum number of issues to fetch (default: 100)" }, { long: "--atomic", description: "Commit the complete sync as one workspace-writer-locked, crash-resumable transaction (pm-cli >=2026.7.20); compensate applied mutations on failure and report incomplete compensation" }, - { long: "--dry-run", description: "Preview the exact GraphQL request without any network call or writes" }, + { long: "--dry-run", description: "Preview the exact GraphQL request without writes. Offline on its own; with --atomic it fetches issues to build the plan, so it needs LINEAR_API_KEY" }, { long: "--skip-preflight-network", description: "Skip the preflight reachability probe (offline/CI)" }, ], @@ -2769,7 +2773,7 @@ export default defineExtension({ { long: "--project-map", value_name: "map", description: "Tag items by Linear project name (bare flag = verbatim; \"Mobile=mobile\" remaps)." }, { long: "--limit", value_name: "n", description: "Maximum number of issues to fetch (default: 100)." }, { long: "--atomic", description: "Commit the complete import as one workspace-writer-locked, crash-resumable transaction (pm-cli >=2026.7.20); compensate applied mutations on failure and report incomplete compensation" }, - { long: "--dry-run", description: "Print the exact GraphQL request offline (no network, no writes)." }, + { long: "--dry-run", description: "Print the exact GraphQL request without writes. Offline on its own; with --atomic it fetches issues to build the plan, so it needs LINEAR_API_KEY." }, ]); api.registerFlags("linear export", [ { long: "--push", description: "Create/update the issues in Linear (requires LINEAR_API_KEY + --team)." },