@mgiles/perk 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { existsSync, readFileSync } from "node:fs";
14
14
  import { parseUserBindings, type SkillBinding } from "./bindings.ts";
15
+ import { mainCheckoutRoot } from "./git.ts";
15
16
  import { configFile, localConfigFile } from "./paths.ts";
16
17
 
17
18
  /**
@@ -344,14 +345,17 @@ export const GITHUB_ISSUE_BACKEND_ID: IssueBackendId = "github";
344
345
  *
345
346
  * Reads ONLY committed `.perk/config.toml` — deliberately not `loadPerkConfig`'s overlay, mirroring
346
347
  * the Python committed-only read (the backend decides where canonical durable state is written;
347
- * a per-user `.perk/local.toml` override would fragment the canonical store). Python
348
- * (`perk/backends/issues.py::resolve_issue_backend_id`) is the AUTHORITATIVE validator and **raises** on
349
- * "linear"/unknown; this mirror is fail-safe (absence/unknown/any error `"github"`) because
350
- * the TS plane only renders prompts it never writes canonical issues.
348
+ * a per-user `.perk/local.toml` override would fragment the canonical store). The read is
349
+ * anchored to the MAIN checkout via `mainCheckoutRoot` (fail-open: `cwd` outside a git repo),
350
+ * mirroring Python's main-worktree anchoring a linked worktree's checkout state (detached /
351
+ * stale branch / missing `.perk/`) must never flip a Linear repo's prompt clauses to GitHub.
352
+ * Python (`perk/backends/resolve.py::resolve_issue_backend_id`) is the AUTHORITATIVE validator and
353
+ * **raises** on unknown values; this mirror is fail-safe (absence/unknown/any error → `"github"`)
354
+ * because the TS plane only renders prompts — it never writes canonical issues.
351
355
  */
