@zalom/plastic 1.4.1 → 1.6.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.
@@ -22,6 +22,8 @@ Violation tags (starter set, free-text tags allowed):
22
22
  - `broken-chain`: a chain frontmatter edge to an intent that no longer exists
23
23
  - `broken-source`: a sources frontmatter edge to an intent that no longer exists
24
24
  - `misplaced-content`: content that belongs in a different artifact or section
25
+ - `links-projection`: a tool-authored `## Links` regeneration (project-links; intent 197)
26
+ - `graph-rebuild`: a tool-authored sources/chain frontmatter rebuild (rebuild-graph; intent 197)
25
27
 
26
28
  ## Two Processes
27
29
 
package/PLASTIC.md CHANGED
@@ -565,26 +565,38 @@ open (the write proceeds, the claim yields) and the condition is surfaced in
565
565
  lock. See `plastic-lock claim`/`release-claim` and `docs/internals.md` for the
566
566
  full mechanism.
567
567
 
568
- Two locks share this schema (the two-lock doctrine): `delivery.lock` (exclusive, one owner
569
- plus delegates) and the future `maintenance.lock` (short TTL, structural move-and-record
570
- only). They are mutually exclusive in either direction; maintenance is allowed at any
571
- lifecycle stage provided no delivery lock is held. Intent 108 ships the delivery lock and
572
- the mutual-exclusion seam; the maintenance lock implementation follows intent 93 in a
573
- chained intent.
568
+ There is exactly one lock in Plastic: `delivery.lock` (exclusive, one owner plus delegates),
569
+ shipped by intent 108. An earlier two-lock doctrine proposed a second `maintenance.lock`
570
+ (short TTL, structural move-and-record only); intent 112 built it in full and was then
571
+ abandoned before merge on a design pivot, so nothing from it ever shipped (`lock.rb`'s
572
+ `TYPES` seam is the only trace left). Intent 197 rejects the second lock outright rather than
573
+ reviving it: a lock held by a maintenance session could be mistaken by a resuming session
574
+ for an active delivery. Maintenance instead DETECTS `delivery.lock`'s freshness
575
+ (`Lock.fresh?`) and defers when fresh; it never acquires any lock of its own and leaves none
576
+ behind. See "WORK vs MAINTENANCE" below for the full doctrine.
574
577
 
575
578
  Every code-touching intent gets its own git worktree named `{id}--{slug}`, and all code edits
576
579
  for that intent happen only inside it. Plastic provisions the worktree deterministically: it
577
580
  resolves the project repo from `projects.yml` and runs `git -C <repo> worktree add`, so
578
- isolation never depends on the current working directory. There are two worktrees per project
579
- intent: a code worktree at `<repo>/.claude/worktrees/{id}--{slug}` (branch `plastic/{id}--{slug}`)
580
- and a store worktree at `<plastic_home>/.worktrees/{id}--{slug}` (branch
581
- `plastic-store/{id}--{slug}`), so lifecycle-doc commits and code commits move as one unit.
581
+ isolation never depends on the current working directory. There is one worktree per project
582
+ intent, the code worktree at `<repo>/.claude/worktrees/{id}--{slug}` (branch
583
+ `plastic/{id}--{slug}`).
584
+
585
+ Plastic does not provision a second worktree for lifecycle-doc writes. Two things cover that
586
+ need instead. First, the harness's own native worktree: Claude Code manages its own code
587
+ worktree at `<repo>/.claude/worktrees/{name}`, and Codex manages its own at
588
+ `$CODEX_HOME/worktrees` (default `~/.codex/worktrees`); both exist on their own, independent of
589
+ anything Plastic provisions. Second, intent 197's branch-from-main plus scoped commit, which
590
+ gives store writes their own write safety without a dedicated worktree. Plastic tried a second,
591
+ dedicated store worktree at `<plastic_home>/.worktrees/{id}--{slug}` first; agents never wrote
592
+ into it, because every delivering agent writes lifecycle docs straight to the main store
593
+ checkout, so intent 178 retired the store worktree in favor of the two mechanisms above.
582
594
 
583
595
  Provisioning fails open for intents that touch no project code (pure research or decision
584
596
  intents in the global store, or a non-git repo): those get the lock only, and the worktree
585
597
  block stays unprovisioned. The fail-open path is always logged, never silent.
586
598
 
587
- Cleanup is part of Done: the End tail merges the branch, then removes both worktrees. Never leave
599
+ Cleanup is part of Done: the End tail merges the branch, then removes the worktree. Never leave
588
600
  an orphaned worktree behind, and clear a stale worktree reference with `git worktree prune`.
589
601
 
590
602
  ### Intent delivery, station by station
@@ -600,7 +612,7 @@ each station.
600
612
  | How | `plan.md`, `actions/ACTION_N.md` (at least one), `checklist.md` | heartbeat on writes; the code gate stays closed until plan.md, checklist.md, and a real action file all exist | gate-check requires spec.md before plan.md, and plan.md plus a real actions/ACTION_N.md before checklist.md | savepoint `How started`, `How plan.md created`, `How checklist.md created`, `Exec started` |
601
613
  | Exec | code on the intent branch, checklist checked off | heartbeat; code edits confined to the provisioned worktree; delegates write under the owner's lock; bash, interpreter, and MCP writes gated the same way | code-gate, worktree-gate, bash-gate, lock-gate | checklist boxes; savepoint milestones |
