@zalom/plastic 1.4.1 → 1.5.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.5.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
@@ -52,18 +52,14 @@ class Doctor
52
52
  # never drift.
53
53
  LEGACY_BOOKEND_AMNESTY = LegacyBookendAmnesty::LIST
54
54
 
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
55
  CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
66
56
 
57
+ # Launchers the installer places in the agent's hooks dir that are NOT hooks
58
+ # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
59
+ # outside HookRegistry.events entirely, so it must be excluded from the
60
+ # orphan-launcher scan below or a correct install would report a false orphan.
61
+ CLAUDE_NON_HOOK_LAUNCHERS = %w[plastic-statusline].freeze
62
+
67
63
  REQUIRED_SCRIPTS = %w[
68
64
  folgezettel-id
69
65
  read-config
@@ -815,7 +811,11 @@ class Doctor
815
811
  "it PRESERVES any line unbacked by frontmatter that still resolves to a real intent " \
816
812
  "(reported as an orphan candidate, never silently deleted); add the missing " \
817
813
  "sources/chain edge if the relationship is real, or pass --drop-unbacked-links to " \
818
- "delete an orphan candidate deliberately."
814
+ "delete an orphan candidate deliberately. Prefer `ruby scripts/maintenance-run --tool " \
815
+ "project-links --intent <id> --apply` over running project-links directly: it detects " \
816
+ "(never acquires) the target's delivery lock, requires a clean store working tree, and " \
817
+ "commits the scoped change plus its revisions.md receipt as one merged operation " \
818
+ "(PLASTIC.md > WORK vs MAINTENANCE)."
819
819
  )
820
820
  end
821
821
 
@@ -1003,13 +1003,17 @@ class Doctor
1003
1003
  checks = []
1004
1004
  hooks_dir = File.join(agent_dir, "hooks")
1005
1005
 
1006
+ # Derived from HookRegistry.events (intent 204), not a hand-kept list, so
1007
+ # every registered hook (all 15, including the enforcement gates) is checked.
1008
+ expected_launchers = HookRegistry.claude_launcher_names
1009
+
1006
1010
  # hooks_exist
1007
- missing_hooks = CLAUDE_HOOK_SCRIPTS.reject { |h| File.exist?(File.join(hooks_dir, h)) }
1011
+ missing_hooks = expected_launchers.reject { |h| File.exist?(File.join(hooks_dir, h)) }
1008
1012
 
1009
1013
  if missing_hooks.empty?
1010
1014
  checks << check(
1011
1015
  category: "agent_registration", name: "hooks_exist", status: "pass",
1012
- message: "All #{CLAUDE_HOOK_SCRIPTS.size} expected hook scripts exist"
1016
+ message: "All #{expected_launchers.size} expected hook scripts exist"
1013
1017
  )
1014
1018
  else
1015
1019
  checks << check(
@@ -1021,7 +1025,7 @@ class Doctor
1021
1025
  end
1022
1026
 
1023
1027
  # hooks_executable
1024
- existing_hooks = CLAUDE_HOOK_SCRIPTS
1028
+ existing_hooks = expected_launchers
1025
1029
  .map { |h| File.join(hooks_dir, h) }
1026
1030
  .select { |p| File.exist?(p) }
1027
1031
 
@@ -1041,6 +1045,29 @@ class Doctor
1041
1045
  )
1042
1046
  end
1043
1047
 
1048
+ # hooks_no_orphans: the mirror of hooks_exist. A plastic-* launcher on disk
1049
+ # that HookRegistry does not know about is dead code the next reader would
1050
+ # trust as live (the same drift class as a missing launcher, just facing
1051
+ # the other way). plastic-statusline is a legitimate non-hook installer
1052
+ # artifact (see CLAUDE_NON_HOOK_LAUNCHERS) and is excluded here.
1053
+ present_launchers = Dir.glob(File.join(hooks_dir, "plastic-*")).map { |p| File.basename(p) }
1054
+ orphans = (present_launchers - expected_launchers - CLAUDE_NON_HOOK_LAUNCHERS).sort
1055
+
1056
+ if orphans.empty?
1057
+ checks << check(
1058
+ category: "agent_registration", name: "hooks_no_orphans", status: "pass",
1059
+ message: "No orphaned hook launchers in #{tilde(hooks_dir)}"
1060
+ )
1061
+ else
1062
+ checks << check(
1063
+ category: "agent_registration", name: "hooks_no_orphans", status: "warn",
1064
+ message: "#{orphans.size} hook launcher(s) on disk are not registered in HookRegistry",
1065
+ details: orphans.map { |h| "#{tilde(hooks_dir)}/#{h}" },
1066
+ fixable: true,
1067
+ fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes stale launchers)"
1068
+ )
1069
+ end
1070
+
1044
1071
  # hooks_registered — settings.json has Plastic hooks for required events
