@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.
- package/PLASTIC-reference.md +2 -0
- package/PLASTIC.md +132 -31
- package/agents/plastic-intent-curator.md +10 -2
- package/package.json +1 -1
- package/scripts/doctor.rb +117 -15
- package/scripts/end-intent +32 -7
- package/scripts/lib/agent_models.rb +25 -0
- package/scripts/lib/bridge.rb +3 -4
- package/scripts/lib/hook_registry.rb +12 -0
- package/scripts/lib/installer_core.rb +18 -6
- package/scripts/lib/maintenance_git.rb +94 -0
- package/scripts/lib/revisions_writer.rb +69 -0
- package/scripts/lib/worktree.rb +14 -32
- package/scripts/lib/worktree_sweep.rb +129 -0
- package/scripts/maintenance-run +236 -0
- package/scripts/project-links +127 -24
- package/scripts/rebuild-graph +37 -3
- package/scripts/restore-intent-v1 +37 -3
- package/scripts/sweep-store-worktrees +53 -0
- package/skills/auto/references/end-tail.md +8 -6
- package/skills/doctor/SKILL.md +6 -0
- package/skills/releasing/SKILL.md +3 -3
- package/skills/store-curating/SKILL.md +9 -0
- package/skills/store-curating/evals/evals.json +16 -0
package/PLASTIC-reference.md
CHANGED
|
@@ -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
|
-
|
|
569
|
-
|
|
570
|
-
only)
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
|
579
|
-
intent
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
delivery
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
APPEND-ONLY:
|
|
657
|
-
subtracted.
|
|
658
|
-
|
|
659
|
-
after v1 (proven on intent 124: a
|
|
660
|
-
|
|
661
|
-
|
|
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
|
|
678
|
-
|
|
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.
|
|
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
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 =
|
|
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 #{
|
|
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 =
|
|
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)
|
package/scripts/end-intent
CHANGED
|
@@ -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
|
-
#
|
|
329
|
-
#
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -798,13 +798,12 @@ module Bridge
|
|
|
798
798
|
"warning_at" => 80,
|
|
799
799
|
"critical_at" => 90
|
|
800
800
|
},
|
|
801
|
-
# Worktree isolation block (intent 73c
|
|
802
|
-
#
|
|
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:)
|