602
614
  | End (done) | mandatory `outcome.md` (`disposition: delivered\|abandoned`), INDEX moves to Completed or Abandoned | ordered End tail: verify, merge and remove worktrees, disarm clears `delivery.lock`, then the bridge is purge-eligible, and the QMD reindex runs LAST (after purge) | gate-check blocks outcome.md while checklist items are unchecked | savepoint `Done delivered` (or `abandoned`); takeover audits, if any, remain in savepoint.md |
603
- | Maintenance (any stage) | `revisions.md` move-and-record entries | future `maintenance.lock` (short TTL), mutually exclusive with `delivery.lock` in either direction; 108 ships the schema seam only, the implementation follows intent 93 in a chained intent | acquisition refuses while the other lock type is fresh; a terminal intent with no lock held is read-only | dated, rule-tagged `revisions.md` entry; savepoint untouched |
615
+ | Maintenance (Future, Terminal, or Active-with-a-stale-or-no-lock) | `revisions.md` move-and-record entries | detects (never acquires) `delivery.lock`; defers and reports while the target's lock is FRESH (`Lock.fresh?`); a stale or absent lock is not-active, maintenance proceeds | none enforced by any gate; the maintenance tool or skill itself checks `Lock.fresh?` (see WORK vs MAINTENANCE below) | append-only, rule-tagged `revisions.md` entry written in the same operation as the change, or the change is refused; lands via a fresh branch off store main merged back as one closed op, never `git add -A` |
604
616
 
605
617
  ### What "intent done" means (intent 93)
606
618
 
@@ -639,26 +651,113 @@ tail; `doctor` surfaces this as a "stalled completion" (terminal in INDEX but th
639
651
  still present or stale). Finishing the tail is FINISHING a completion, never a reactivation:
640
652
  a done intent is never moved back to `## Active`.
641
653
 