352
356
  export function resolveIssueBackendId(cwd: string): IssueBackendId {
353
357
  try {
354
- const committed = readTomlFile(configFile(cwd));
358
+ const committed = readTomlFile(configFile(mainCheckoutRoot(cwd)));
355
359
  const backend = committed.tables.issues?.backend;
356
360
  if (backend === "github" || backend === "linear") return backend;
357
361
  return GITHUB_ISSUE_BACKEND_ID;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mgiles/perk",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "perk Pi extension (session interior) for the plan-oriented workflow.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1130,7 +1130,10 @@ validate_pr_body(body, *, pr_number) -> string[] (empty == vali
1130
1130
 
1131
1131
  **Plan-header block (the queryable metadata in the issue *body*).** The minimal
1132
1132
  observably-distinct set; rendered as a `perk:metadata-block:plan-header` collapsible YAML
1133
- block; the full plan markdown lives in the `plan-body` first comment:
1133
+ block; the full plan markdown lives in the `plan-body` first comment. **Carrier is
1134
+ backend-owned:** GitHub renders it in the issue body; **Linear stores the same fields as a
1135
+ native issue-attachment envelope** (the §8.24 native-attachment metadata amendment) — the field
1136
+ set below is the cross-backend contract either way:
1134
1137
 
1135
1138
  ```
1136
1139
  { run_id: string, # the §8.2 run that created the plan (idempotency key)
@@ -2928,7 +2931,13 @@ team = "ENG" # the Linear team key — required when backend = "linear"
2928
2931
  `load_committed_issues_backend` / `load_committed_issues_team`; TS: `resolveIssueBackendId` reads
2929
2932
  only the committed file). Rationale: the backend decides where canonical durable state
2930
2933
  (plan/learn/objective issues) is *written*; a per-user override would fragment the canonical
2931
- store. **`LINEAR_API_KEY` lives in the environment or the gitignored `.perk/local.toml`
2934
+ store. **Main-checkout anchored, both planes.** Both planes resolve the read root to the **main
2935
+ checkout** (git-common-dir resolution — Python `git.main_worktree_root(repo_root) or repo_root`,
2936
+ TS `mainCheckoutRoot(cwd)`; both fall back to the invocation root outside a git repo), so a
2937
+ linked worktree's checkout state (detached HEAD, a stale branch, a checkout without `.perk/`)
2938
+ can never change where canonical durable state is written. Deliberate consequence: a plan branch
2939
+ that *edits* `[issues]` does not take effect from inside its own worktree — the canonical-store
2940
+ selection must not fork mid-plan; it switches when the edit reaches the main checkout. **`LINEAR_API_KEY` lives in the environment or the gitignored `.perk/local.toml`
2932
2941
  `[linear] api_key`** (an exported env var wins over the config) — **never** in a committed file.
2933
2942
  The config read is local-file-only (`config.load_local_linear_api_key`, the inverse of the
2934
2943
  `load_committed_*` readers; fail-soft on malformed TOML — returns `None`, never raised). Two seams
@@ -3543,6 +3552,71 @@ actor is the human user; these changes make perk's footprint read as native:
3543
3552
  round-trip is proven lossless (else dropped). Becoming a true Linear **Agent** (`actor=app`) is a
3544
3553
  separate, out-of-scope follow-up.
3545
3554
 
3555
+ **Native-attachment metadata amendment (#1355) — Linear perk metadata rides issue attachments.**
3556
+ Linear-only (**GitHub renders are byte-identical**; the issue-tier protocol reshape below is the
3557
+ one cross-backend change). The five machine metadata blocks — `plan-header`, `learn-header`,
3558
+ `objective-node` (issue-scoped) and `objective-header`, `objective-manifest` (project-scoped) —
3559
+ **no longer render into Linear bodies at all**: each rides a native issue **attachment** with a
3560
+ machine-readable `metadata` envelope. Bodies/overviews are clean human prose. This supersedes the
3561
+ prose-first-composition bullet above (there are no machine blocks left to position), the
3562
+ collapsed-toggle deferral (moot), `_insert_or_replace_manifest` (deleted), and every
3563
+ list-and-parse find scan. A **clean break**: no legacy read fallback — pre-existing Linear
3564
+ artifacts with body-block metadata are simply not found (re-save/re-create them). Still inline in
3565
+ bodies (structural sentinels, not metadata): the `plan-body`/marked-comment markers, the
3566
+ Reconcilable region markers, the `Adopted-from` archive note, and the copyable command callouts.
3567
+
3568
+ - **The envelope** (`perk/backends/linear/attachments.py::encode`): `attachmentCreate` with
3569
+ `metadata: { source: "perk", schema_version: 1, kind: <block key>, payload_json: <JSON fields>,
3570
+ created, title, attributes: {…} }` — `payload_json` is the authoritative field payload (the
3571
+ same fields the body blocks carried); `attributes` duplicates scalars for Linear-side
3572
+ filterability. Cards render human-readable (title = the block kind, subtitle = a salient
3573
+ field). Decode is `find_perk_attachment(nodes, kind=)` — absent → `None` (tolerant),
3574
+ present-but-malformed → raises (fail-loud); `has_perk_attachment` is the presence-only check.
3575
+ - **The URL scheme is the identity** (live-verified: Linear accepts non-resolving URLs, and
3576
+ `attachmentCreate` **upserts by `(url, issueId)` with REPLACE metadata semantics** — every
3577
+ write must carry the complete envelope): `https://perk.invalid/plan/<run_id-or-identifier>`,
3578
+ `/learn/<run_id-or-identifier>`, `/node/<issue identifier>` (carry-path stable),
3579
+ `/objective/<run_id>`, `/manifest/<run_id>`. Writers always **reuse a found attachment's URL**
3580
+ (never re-derive — re-deriving would orphan the existing card).
3581
+ - **O(1) finds via `attachmentsForURL`** (`find_issue_by_attachment_url`): `find_plan_issue` /
3582
+ `find_learn_issue` / `find_objective` are each ONE workspace-wide exact-URL query (no
3583
+ team-scoped label scans). **Open-only parity rule:** the issue-tier finds treat a hit in a
3584
+ terminal state (`completed`/`canceled`) as not-found — parity with the legacy open-only scan,
3585
+ so a landed plan's run_id never resurrects the closed issue. On **multiple** hits (a landed
3586
+ plan's closed issue + an open re-save sharing the URL) the find prefers the first
3587
+ **non-terminal** hit, so the parity filter is deterministic — never at the mercy of the
3588
+ server's node order. The objective find is
3589
+ state-independent by design (its sentinel is born canceled) and takes the project ref from the
3590
+ hit issue's `project` (a header hit with no project raises — a broken sentinel).
3591
+ - **The project metadata sentinel.** Linear exposes no project-attachment mutation, so each
3592
+ perk project carries one **sentinel issue** (`Perk: objective metadata`, empty body, born in
3593
+ the team's canceled state — cosmetic; created **fail-loud** immediately after `projectCreate`,
3594
+ before milestones/node-issues) holding the `objective-header` + `objective-manifest`
3595
+ attachments. Discovery keys on the header **attachment**, never the title. One best-effort
3596
+ `entityExternalLinkCreate` adds it to the project's Resources (fail-open). Readers find it in
3597
+ the same `project_issues` scan they already run (zero extra queries); it is excluded from
3598
+ roadmap reads, engagement, and adoption candidate maps. `update_objective_header` /
3599
+ manifest syncs (`_sync_manifest_*`, `_refresh_manifest_phase_pins`, the drift backfill) are
3600
+ merge-and-upsert against the sentinel's attachments; a sentinel-less project is **not a perk
3601
+ objective** (`get_objective → None`).
3602
+ - **Node-issues + unified plans.** The `objective-node` payload rides a `/node/<identifier>`
3603
+ attachment (descriptions are clean prose); a unified node-issue carries TWO envelopes — node +
3604
+ plan — disambiguated by `kind`. The node→plan backlink derivation is unchanged but now keys on
3605
+ the **plan-header attachment's presence**. Attachments cascade-delete with their issue.
3606
+ - **Accepted create window (issue tier).** Every Linear create is now two writes — `issueCreate`
3607
+ then the identity-carrying attachment upsert — so a crash between them orphans a header-less
3608
+ issue invisible to the URL finds (a retry mints a fresh one; the orphan is human-visible
3609
+ garbage to close). The same accepted one-round-trip window as the metadata sentinel's,
3610
+ now explicit for plan/learn/node creates too.
3611
+ - **The issue-tier protocol reshape (all backends).** `create_plan_issue(title, header_fields,
3612
+ run_id, dry_run)` replaces the pre-rendered `body` param — the backend owns the header carrier
3613
+ (GitHub renders the body block itself, byte-identical; Linear creates a clean empty body + the
3614
+ attachment). Two additive fields: `LearnIssueSummary.header: LearnHeader | None` (decoded
3615
+ backend-side; GitHub parses the body, Linear the attachment — degrade-to-None either way) and
3616
+ `AdoptableIssue.already_plan: bool` (backend-decided — GitHub `has_metadata_block(body,
3617
+ plan-header)`, Linear the plan-header attachment), consumed by `plan from`'s `already_a_plan`
3618
+ refusal.
3619
+
3546
3620
  ## §8.25 · The human-engagement read contract (Objective #682, Node 1.2)
3547
3621
 
3548
3622
  A backend-neutral **READ** surface for human engagement — comments, description edits, and
@@ -3806,14 +3880,17 @@ are verbatim human content". A normally-authored plan leaves it `None`.
3806
3880
  `plan-header` block additively into the issue **body** (human prose preserved verbatim, **title
3807
3881
  untouched**); (c) idempotently prepend the `perk impl <id>` callout above the body; (d) upsert
3808
3882
  the `plan-body` comment carrying the authored markdown. Returns `IssueRef(existed=True)`.
3809
- Idempotent on re-save; GitHub stamps HTML-encoded, Linear inline-code (Linear-safe).
3883
+ Idempotent on re-save; GitHub stamps the body block HTML-encoded; Linear upserts the
3884
+ plan-header **attachment** instead — the human body stays verbatim apart from the callout
3885
+ (the §8.24 native-attachment metadata amendment).
3810
3886
 
3811
3887
  **The cold door (`perk plan from <issue>`).** A dedicated launcher verb in the `plan` hybrid group
3812
3888
  (mirrors `replan`/`resume`; `from` is a valid Click command string). It performs every Linear/GitHub
3813
3889
  read up front (the read-only plan-mode session has no `gh`/Linear access), then re-launches the
3814
3890
  `plan` stage seeded to author a plan over the materialized source. It **refuses** when: the issue is
3815
3891
  not found (`adopt_not_found`), not OPEN (`adopt_not_open`), or already a perk plan
3816
- (`has_metadata_block(body, plan-header)` `already_a_plan`, hinting `perk plan replan <id>`).
3892
+ (`AdoptableIssue.already_plan` — backend-decided: GitHub the body block, Linear the plan-header
3893
+ attachment → `already_a_plan`, hinting `perk plan replan <id>`).
3817
3894
  Engagement is read fail-soft (`render_adopted_engagement` → `<untrusted_adopted_issue_engagement>`;
3818
3895
  `IssueBackendError` → omitted). The source is materialized to `scratch/adopt-<issue_id>.md` (title +
3819
3896
  body wrapped in `<untrusted_adopted_issue>` + the optional engagement block). A **fresh** `run_id`
@@ -3910,18 +3987,21 @@ reconcile. Mapped issues' titles/bodies are independently preserved verbatim by
3910
3987
  `perk objective plan <n>` callout prepended), backfill `objective_comment_id`. `adopt_map` is
3911
3988
  ignored (no child issues).
3912
3989
  - **Linear project-backed (full):** `_LinearProjectOps.project_issues_for_adoption` (a sibling of
3913
- `project_issues` selecting `title` too; the byte-stable `project_issues` left untouched).
3914
- `read_objective_source` → the project overview `content` + its issues. `adopt_source_as_objective`
3915
- composes the new overview preserving the original verbatim (`to_linear_markdown(`
3916
- Reconcilable(`<model prose>`) + `objective-header`(`adopted_from=source_id`) + `objective-manifest`
3917
- + `render_adopted_overview_note(<original overview>)` below the markers `)`), `update_project
3918
- _content` (in place, NOT `create_project`), prepends the callout; one milestone per phase via
3919
- `ensure_phase_milestone` seeded from `project_milestones` (de-dupe against existing); for each
3920
- node in `node_sort_key` order a **mapped** node stamps the `objective-node` block additively into
3921
- the existing issue (title/body verbatim, description PATCH + `perk:objective-node` label added +
3922
- phase-milestone attach), an **unmapped** node mints a fresh node-issue; blocking relations per
3923
- explicit `depends_on`. Raises on an `adopt_issue` id not in the project (fail-loud). Idempotent on
3924
- `run_id`.
3990
+ `project_issues` selecting `title` too; both selections now also carry the attachment nodes
3991
+ the §8.24 native-attachment metadata amendment).
3992
+ `read_objective_source` the project overview `content` + its issues (the metadata sentinel
3993
+ excluded). `adopt_source_as_objective` composes the new overview preserving the original
3994
+ verbatim (`to_linear_markdown(` Reconcilable(`<model prose>`) +
3995
+ `render_adopted_overview_note(<original overview>)` below the markers `)`), `update_project
3996
+ _content` (in place, NOT `create_project`), prepends the callout; the
3997
+ `objective-header`(`adopted_from=source_id`) + `objective-manifest` ride a fresh metadata
3998
+ sentinel's attachments (the §8.24 native-attachment metadata amendment); one milestone per
3999
+ phase via `ensure_phase_milestone` seeded from `project_milestones` (de-dupe against existing);
4000
+ for each node in `node_sort_key` order a **mapped** node upserts the `objective-node`
4001
+ attachment onto the existing issue (title/body verbatim — no description write — +
4002
+ `perk:objective-node` label added + phase-milestone attach), an **unmapped** node mints a fresh
4003
+ node-issue; blocking relations per explicit `depends_on`. Raises on an `adopt_issue` id not in
4004
+ the project (fail-loud). Idempotent on `run_id`.
3925
4005
  - **Issue-backed Linear (dormant):** both `read_objective_source` and `adopt_source_as_objective`
3926
4006
  return `None` (honest no-op; keeps `ty` green).
3927
4007