1045
1072
  settings_path = File.join(agent_dir, "settings.json")
1046
1073
  settings = read_json_safe(settings_path)
@@ -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])
@@ -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:)
@@ -330,6 +330,11 @@ class InstallerCore
330
330
  "scripts/rebuild-graph" => "scripts/rebuild-graph",
331
331
  "scripts/lib/restore_intent_v1.rb" => "scripts/lib/restore_intent_v1.rb",
332
332
  "scripts/restore-intent-v1" => "scripts/restore-intent-v1",
333
+ "scripts/lib/revisions_writer.rb" => "scripts/lib/revisions_writer.rb",
334
+ "scripts/maintenance-run" => "scripts/maintenance-run",
335
+ "scripts/lib/maintenance_git.rb" => "scripts/lib/maintenance_git.rb",
336
+ "scripts/lib/worktree_sweep.rb" => "scripts/lib/worktree_sweep.rb",
337
+ "scripts/sweep-store-worktrees" => "scripts/sweep-store-worktrees",
333
338
  "scripts/validate-intent" => "scripts/validate-intent",
334
339
  "scripts/new-intent" => "scripts/new-intent",
335
340
  "scripts/end-intent" => "scripts/end-intent",
@@ -0,0 +1,94 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "open3"
5
+
6
+ # MaintenanceGit - git isolation for a maintenance action (intent 197, D12/D13): a fresh
7
+ # branch off the CURRENT tip of `base`, the caller's block runs, only the paths the block
8
+ # ACTUALLY changed are staged (never `git add -A`), committed, and the branch is merged back
9
+ # to `base` as part of the SAME closed operation before this method returns. Nothing strands
10
+ # on an unmerged branch; nothing outside the block's own change is ever touched.
11
+ #
12
+ # Requires a CLEAN working tree before starting (see module doc above for why); refuses
13
+ # loudly rather than attempt to distinguish pre-existing dirt from the block's own changes.
14
+ # On any error inside the block, the working tree is hard-reset and returned to `base` before
15
+ # re-raising, which is SAFE only because the precondition already proved nothing else was
16
+ # dirty when the branch was created.
17
+ module MaintenanceGit
18
+ module_function
19
+
20
+ class NotAGitRepo < StandardError; end
21
+ class DirtyWorkingTree < StandardError; end
22
+
23
+ def git_toplevel(dir)
24
+ out, _err, status = Open3.capture3("git", "-C", dir, "rev-parse", "--show-toplevel")
25
+ return nil unless status.success?
26
+
27
+ top = out.strip
28
+ top.empty? ? nil : top
29
+ end
30
+
31
+ # Bare paths (status prefix stripped), relative to `root`. Empty array on a clean tree.
32
+ def porcelain_paths(root)
33
+ out, _err, status = Open3.capture3("git", "-C", root, "status", "--porcelain")
34
+ return [] unless status.success?
35
+
36
+ out.lines.map { |l| l[3..].to_s.strip }.reject(&:empty?)
37
+ end
38
+
39
+ # Runs `block` inside a fresh branch off `base`'s current tip. Returns
40
+ # { changed: [...], committed: bool, merged: bool, branch: name }.
41
+ def run_scoped(repo_dir:, branch_name:, commit_message:, base: "main")
42
+ root = git_toplevel(repo_dir)
43
+ raise NotAGitRepo, "#{repo_dir} is not inside a git repository" unless root
44
+
45
+ dirty = porcelain_paths(root)
46
+ unless dirty.empty?
47
+ raise DirtyWorkingTree,
48
+ "#{root} has #{dirty.size} uncommitted path(s) before maintenance started; " \
49
+ "commit or stash them first (never swept via git add -A): #{dirty.join(", ")}"
50
+ end
51
+
52
+ checkout!(root, base)
53
+ run_git!(root, "checkout", "--quiet", "-b", branch_name)
54
+
55
+ begin
56
+ yield
57
+ rescue StandardError
58
+ run_git!(root, "reset", "--hard", "--quiet")
59
+ run_git!(root, "clean", "-fd", "--quiet")
60
+ checkout!(root, base)
61
+ delete_branch(root, branch_name)
62
+ raise
63
+ end
64
+
65
+ changed = porcelain_paths(root)
66
+ if changed.empty?
67
+ checkout!(root, base)
68
+ delete_branch(root, branch_name)
69
+ return { changed: [], committed: false, merged: false, branch: branch_name }
70
+ end
71
+
72
+ run_git!(root, "add", "--", *changed)
73
+ run_git!(root, "-c", "user.name=Plastic", "-c", "user.email=plastic@localhost",
74
+ "commit", "--quiet", "-m", commit_message)
75
+ checkout!(root, base)
76
+ run_git!(root, "merge", "--quiet", "--no-ff", "-m", "Merge #{branch_name} into #{base}", branch_name)
77
+ delete_branch(root, branch_name)
78
+
79
+ { changed: changed, committed: true, merged: true, branch: branch_name }
80
+ end
81
+
82
+ def checkout!(root, ref)
83
+ run_git!(root, "checkout", "--quiet", ref)
84
+ end
85
+
86
+ def delete_branch(root, name)
87
+ Open3.capture3("git", "-C", root, "branch", "--quiet", "-D", name)
88
+ end
89
+
90
+ def run_git!(root, *args)
91
+ _out, err, status = Open3.capture3("git", "-C", root, *args)
92
+ raise "git #{args.join(" ")} failed in #{root}: #{err}" unless status.success?
93
+ end
94
+ end
@@ -0,0 +1,69 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # RevisionsWriter - the shared append-only revisions.md writer (intent 107's convention,
5
+ # generalized from restore_intent_v1.rb's proven pattern, intent 197). Every tool that
6
+ # performs structural maintenance on an intent (project-links, rebuild-graph,
7
+ # restore-intent-v1) must record it here: PLASTIC.md's `revisions.md` contract is that a
8
+ # structural change and its receipt are never separated. This module owns rendering ONE
9
+ # entry's text and appending it correctly; it does no git operations (that is
10
+ # lib/maintenance_git.rb's job) and never overwrites a prior entry.
11
+ #
12
+ # Pure where it can be (render_entry has no IO); the IO half (append!) is a thin,
13
+ # dependency-free file read/write, matching every other tool in scripts/lib.
14
+ module RevisionsWriter
15
+ module_function
16
+
17
+ # PURE. Renders one `## Revision vN - TIMESTAMP` entry in the documented shape
18
+ # (PLASTIC-reference.md > Structural maintenance and revisions.md; templates/revisions.md).
19
+ # `n` is the next revision number (caller resolves it via next_revision_number, or a caller
20
+ # that already knows it, e.g. a batch writer amortizing one file read across many entries).
21
+ # `why` is the one-sentence reason ending in "[rule: <tag>]" (tag is appended here if the
22
+ # caller passes a bare sentence plus `rule:`, so every caller cannot forget the tag).
23
+ # `prior_location` and `change` are free text (the "Change:" line for a metadata edit, or a
24
+ # multi-line indented "Content held:" block for a relocated section/file - callers building
25
+ # a Content-held entry should pass an already-indented `change` block).
26
+ def render_entry(n, why:, rule:, prior_location:, change:, timestamp: Time.now.utc)
27
+ ts = timestamp.strftime("%Y-%m-%d-%H:%M")
28
+ lines = []
29
+ lines << "## Revision v#{n} - #{ts}"
30
+ lines << "- Why: #{why.to_s.strip} [rule: #{rule}]"
31
+ lines << "- Prior location: #{prior_location}"
32
+ lines << "- Change: #{change}"
33
+ "#{lines.join("\n")}\n"
34
+ end
35
+
36
+ # PURE. Every existing "## Revision vN" number found in `existing_text` (empty array when
37
+ # none, i.e. the file does not exist yet or carries no entries). Mirrors
38
+ # scripts/restore-intent-v1's own `nums = existing.scan(/^## Revision v(\d+)/)` exactly, so
39
+ # the two writers can never disagree about numbering.
40
+ def revision_numbers(existing_text)
41
+ existing_text.to_s.scan(/^## Revision v(\d+)/).flatten.map(&:to_i)
42
+ end
43
+
44
+ def next_revision_number(existing_text)
45
+ (revision_numbers(existing_text).max || 0) + 1
46
+ end
47
+
48
+ # IO. Appends one entry to `<intent_dir>/revisions.md`, creating the file with its
49
+ # documented header (matching templates/revisions.md's "# revisions.md" title line) if it
50
+ # does not exist yet. NEVER overwrites or reorders a prior entry (append-only, intent 124's
51
+ # own v3-corrects-v2-by-appending precedent). Returns the revision number written.
52
+ #
53
+ # Raises RevisionsWriter::WriteFailed on any IO error (permission, disk full, read-only
54
+ # filesystem) so a caller can roll back a paired structural change rather than leave it
55
+ # unrecorded (D14's "or refuse"). Never swallows an error silently.
56
+ def append!(intent_dir, why:, rule:, prior_location:, change:, timestamp: Time.now.utc)
57
+ path = File.join(intent_dir, "revisions.md")
58
+ existing = File.exist?(path) ? File.read(path) : "# revisions.md\n\n"
59
+ n = next_revision_number(existing)
60
+ entry = render_entry(n, why: why, rule: rule, prior_location: prior_location,
61
+ change: change, timestamp: timestamp)
62
+ File.write(path, "#{existing.chomp}\n\n#{entry}")
63
+ n
64
+ rescue StandardError => e
65
+ raise WriteFailed, "could not append revisions.md at #{path}: #{e.message}"
66
+ end
67
+
68
+ class WriteFailed < StandardError; end
69
+ end