642
- Terminal immutability (the contract intent 112 enforces): a terminal directory is writable
643
- ONLY while a lock is held. The delivery lock covers the completing session's End tail up to
644
- `Lock.release`; the maintenance lock covers sanctioned structural move-and-record edits
645
- after. Terminal with no lock held is frozen. There are only two locks in the system,
646
- delivery and maintenance (108 D11). This governs WRITES only: reads of a terminal intent are
647
- always allowed and unbounded (curator reindex, dashboards, and future intents that reference
648
- its id or chain), so a done intent stays fully readable forever. Intent 93 states this rule;
649
- intent 112 builds the gate that enforces it.
654
+ ### WORK vs MAINTENANCE (intent 197)
655
+
656
+ Plastic separates two different things an earlier doctrine blurred under one word,
657
+ "immutable." WORK is the delivered CONTENT an intent produced: the code and project files a
658
+ delivery changed, the research it recorded, the outcome it wrote. Once the intent is terminal
659
+ (Completed or Abandoned), that content is immutable - the only way to change it is another
660
+ intent that continues or reverts it. Editing a Done intent's own artifacts so it looks like it
661
+ delivered something different, or that parts are missing, is forbidden (the book analogy:
662
+ never rewrite the text on the pages of an old, valuable book).
663
+
664
+ MAINTENANCE is everything else: structure, the sources/chain graph, a section that does not
665
+ belong in the file, formatting, and any store-wide operational change (a new Plastic version
666
+ adding or removing a frontmatter field across every intent). Maintenance is not immutable and
667
+ needs no owner gate to run, on the one condition below (recording is universal). The
668
+ decidable test is CONTENT vs METADATA, not "meaning vs structure": a graph edit is structure
669
+ even when it is also, in a loose sense, about lineage, because it does not change what the
670
+ intent delivered. Precedent: plastic intent 124's own `revisions.md` v1 dropped a dead chain
671
+ edge to a non-existent `124b` (`[rule: broken-chain]`), and v2 added a missing required
672
+ reciprocity edge to `131` (`[rule: misplaced-content]`), both ordinary maintenance, not
673
+ owner-gated exceptions. Allowed maintenance: (a) a frontmatter chain/sources edge that points
674
+ to a non-existent or wrong intent, or a missing required edge; (b) an extra non-convention
675
+ section in the intent file, removed and moved into `revisions.md`; (c) a store-wide
676
+ operational change from a new Plastic version, applied to every intent; (d) any other
677
+ structural or operational tidy. Forbidden: anything that alters what the work delivered.
678
+
679
+ The residual guard on every graph edit: it must move TOWARD ground truth (drop a dangling or
680
+ false edge, add a reciprocity-forced or documented-real one) and must never invent a
681
+ relationship - "might be related" is never a valid `[rule:]` reason. This is already implied
682
+ by the mandatory `[rule: tag]` on every `revisions.md` entry; no additional per-edit owner
683
+ gate is needed for an ordinary graph fix of this kind.
684
+
685
+ Maintenance normally needs no intent and no roadmap at all; it runs through the maintenance
686
+ tools and skills and records itself. The one exception: a batch touching more than about 5
687
+ different intents at once must stay rare, and is always an owner decision - the agent asks
688
+ first and shows the diff before proceeding. This exception governs rare cross-intent sweeps;
689
+ it does not apply to an ordinary single-intent graph repair.
690
+
691
+ Maintenance target-state eligibility, by the intent's own lifecycle state: a Future intent,
692
+ yes; a Terminal (Completed or Abandoned) intent, yes; an Active intent mid-delivery, WAIT. The
693
+ wait is keyed on whether the target currently holds a FRESH `delivery.lock` (`Lock.fresh?`),
694
+ never on INDEX `## Active` membership - `end-intent` releases the lock only after the INDEX
695
+ move and its commit tail finish, so keying on Active membership would miss that tail window
696
+ and let maintenance race a live completion. A STALE lock is not maintenance's problem to
697
+ resolve; it is treated as not-active, and maintenance proceeds rather than waiting
698
+ indefinitely behind a dead session.
699
+
700
+ There is exactly one lock in the system (see the two-lock correction above): `delivery.lock`,
701
+ meaning an active agent is delivering that intent. Maintenance DETECTS this lock and NEVER
702
+ ACQUIRES it, even transiently, because a maintenance-held lock could be mistaken by a resuming
703
+ or continuation session for an active delivery. Maintenance leaves no lock behind: there is
704
+ nothing to clean up afterward, and no ambiguity about who, if anyone, holds the one lock.
705
+ `bridge.rb:1195`'s `lock_gate_decision` already allows any write once an intent is not in
706
+ INDEX `## Active` - there is no enforced freeze gate in the codebase today, and there never
707
+ was one that shipped (see the corrected history below).
708
+
709
+ Stranding and clobbering are avoided by construction, not by a second lock: a maintenance
710
+ action creates a fresh branch from the CURRENT state of store main, applies only its own
711
+ scoped changes, and merges that branch back to main as part of the SAME closed operation.
712
+ Nothing strands on an unmerged branch; two concurrent maintenance runs reconcile as ordinary
713
+ merge conflicts on main, never silent loss. This is lighter than intent 178's full per-session
714
+ delivery worktrees (178 stays about the agent write paths for delivery); maintenance only
715
+ needs branch-from-main plus scoped merge-back (`scripts/lib/maintenance_git.rb`,
716
+ `scripts/maintenance-run`).
717
+
718
+ No commit anywhere, store or project repo, uses `git add -A`; every maintenance and delivery
719
+ commit stages only the paths it actually changed (`scripts/end-intent`'s `store_commit`,
720
+ `scripts/maintenance-run`).
721
+
722
+ The one condition on every maintenance action, with no exception, is that it is recorded.
723
+ Every maintenance action, whether run by a tool or made by hand, must leave an append-only
724
+ `revisions.md` entry on its target intent (`## Revision vN`, a `Why ... [rule: tag]` line, a
725
+ `Prior location`, and the change itself). If the file already exists, a new run appends
726
+ `vN+1`; it never overwrites an earlier entry (precedent: intent 124's `revisions.md` v3
727
+ corrects v2 by appending a correction entry and explicitly leaving v2 in place). This is
728
+ tool-enforced, not prose alone: `scripts/project-links`, `scripts/rebuild-graph`, and
729
+ `scripts/restore-intent-v1` each write this receipt in the SAME write as the structural
730
+ change, or refuse to proceed without one (`scripts/lib/revisions_writer.rb`); the intent
731
+ curator (`agents/plastic-intent-curator.md`) holds itself to the identical rule by hand.
732
+
733
+ Doctor stays a detector: core and full checks, every installed agent, both global and project
734
+ stores. It gains no write path of its own. The "Fix all" prompt
735
+ (`skills/doctor/SKILL.md`) is a ROUTER: for each fixable finding it dispatches to the tool
736
+ that already owns that class of repair (`project-links`, `rebuild-graph`,
737
+ `restore-intent-v1`, or the curator, via `scripts/maintenance-run` where applicable), and
738
+ those tools perform the mutation and write the `revisions.md` receipt - never doctor itself.
739
+
740
+ Corrected history (D18): an earlier version of this section described a terminal-immutability
741
+ gate "intent 112 enforces" and a two-lock model. Intent 112 built that gate in full and was
742
+ then ABANDONED before merge on a design pivot; nothing from it ever shipped. `bridge.rb:1195`
743
+ confirms no such gate runs today: a write to a terminal intent is allowed unconditionally once
744
+ the intent leaves INDEX `## Active`. The deadlock that stopped intents 189, 192, and 195 from
745
+ repairing three live `graph_links_projection` violations was self-imposed discipline (agents
746
+ and the owner both treating undocumented doctrine as a real gate), not a technical one. This
747
+ section is the corrected doctrine; intent 112's own history stays in INDEX as an abandoned,
748
+ superseded design.
650
749
 
651
750
  Restore-to-v1 (the owner rule that a completed intent is immutable: a late ruling goes to a
652
751
  new `--parent` branch intent, and the completed intent is restored to v1) is performed ONLY by
653
- `scripts/restore-intent-v1`, run under the maintenance lock. Its prose (the intent narrative,
654
- checklist.md, outcome.md, spec.md, plan.md) is immutable and reverts to v1; its frontmatter
655
- graph (`sources`/`chain`) is metadata about OTHER intents, not content of this one, and is
656
- APPEND-ONLY: it is preserved as the union of the v1 snapshot and the current snapshot, never
657
- subtracted. A hand-run whole-file `git checkout`/revert of a completed intent is FORBIDDEN,
658
- because it cannot distinguish prose from graph metadata and silently destroys backlinks written
659
- after v1 (proven on intent 124: a legitimately accrued chain edge was destroyed by a hand-run
660
- restore and went undetected for a week). This governs the restore mechanism only; it does not
661
- loosen terminal immutability itself.
752
+ `scripts/restore-intent-v1`. Its prose (the intent narrative, `checklist.md`, `outcome.md`,
753
+ `spec.md`, `plan.md`) is immutable and reverts to v1; its frontmatter graph
754
+ (`sources`/`chain`) is metadata about OTHER intents, not content of this one, and is
755
+ APPEND-ONLY: preserved as the union of the v1 snapshot and the current snapshot, never
756
+ subtracted. It writes its own `revisions.md` receipt in the same run. A hand-run whole-file
757
+ `git checkout`/revert of a completed intent is FORBIDDEN, because it cannot distinguish prose
758
+ from graph metadata and silently destroys backlinks written after v1 (proven on intent 124: a
759
+ legitimately accrued chain edge was destroyed by a hand-run restore and went undetected for a
760
+ week).
662
761
 
663
762
  Fail-safe lock doctrine (the contract intent 111 implements): the lock system never traps a
664
763
  session or burns credits. When a gate cannot verify lock integrity it fails open, degrading
@@ -674,5 +773,7 @@ plus `disposition` header at both terminals, the End tail with the reindex moved
674
773
  `done_signals` doctor check (three-signal agreement plus stalled-completion detection), and
675
774
  the lock-bounded post-done window with its keep-guard test. Intent 111 owns the lock
676
775
  liveness surface, the lock-issue message, orchestrator auto-repair, and the fail-open
677
- behavior itself. Intent 112 owns the maintenance lock and the immutability gate (it inherits
678
- fail-open from 111). Intent 4a1b1 owns deep agent stuck-detection and is not superseded.
776
+ behavior itself. Intent 112 attempted a maintenance lock and an immutability gate; it was
777
+ abandoned before merge and superseded by intent 197's WORK vs MAINTENANCE doctrine
778
+ (detect-only lock, branch-and-merge, tool-enforced `revisions.md`). Intent 4a1b1 owns deep
779
+ agent stuck-detection and is not superseded.
@@ -27,8 +27,16 @@ You are the Plastic Intent Curator. Your role is to maintain the health and navi
27
27
  5. On a terminal-state transition (Completed OR Abandoned), do these things:
28
28
  a. Author a real `outcome.md` in the intent directory from `~/.plastic/templates/outcome.md`, with the frontmatter `disposition: delivered` for a completed intent or `disposition: abandoned` for an abandoned one. `outcome.md` is MANDATORY at every terminal, delivered and abandoned alike: on abandon it records the abandonment reason and replaces the scaffolded placeholder sentinel (never leave `outcome.md` a placeholder at a terminal).
29
29
  b. Call `plastic-intent-ending` for the terminal-transition close (INDEX move, savepoint `Done` bookend, store commit, disarm, and the QMD reindex last): `ruby ~/.plastic/scripts/end-intent --store <store> --id <id> --disposition delivered|abandoned`, then follow that skill's own disarm and reindex steps. Never restate the INDEX/savepoint/reindex one-liners here.
30
- 6. Structural maintenance is move-and-record: remove the misplaced section, file, or ref from its artifact, then create or append `revisions.md` in that intent directory (copy the FORM from `~/.plastic/templates/revisions.md`). One entry per relocated item, newest at the bottom: a `## Revision vN - YYYY-MM-DD-HH:MM` header, a one-sentence `Why` ending with `[rule: <tag>]`, `Prior location`, and either `Content held` (verbatim) or a one-line `Change` for a frontmatter edit. For a stray file, embed its full content and delete the original. The violation-tag catalog is canonical in PLASTIC.md.
31
- 7. Report what you changed
30
+ 6. Structural maintenance is move-and-record, and it is NEVER done without its receipt: remove the misplaced section, file, or ref from its artifact, then create or append `revisions.md` in that intent directory (copy the FORM from `~/.plastic/templates/revisions.md`) IN THE SAME PASS as the edit. If you cannot write `revisions.md` for any reason (permissions, a read-only path), you MUST NOT make the structural edit either - report the blocker instead of leaving an unrecorded change (this mirrors the tool-side rule: project-links, rebuild-graph, and restore-intent-v1 refuse rather than write a change with no receipt; you hold yourself to the same rule by hand). One entry per relocated item, newest at the bottom: a `## Revision vN - YYYY-MM-DD-HH:MM` header, a one-sentence `Why` ending with `[rule: <tag>]`, `Prior location`, and either `Content held` (verbatim) or a one-line `Change` for a frontmatter edit. For a stray file, embed its full content and delete the original. The violation-tag catalog is canonical in PLASTIC.md. A graph edit must move TOWARD ground truth (drop a dangling/false edge, add a reciprocity-forced or documented-real one) and must NEVER invent a relationship - "might be related" is never a valid `[rule:]` reason (PLASTIC.md > WORK vs MAINTENANCE).
31
+ 7. Before performing structural maintenance on ANY intent that is NOT the one your own session is currently delivering under its own held delivery lock, you must:
32
+ a. Check the target's lock freshness: `ruby ~/.plastic/scripts/plastic-lock status --intent-dir <target-intent-dir>` and read the `lock_fresh` field of its JSON output. If `true`, DEFER: make no edit to that intent, and report it as skipped (an active delivery is in progress). This is DETECT-ONLY - you never acquire, create, or hold any lock of your own for maintenance (PLASTIC.md > WORK vs MAINTENANCE; there is exactly one lock in Plastic, the delivery lock).
33
+ b. Require a clean store working tree before starting: `git -C ~/.plastic status --porcelain` (or the project store's own root, if not global) must be empty. If it is not, STOP and report the dirty paths rather than risk sweeping an unrelated concurrent change into your own commit; do not proceed until the tree is clean.
34
+ c. Create a fresh branch from the current tip of that repo's main: `git -C <repo-root> checkout -b maintenance/curator-<UTC-timestamp> main`.
35
+ d. Make the scoped edit plus its `revisions.md` receipt (step 6 above), touching nothing else.
36
+ e. Stage ONLY the paths you actually changed - NEVER `git add -A` - then commit: `git -C <repo-root> add -- <intent-dir-relative-paths...> && git -C <repo-root> commit -m "..."`.
37
+ f. Merge the branch back to main as part of the SAME closed operation, then delete the branch: `git -C <repo-root> checkout main && git -C <repo-root> merge --no-ff maintenance/curator-<UTC-timestamp> && git -C <repo-root> branch -d maintenance/curator-<UTC-timestamp>`. Never leave the change stranded on an unmerged branch.
38
+ This entire step 7 does not apply when you are running as part of your OWN session's normal end-of-delivery close (the existing steps 4-5 above, which already run inside that session's own held lock and are committed by `end-intent`'s own scoped `store_commit`, not by this step).
39
+ 8. Report what you changed
32
40
 
33
41
  ## Constraints
34
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.rb CHANGED
@@ -14,6 +14,7 @@ require "yaml"
14
14
  require "time"
15
15
  require "date"
16
16
  require "digest"
17
+ require "rubygems"
17
18
 
18
19
  require_relative "lib/qmd_sync"
19
20
  require_relative "lib/intent_validator"
@@ -52,18 +53,14 @@ class Doctor
52
53
  # never drift.
53
54
  LEGACY_BOOKEND_AMNESTY = LegacyBookendAmnesty::LIST
54
55
 
55
- CLAUDE_HOOK_SCRIPTS = %w[
56
- plastic-session-start
57
- plastic-check-update
58
- plastic-savepoint
59
- plastic-gate-check
60
- plastic-continue
61
- plastic-future-intent-check
62
- plastic-qmd-search
63
- ].freeze
64
-
65
56
  CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
66
57
 
58
+ # Launchers the installer places in the agent's hooks dir that are NOT hooks
59
+ # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
60
+ # outside HookRegistry.events entirely, so it must be excluded from the
61
+ # orphan-launcher scan below or a correct install would report a false orphan.
62
+ CLAUDE_NON_HOOK_LAUNCHERS = %w[plastic-statusline].freeze
63
+
67
64
  REQUIRED_SCRIPTS = %w[
68
65
  folgezettel-id
69
66
  read-config
@@ -75,13 +72,32 @@ class Doctor
75
72
  doctor.rb
76
73
  ].freeze
77
74
 
75
+ # The apply_patch PreToolUse veto only fires from Codex v0.123.0 onward
76
+ # (PR #18391, "emit hooks for apply_patch edits"). Below it the veto
77
+ # silently no-ops (intent 184).
78
+ CODEX_HOOKS_FLOOR = "0.123.0"
79
+
78
80
  attr_reader :plastic_home, :agents
79
81
 
82
+ # Testable shell-out default, mirroring QmdSync.default_runner: a real
83
+ # Open3.capture3 call in production, swappable for a fake in tests so no
84
+ # test needs a real codex binary or a PATH mutation.
85
+ def self.default_runner
86
+ lambda do |args|
87
+ require "open3"
88
+ out, _err, status = Open3.capture3("codex", *args)
89
+ [out, status.success?]
90
+ rescue Errno::ENOENT
91
+ ["", false] # codex not on PATH: undetectable, fail open
92
+ end
93
+ end
94
+
80
95
  def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS,
81
- bookend_amnesty: LEGACY_BOOKEND_AMNESTY)
96
+ bookend_amnesty: LEGACY_BOOKEND_AMNESTY, runner: Doctor.default_runner)
82
97
  @plastic_home = plastic_home
83
98
  @agents = agents
84
99
  @bookend_amnesty = bookend_amnesty
100
+ @runner = runner
85
101
  end
86
102
 
87
103
  # --- Flag parsing ---
@@ -815,7 +831,11 @@ class Doctor
815
831
  "it PRESERVES any line unbacked by frontmatter that still resolves to a real intent " \
816
832
  "(reported as an orphan candidate, never silently deleted); add the missing " \
817
833
  "sources/chain edge if the relationship is real, or pass --drop-unbacked-links to " \
818
- "delete an orphan candidate deliberately."
834
+ "delete an orphan candidate deliberately. Prefer `ruby scripts/maintenance-run --tool " \
835
+ "project-links --intent <id> --apply` over running project-links directly: it detects " \
836
+ "(never acquires) the target's delivery lock, requires a clean store working tree, and " \
837
+ "commits the scoped change plus its revisions.md receipt as one merged operation " \
838
+ "(PLASTIC.md > WORK vs MAINTENANCE)."
819
839
  )
820
840
  end
821
841
 
@@ -1003,13 +1023,17 @@ class Doctor
1003
1023
  checks = []
1004
1024
  hooks_dir = File.join(agent_dir, "hooks")
1005
1025
 
1026
+ # Derived from HookRegistry.events (intent 204), not a hand-kept list, so
1027
+ # every registered hook (all 15, including the enforcement gates) is checked.
1028
+ expected_launchers = HookRegistry.claude_launcher_names
1029
+
1006
1030
  # hooks_exist
1007
- missing_hooks = CLAUDE_HOOK_SCRIPTS.reject { |h| File.exist?(File.join(hooks_dir, h)) }
1031
+ missing_hooks = expected_launchers.reject { |h| File.exist?(File.join(hooks_dir, h)) }
1008
1032
 
1009
1033
  if missing_hooks.empty?
1010
1034
  checks << check(
1011
1035
  category: "agent_registration", name: "hooks_exist", status: "pass",
1012
- message: "All #{CLAUDE_HOOK_SCRIPTS.size} expected hook scripts exist"
1036
+ message: "All #{expected_launchers.size} expected hook scripts exist"
1013
1037
  )
1014
1038
  else
1015
1039
  checks << check(
@@ -1021,7 +1045,7 @@ class Doctor
1021
1045
  end
1022
1046
 
1023
1047
  # hooks_executable
1024
- existing_hooks = CLAUDE_HOOK_SCRIPTS
1048
+ existing_hooks = expected_launchers
1025
1049
  .map { |h| File.join(hooks_dir, h) }
1026
1050
  .select { |p| File.exist?(p) }
1027
1051
 
@@ -1041,6 +1065,29 @@ class Doctor
1041
1065
  )
1042
1066
  end
1043
1067
 
1068
+ # hooks_no_orphans: the mirror of hooks_exist. A plastic-* launcher on disk
1069
+ # that HookRegistry does not know about is dead code the next reader would
1070
+ # trust as live (the same drift class as a missing launcher, just facing
1071
+ # the other way). plastic-statusline is a legitimate non-hook installer
1072
+ # artifact (see CLAUDE_NON_HOOK_LAUNCHERS) and is excluded here.
1073
+ present_launchers = Dir.glob(File.join(hooks_dir, "plastic-*")).map { |p| File.basename(p) }
1074
+ orphans = (present_launchers - expected_launchers - CLAUDE_NON_HOOK_LAUNCHERS).sort
1075
+
1076
+ if orphans.empty?
1077
+ checks << check(
1078
+ category: "agent_registration", name: "hooks_no_orphans", status: "pass",
1079
+ message: "No orphaned hook launchers in #{tilde(hooks_dir)}"
1080
+ )
1081
+ else
1082
+ checks << check(
1083
+ category: "agent_registration", name: "hooks_no_orphans", status: "warn",
1084
+ message: "#{orphans.size} hook launcher(s) on disk are not registered in HookRegistry",
1085
+ details: orphans.map { |h| "#{tilde(hooks_dir)}/#{h}" },
1086
+ fixable: true,
1087
+ fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes stale launchers)"
1088
+ )
1089
+ end
1090
+
1044
1091
  # hooks_registered — settings.json has Plastic hooks for required events
1045
1092
  settings_path = File.join(agent_dir, "settings.json")
1046
1093
  settings = read_json_safe(settings_path)
@@ -1273,6 +1320,7 @@ class Doctor
1273
1320
  checks << codex_hooks_implemented_check(config)
1274
1321
  checks << codex_hook_trust_advisory_check if hooks_check[:status] == "pass"
1275
1322
  codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
1323
+ codex_version_floor_check(config).tap { |c| checks << c if c }
1276
1324
 
1277
1325
  checks
1278
1326
  end
@@ -1528,6 +1576,60 @@ class Doctor
1528
1576
  )
1529
1577
  end
1530
1578
 
1579
+ # Codex version-floor advisory (intent 184): READ ONLY. The apply_patch PreToolUse
1580
+ # veto (scripts/lib/hook_registry.rb, scripts/codex-hook) only fires from Codex
1581
+ # v0.123.0 (PR #18391); below it the veto silently no-ops. version.json is Codex's
1582
+ # update-checker cache and holds no installed version, so the only install-method-
1583
+ # agnostic source is `codex --version`, shelled out through the injected runner.
1584
+ # Four honest branches (intent 208, no pass-by-construction): absent home -> nil;
1585
+ # present but undetectable -> distinct warn; below floor -> warn; at/above -> pass.
1586
+ def codex_version_floor_check(config)
1587
+ return nil unless File.exist?(config[:home_dir])
1588
+
1589
+ stdout, ok = @runner.call(["--version"])
1590
+ version = ok ? codex_version_from_output(stdout) : nil
1591
+ parsed = version && safe_version(version)
1592
+
1593
+ if parsed.nil?
1594
+ return check(
1595
+ category: "agent_registration", name: "codex_version_floor", status: "warn",
1596
+ message: "Could not determine the installed Codex version (`codex --version` did not " \
1597
+ "return a parseable version); Plastic cannot confirm the apply_patch hooks " \
1598
+ "floor v#{CODEX_HOOKS_FLOOR} is met, so its gate may silently no-op",
1599
+ fixable: false,
1600
+ fix_hint: "Ensure `codex` is on PATH and `codex --version` >= #{CODEX_HOOKS_FLOOR}"
1601
+ )
1602
+ end
1603
+
1604
+ if parsed < safe_version(CODEX_HOOKS_FLOOR)
1605
+ return check(
1606
+ category: "agent_registration", name: "codex_version_floor", status: "warn",
1607
+ message: "Installed Codex #{version} predates v#{CODEX_HOOKS_FLOOR}; the apply_patch " \
1608
+ "PreToolUse veto only exists from v#{CODEX_HOOKS_FLOOR} (PR #18391), so Plastic's " \
1609
+ "gate silently no-ops on this install",
1610
+ fixable: false,
1611
+ fix_hint: "Upgrade Codex to v#{CODEX_HOOKS_FLOOR} or newer with your install method " \
1612
+ "(npm i -g @openai/codex, mise, homebrew, or cargo)"
1613
+ )
1614
+ end
1615
+
1616
+ check(
1617
+ category: "agent_registration", name: "codex_version_floor", status: "pass",
1618
+ message: "Codex #{version} meets the apply_patch hooks floor v#{CODEX_HOOKS_FLOOR}"
1619
+ )
1620
+ end
1621
+
1622
+ def codex_version_from_output(stdout)
1623
+ m = stdout.to_s.match(/(\d+\.\d+\.\d+(?:[.\-+][0-9A-Za-z.\-+]*)?)/)
1624
+ m && m[1]
1625
+ end
1626
+
1627
+ def safe_version(str)
1628
+ Gem::Version.new(str.to_s)
1629
+ rescue ArgumentError
1630
+ nil
1631
+ end
1632
+
1531
1633
  # --- Check category 4: Core files ---
1532
1634
 
1533
1635
  def check_core_files(agent_key)
@@ -67,6 +67,7 @@
67
67
  require "fileutils"
68
68
  require "date"
69
69
  require "open3"
70
+ require "pathname"
70
71
  require_relative "lib/bridge"
71
72
  require_relative "lib/lock"
72
73
  require_relative "lib/intent_validator"
@@ -324,14 +325,25 @@ def git_toplevel(dir)
324
325
  end
325
326
 
326
327
  # Best-effort store commit: never raises, never blocks the mechanical close.
327
- # Returns true when a commit was created, false otherwise (not a git repo,
328
- # nothing to commit, or the commit failed). Pins a local committer identity so
329
- # the commit succeeds even with no ambient git config (hermetic).
330
- def store_commit(store, id, disposition)
328
+ # Returns true when a commit was created, false otherwise (not a git repo, nothing to
329
+ # commit, or the commit failed). Pins a local committer identity so the commit succeeds
330
+ # even with no ambient git config (hermetic).
331
+ #
332
+ # SCOPED, never `git add -A` (D17, intent 197): stages only the completing intent's own
333
+ # directory plus the store's INDEX.md, by explicit relative path. An unrelated dirty file
334
+ # elsewhere in the store (another session's uncommitted work, a maintenance session's
335
+ # in-flight change) is left exactly as it was found, never swept into this commit. This is
336
+ # the safety floor that makes a concurrent maintenance session's change-plus-receipt safe on
337
+ # the shared store checkout before intent 178 (store worktrees) lands.
338
+ def store_commit(store, id, disposition, intent_dir:, index_path:)
331
339
  root = git_toplevel(store)
332
340
  return false if root.nil?
333
341
 
334
- Open3.capture3("git", "-C", root, "add", "-A")
342
+ paths = [intent_dir, index_path].select { |p| p && File.exist?(p) }
343
+ .map { |p| relative_to(root, p) }
344
+ return false if paths.empty?
345
+
346
+ Open3.capture3("git", "-C", root, "add", "--", *paths)
335
347
  _out, _err, status = Open3.capture3(
336
348
  "git", "-C", root,
337
349
  "-c", "user.name=Plastic", "-c", "user.email=plastic@localhost",
@@ -340,6 +352,18 @@ def store_commit(store, id, disposition)
340
352
  status.success?
341
353
  end
342
354
 
355
+ # `path`, relative to `root` (both absolute). Assumes `path` is inside `root` (true for
356
+ # both intent_dir and index_path here, since both are derived from `store`, itself always
357
+ # inside the same git repo `git_toplevel` resolved `root` from). Uses File.realpath, not
358
+ # File.expand_path: `root` came back from `git rev-parse --show-toplevel`, which resolves
359
+ # symlinked ancestors (e.g. macOS's /var -> /private/var), so an unresolved `path` can start
360
+ # with a different prefix than `root` and produce a bogus, escaping relative path. Both
361
+ # `path` and `root` are known to exist by the time this is called (the caller filters on
362
+ # File.exist? first), so realpath is safe here.
363
+ def relative_to(root, path)
364
+ Pathname.new(File.realpath(path)).relative_path_from(Pathname.new(File.realpath(root))).to_s
365
+ end
366
+
343
367
  # --- pre-flight lock guard (D3/D4, intent 188) ------------------------------
344
368
 
345
369
  # Session resolution order (D3): explicit --session, else CLAUDE_CODE_SESSION_ID,
@@ -617,8 +641,9 @@ def main(argv)
617
641
  # 3. Savepoint Done bookend (idempotent).
618
642
  Bridge.append_terminal_savepoint(intent_dir, disposition)
619
643
 
620
- # 4. Store auto-commit, unless --no-commit.
621
- store_commit(store, id, disposition) unless opts[:no_commit]
644
+ # 4. Store auto-commit, unless --no-commit. Scoped to this intent's own paths (D17):
645
+ # never git add -A.
646
+ store_commit(store, id, disposition, intent_dir: intent_dir, index_path: index_path) unless opts[:no_commit]
622
647
 
623
648
  # 5. Disarm (D2/D16): dirty-worktree guard, disarm seam, lock verification.
624
649
  result = run_disarm(intent_dir, id, session, discard_worktree_changes: opts[:discard_worktree_changes])
@@ -31,6 +31,10 @@ module AgentModels
31
31
  # dispatched by the auto pipeline, not part of TIER_DEFAULTS. Claude-only for
32
32
  # this release (generate_codex_agents skips both by name; the Codex advisor
33
33
  # case is intent 186, not a permanent exclusion).
34
+ #
35
+ # Intent 186 DEFINES the advisor Codex pairing but keeps emission deferred: when the skip is
36
+ # lifted, plastic-advisor pairs to gpt-5.6-sol at xhigh (the deepest) and plastic-faux-advisor
37
+ # to gpt-5.6-terra at high (cheaper). Neither is in TIER_DEFAULTS and neither is auto-dispatched.
34
38
  CONSULTATION_AGENTS = %w[plastic-advisor plastic-faux-advisor].freeze
35
39
 
36
40
  # Codex reasoning-effort per tier alias (intent 102a). model_reasoning_effort is a
@@ -45,6 +49,21 @@ module AgentModels
45
49
  "haiku" => "low"
46
50
  }.freeze
47
51
 
52
+ # Codex model id per tier alias (intent 186). Codex has NO vendor alias layer: every model id
53
+ # is a literal versioned string that rots (gpt-5.2 / gpt-5.3-codex already deprecated), which is
54
+ # why 116 D1 / 102a Decision B refused to pin a raw id per role file. This resolves that by
55
+ # centralizing every id in ONE map: Plastic owns the alias, so per-role identity costs a single
56
+ # line to refresh on a Codex deprecation plus a Plastic release, and no per-role file carries a
57
+ # raw id. opus (deepest reasoning tier) -> the flagship Sol; sonnet (execution tier) -> the
58
+ # balanced Terra; haiku (lightest) -> the fast/cheap Luna. Paired with EFFORT_BY_ALIAS so
59
+ # reasoning roles get a stronger model AND higher effort than executors. This is a shipped
60
+ # DEFAULT, fully overridable via agents.models.codex.<name>.
61
+ CODEX_MODEL_BY_ALIAS = {
62
+ "opus" => "gpt-5.6-sol",
63
+ "sonnet" => "gpt-5.6-terra",
64
+ "haiku" => "gpt-5.6-luna"
65
+ }.freeze
66
+
48
67
  module_function
49
68
 
50
69
  # Pull { basename => model } out of a loaded config hash's `agents.models`
@@ -85,4 +104,10 @@ module AgentModels
85
104
  def effort_for(value)
86
105
  EFFORT_BY_ALIAS[value.to_s]
87
106
  end
107
+
108
+ # The Codex model id for a Plastic tier alias, or nil for any value that is not one of the three
109
+ # shipped aliases (the caller then treats the value as a literal Codex model id, or omits it).
110
+ def codex_model_for(value)
111
+ CODEX_MODEL_BY_ALIAS[value.to_s]
112
+ end
88
113
  end
@@ -798,13 +798,12 @@ module Bridge
798
798
  "warning_at" => 80,
799
799
  "critical_at" => 90
800
800
  },
801
- # Worktree isolation block (intent 73c). Born unprovisioned; arm_auto calls
802
- # Worktree.provision to fill it. code/store are abs paths or null.
801
+ # Worktree isolation block (intent 73c; store-worktree half retired by
802
+ # intent 178). Born unprovisioned; arm_auto calls Worktree.provision to
803
+ # fill it. "code" is an abs path or null.
803
804
  "worktree" => {
804
805
  "code" => nil,
805
806
  "code_branch" => nil,
806
- "store" => nil,
807
- "store_branch" => nil,
808
807
  "provisioned" => false
809
808
  },
810
809
  # Delivery-lock CACHE block (intent 108, D2). The durable truth is the
@@ -138,6 +138,18 @@ module HookRegistry
138
138
  result
139
139
  end
140
140
 
141
+ # Flattened, deduplicated Claude launcher names for every hook `events`
142
+ # registers (intent 204): each hook name maps to a hooks/<name> launcher
143
+ # installed as ~/.claude/hooks/plastic-<name>. The single derivation doctor's
144
+ # hooks_exist/hooks_executable/hooks_no_orphans checks read from, so a
145
+ # hand-kept list of launchers can never drift out of step with `events`
146
+ # again (the gap that let 8 of 15 launchers, all the enforcement gates, go
147
+ # unchecked).
148
+ def claude_launcher_names
149
+ events.values.flatten.flat_map { |g| g["hooks"].map { |h| h["name"] } }
150
+ .uniq.sort.map { |name| "plastic-#{name}" }
151
+ end
152
+
141
153
  # The settings.json shape merge_claude_hooks expects: single-group events map
142
154
  # to a Hash, multi-group events to an Array (the merge loop handles both).
143
155
  def claude_settings_hooks(hook_dir:)