@zalom/plastic 1.4.0 → 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.
- package/PLASTIC-reference.md +2 -0
- package/PLASTIC.md +160 -42
- package/agents/plastic-intent-curator.md +10 -2
- package/package.json +1 -1
- package/scripts/codex-hook +122 -8
- package/scripts/dashboard.rb +323 -71
- package/scripts/doctor.rb +271 -15
- package/scripts/end-intent +32 -7
- package/scripts/hook-lock-gate +8 -3
- package/scripts/install.rb +51 -6
- package/scripts/lib/bridge.rb +79 -30
- package/scripts/lib/hook_registry.rb +44 -2
- package/scripts/lib/installer_core.rb +45 -6
- package/scripts/lib/lock.rb +186 -11
- 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/plastic-lock +76 -9
- 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/SKILL.md +29 -6
- package/skills/auto/references/agent-architecture.md +7 -0
- package/skills/auto/references/end-tail.md +8 -6
- package/skills/dashboard/SKILL.md +48 -25
- package/skills/dashboard/evals/evals.json +4 -4
- package/skills/dashboard/templates/dashboard-global.md +3 -5
- package/skills/dashboard/templates/dashboard-project.md +6 -18
- package/skills/doctor/SKILL.md +6 -0
- package/skills/intent-locking/SKILL.md +20 -2
- package/skills/intent-starting/SKILL.md +6 -4
- package/skills/project-continuing/SKILL.md +10 -0
- package/skills/project-continuing/evals/evals.json +3 -3
- package/skills/project-continuing/references/board-fill.md +13 -11
- 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/skills/tutorial/SKILL.md +4 -4
- package/skills/tutorial/references/track-1-guided.md +2 -1
- package/skills/tutorial/references/track-2-auto.md +2 -1
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -1
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
|
@@ -99,6 +99,11 @@ tags: [plastic, architecture]
|
|
|
99
99
|
| **How** | Planning | `plan.md` + `actions/ACTION_N.md` (at least one) + `checklist.md` | `plastic-intent-planning` |
|
|
100
100
|
| **Exec** | Execution | `outcome.md` | `plastic-intent-executing` |
|
|
101
101
|
|
|
102
|
+
Invoke a skill for your harness: Claude Code uses the slash form (`/plastic-intent-creating`);
|
|
103
|
+
Codex CLI uses a dollar prefix instead (`$plastic-intent-creating`), and may also select a skill
|
|
104
|
+
implicitly by matching its description. Skill names elsewhere in this document are given bare
|
|
105
|
+
(`plastic-intent-creating`); add the prefix for your harness.
|
|
106
|
+
|
|
102
107
|
`## Insights` is the append-only log of durable discoveries captured throughout ALL stages.
|
|
103
108
|
An insight is a discovery worth keeping for later reads: novel, or old but newly relevant,
|
|
104
109
|
surfaced at any stage (What, Why, How, Exec). It is the most interesting residue of an
|
|
@@ -288,8 +293,8 @@ Beyond the lifecycle agents, Plastic ships thin skills for day-to-day operation:
|
|
|
288
293
|
- **`plastic-feedback`** (intent 174) turns a described Plastic quirk, bug, or feature idea
|
|
289
294
|
into a redacted local report file and a prefilled GitHub issue URL; only the user can submit
|
|
290
295
|
it. `disable-model-invocation` hides its description from your own context, so if the user
|
|
291
|
-
hits a Plastic quirk, bug, or missing feature, offer to
|
|
292
|
-
instead of waiting to be asked; the user still sends it, you never do.
|
|
296
|
+
hits a Plastic quirk, bug, or missing feature, offer to invoke the plastic-feedback skill
|
|
297
|
+
yourself instead of waiting to be asked; the user still sends it, you never do.
|
|
293
298
|
|
|
294
299
|
## Releases and Versioning
|
|
295
300
|
|
|
@@ -505,18 +510,31 @@ Each gate guards one thing. All are hard except the retrieval gate:
|
|
|
505
510
|
|
|
506
511
|
Exactly one session or agent develops an intent's delivery at a time. Ownership is
|
|
507
512
|
session-keyed and durable: arming acquires `delivery.lock` inside the intent directory
|
|
508
|
-
(atomically, O_EXCL)
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
513
|
+
(atomically, O_EXCL). The session id is the authorization identity. Descriptive provenance
|
|
514
|
+
records the controller's explicit `harness`, `agent`, `model`, `thread`, and `mode` values,
|
|
515
|
+
but never grants access and is never inferred from transcripts or filesystem paths. Missing
|
|
516
|
+
fields on legacy locks display as `Unknown`. Liveness is a lease: the owner's hooks refresh
|
|
517
|
+
the lock file's mtime on tool activity, and that mtime is the sole heartbeat truth. The lock
|
|
518
|
+
counts as stale only when the mtime is older than the TTL. No process id is consulted anywhere.
|
|
519
|
+
The /tmp session bridge is a cache
|
|
512
520
|
of this state; on any disagreement, or when the bridge is missing, the lock file wins.
|
|
513
521
|
Another session that finds a fresh lock backs off; a stale lock is reclaimed only by
|
|
514
522
|
explicit takeover, which replaces the lock and appends an audit line to the intent's
|
|
515
|
-
savepoint.md.
|
|
516
|
-
|
|
523
|
+
savepoint.md. Rearming the same session preserves its acquired identity and refreshes known
|
|
524
|
+
provenance; an explicit takeover replaces the controller and starts new provenance.
|
|
525
|
+
Subagents spawned by the owner write under the owner's lock once registered as delegates.
|
|
526
|
+
Delegate activity status (`active`, `finished`, or `failed`) is descriptive and does not revoke
|
|
527
|
+
the session's string-array authorization. A registered delegate remains authorized until a
|
|
528
|
+
separate authorization-removal mechanism exists. Finished and failed delegate activity is
|
|
529
|
+
retained as descriptive history, bounded to the 20 most recent terminal entries. A controller,
|
|
530
|
+
a delegate, and an artifact claim are distinct evidence: controller ownership authorizes the
|
|
531
|
+
delivery, delegate registration authorizes a child session, and a claim selects one current
|
|
532
|
+
writer for one artifact. Disarm clears the lock; the End tail is ordered: verify, merge and remove
|
|
517
533
|
worktrees, clear the lock, and only then is the bridge purge-eligible. Repair is one
|
|
518
|
-
idempotent function with two entry points: the `plastic-lock` command (status, fix,
|
|
519
|
-
release, reclaim, delegate) and `/plastic-intent-starting`, so boarding self-heals.
|
|
534
|
+
idempotent function with two entry points: the `plastic-lock` command (`who`, status, fix,
|
|
535
|
+
release, reclaim, delegate) and `/plastic-intent-starting`, so boarding self-heals. `who` is
|
|
536
|
+
read-only and reports the controller, mtime heartbeat, delegates, and claims from durable files.
|
|
537
|
+
This is
|
|
520
538
|
mandatory, not a convention.
|
|
521
539
|
|
|
522
540
|
Solo-mode gate defaults (intent 128): on a confirmed positive solo determination
|
|
@@ -547,26 +565,38 @@ open (the write proceeds, the claim yields) and the condition is surfaced in
|
|
|
547
565
|
lock. See `plastic-lock claim`/`release-claim` and `docs/internals.md` for the
|
|
548
566
|
full mechanism.
|
|
549
567
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
only)
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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.
|
|
556
577
|
|
|
557
578
|
Every code-touching intent gets its own git worktree named `{id}--{slug}`, and all code edits
|
|
558
579
|
for that intent happen only inside it. Plastic provisions the worktree deterministically: it
|
|
559
580
|
resolves the project repo from `projects.yml` and runs `git -C <repo> worktree add`, so
|
|
560
|
-
isolation never depends on the current working directory. There
|
|
561
|
-
intent
|
|
562
|
-
|
|
563
|
-
|
|
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.
|
|
564
594
|
|
|
565
595
|
Provisioning fails open for intents that touch no project code (pure research or decision
|
|
566
596
|
intents in the global store, or a non-git repo): those get the lock only, and the worktree
|
|
567
597
|
block stays unprovisioned. The fail-open path is always logged, never silent.
|
|
568
598
|
|
|
569
|
-
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
|
|
570
600
|
an orphaned worktree behind, and clear a stale worktree reference with `git worktree prune`.
|
|
571
601
|
|
|
572
602
|
### Intent delivery, station by station
|
|
@@ -582,7 +612,7 @@ each station.
|
|
|
582
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` |
|
|
583
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 |
|
|
584
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 |
|
|
585
|
-
| 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` |
|
|
586
616
|
|
|
587
617
|
### What "intent done" means (intent 93)
|
|
588
618
|
|
|
@@ -621,26 +651,113 @@ tail; `doctor` surfaces this as a "stalled completion" (terminal in INDEX but th
|
|
|
621
651
|
still present or stale). Finishing the tail is FINISHING a completion, never a reactivation:
|
|
622
652
|
a done intent is never moved back to `## Active`.
|
|
623
653
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
delivery
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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.
|
|
632
749
|
|
|
633
750
|
Restore-to-v1 (the owner rule that a completed intent is immutable: a late ruling goes to a
|
|
634
751
|
new `--parent` branch intent, and the completed intent is restored to v1) is performed ONLY by
|
|
635
|
-
`scripts/restore-intent-v1
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
APPEND-ONLY:
|
|
639
|
-
subtracted.
|
|
640
|
-
|
|
641
|
-
after v1 (proven on intent 124: a
|
|
642
|
-
|
|
643
|
-
|
|
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).
|
|
644
761
|
|
|
645
762
|
Fail-safe lock doctrine (the contract intent 111 implements): the lock system never traps a
|
|
646
763
|
session or burns credits. When a gate cannot verify lock integrity it fails open, degrading
|
|
@@ -656,6 +773,7 @@ plus `disposition` header at both terminals, the End tail with the reindex moved
|
|
|
656
773
|
`done_signals` doctor check (three-signal agreement plus stalled-completion detection), and
|
|
657
774
|
the lock-bounded post-done window with its keep-guard test. Intent 111 owns the lock
|
|
658
775
|
liveness surface, the lock-issue message, orchestrator auto-repair, and the fail-open
|
|
659
|
-
behavior itself. Intent 112
|
|
660
|
-
|
|
661
|
-
|
|
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/codex-hook
CHANGED
|
@@ -2,18 +2,57 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
#
|
|
5
|
-
# Usage: codex-hook <gate>
|
|
5
|
+
# Usage: codex-hook <gate>
|
|
6
|
+
# file-mutation gates: code-gate | lock-gate | savepoint-pre | gate-check | create-gate | links-gate
|
|
7
|
+
# live-state hooks (intent 199): session-start | check-update | continue |
|
|
8
|
+
# future-intent-check | auto-arm | qmd-search | savepoint
|
|
9
|
+
# shell-tool gates (intent 203): bash-gate | retrieval-gate
|
|
6
10
|
#
|
|
7
|
-
# The Codex input adapter (intent 102). Reads a Codex
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
11
|
+
# The Codex input adapter (intent 102, extended by 199 and 203). Reads a Codex
|
|
12
|
+
# hook stdin payload once. Three shapes exist and the gate name alone selects
|
|
13
|
+
# which path runs (Codex's own hooks.json registration guarantees they never
|
|
14
|
+
# cross): the file-mutation gates read session_id at top level and the
|
|
15
|
+
# apply_patch command in tool_input.command [guide Part 4], parse the diff
|
|
16
|
+
# envelope via ApplyPatchEnvelope, and drive Plastic's payload-agnostic Ruby
|
|
17
|
+
# gate/savepoint cores once per file operation, relaying stdout/stderr/exit
|
|
18
|
+
# unchanged. The live-state hooks carry no tool_input at all (they are not tool
|
|
19
|
+
# calls); the dispatcher execs the SAME launcher file Claude already runs for
|
|
20
|
+
# that hook (hooks/<name>), which is already harness-agnostic (resolves
|
|
21
|
+
# ~/.plastic off $HOME, reads only the common stdin fields the guide confirms
|
|
22
|
+
# Codex shares with Claude for these three events), so the body is reused, not
|
|
23
|
+
# rewritten (D3). The shell-tool gates (bash-gate, retrieval-gate) report
|
|
24
|
+
# tool_name: "Bash" with the command in tool_input.command, but MUST NOT be
|
|
25
|
+
# routed through ApplyPatchEnvelope.parse below: a plain shell command has no
|
|
26
|
+
# *** Begin Patch/*** End Patch envelope, so it would parse to an empty op list
|
|
27
|
+
# and hit this file's own `exit 0 if ops.empty?` line, silently allowing every
|
|
28
|
+
# Bash call and reopening the exact hole intent 203 closes. So these two exec
|
|
29
|
+
# the SAME scripts/hook-bash-gate / scripts/hook-retrieval-gate files Claude
|
|
30
|
+
# already runs, unmodified, the identical "drive the body, relay its output"
|
|
31
|
+
# pattern used for the live-state hooks. The one adaptation shared by both the
|
|
32
|
+
# live-state and shell-tool paths is threading the payload's session_id into
|
|
33
|
+
# CLAUDE_CODE_SESSION_ID, since Codex's own process env never carries it, plus
|
|
34
|
+
# a bounded timeout: hooks/check-update backgrounds a real npm network call
|
|
35
|
+
# without redirecting its output away from the inherited stdout/stderr pipes,
|
|
36
|
+
# and Codex invokes hooks synchronously, so without a bound a slow network call
|
|
37
|
+
# could hold this dispatcher's pipe open past the launcher's own (fast) exit.
|
|
13
38
|
require "json"
|
|
39
|
+
require "open3"
|
|
14
40
|
require "rbconfig"
|
|
41
|
+
require "timeout"
|
|
15
42
|
require_relative "lib/apply_patch_envelope"
|
|
16
43
|
require_relative "lib/intent_validator"
|
|
44
|
+
require_relative "lib/links_gate"
|
|
45
|
+
|
|
46
|
+
# Sync stdout so a relayed launcher's stdout and stderr land on the merged
|
|
47
|
+
# pipe in call order. Ruby buffers STDOUT (but not STDERR) when it is not a
|
|
48
|
+
# TTY; without this, `warn err` below can reach the OS pipe before a
|
|
49
|
+
# preceding `print out` is flushed, corrupting the relayed JSON for any
|
|
50
|
+
# caller that merges the dispatcher's own stdout and stderr.
|
|
51
|
+
$stdout.sync = true
|
|
52
|
+
|
|
53
|
+
STATE_HOOKS = %w[session-start check-update continue future-intent-check auto-arm qmd-search savepoint].freeze
|
|
54
|
+
STATE_TIMEOUT = 5
|
|
55
|
+
SHELL_HOOKS = %w[bash-gate retrieval-gate].freeze
|
|
17
56
|
|
|
18
57
|
gate = ARGV[0].to_s
|
|
19
58
|
raw = ($stdin.read rescue nil)
|
|
@@ -22,7 +61,49 @@ payload = (JSON.parse(raw) rescue nil)
|
|
|
22
61
|
exit 0 unless payload.is_a?(Hash)
|
|
23
62
|
|
|
24
63
|
session = payload["session_id"].to_s
|
|
64
|
+
|
|
65
|
+
if STATE_HOOKS.include?(gate)
|
|
66
|
+
launcher = File.expand_path(File.join(__dir__, "..", "hooks", gate))
|
|
67
|
+
cwd = payload["cwd"].to_s
|
|
68
|
+
cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
|
|
69
|
+
env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
|
|
70
|
+
out, err, status = begin
|
|
71
|
+
Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env, launcher, stdin_data: raw, chdir: cwd) }
|
|
72
|
+
rescue StandardError
|
|
73
|
+
["", "", nil] # fail open: launcher missing, unexecutable, crashed, or too slow
|
|
74
|
+
end
|
|
75
|
+
print out unless out.to_s.empty?
|
|
76
|
+
warn err unless err.to_s.empty?
|
|
77
|
+
exit(status ? status.exitstatus : 0)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if SHELL_HOOKS.include?(gate)
|
|
81
|
+
# bash-gate / retrieval-gate (intent 203): a shell command has no apply_patch
|
|
82
|
+
# diff envelope, so this branch execs the SAME scripts/hook-bash-gate /
|
|
83
|
+
# scripts/hook-retrieval-gate files Claude already runs, unmodified, and
|
|
84
|
+
# relays their exit code and stderr. It must never fall through to
|
|
85
|
+
# ApplyPatchEnvelope.parse below (see header comment).
|
|
86
|
+
cwd = payload["cwd"].to_s
|
|
87
|
+
cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
|
|
88
|
+
env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
|
|
89
|
+
script = File.join(__dir__, "hook-#{gate}")
|
|
90
|
+
argv = [script]
|
|
91
|
+
# hook-retrieval-gate reads plastic_home off ARGV[0] (mirrors hooks/retrieval-gate's
|
|
92
|
+
# own launcher, which passes "$HOME/.plastic"); hook-bash-gate takes no args, it
|
|
93
|
+
# resolves Dir.home directly.
|
|
94
|
+
argv << ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") } if gate == "retrieval-gate"
|
|
95
|
+
out, err, status = begin
|
|
96
|
+
Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env, RbConfig.ruby, *argv, stdin_data: raw, chdir: cwd) }
|
|
97
|
+
rescue StandardError
|
|
98
|
+
["", "", nil] # fail open: script missing, unexecutable, crashed, or too slow
|
|
99
|
+
end
|
|
100
|
+
print out unless out.to_s.empty?
|
|
101
|
+
warn err unless err.to_s.empty?
|
|
102
|
+
exit(status ? status.exitstatus : 0)
|
|
103
|
+
end
|
|
104
|
+
|
|
25
105
|
command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
|
|
106
|
+
exit 0 if command.nil? # fail-open: no tool_input at all (unrecognized gate name or non-tool-call payload)
|
|
26
107
|
ops = ApplyPatchEnvelope.parse(command)
|
|
27
108
|
exit 0 if ops.empty? # fail-open: nothing parseable to gate
|
|
28
109
|
|
|
@@ -55,8 +136,11 @@ when "create-gate"
|
|
|
55
136
|
|
|
56
137
|
when "lock-gate"
|
|
57
138
|
# Deny is signalled by JSON on stdout at exit 0. Relay the first deny.
|
|
139
|
+
# "codex" is threaded as the harness (intent 201 D2): hook-lock-gate renders
|
|
140
|
+
# any deny reason with the $plastic-<name> form instead of Claude's default
|
|
141
|
+
# slash.
|
|
58
142
|
ops.each do |o|
|
|
59
|
-
out, _ = run_core("hook-lock-gate", o.path, session)
|
|
143
|
+
out, _ = run_core("hook-lock-gate", o.path, session, "codex")
|
|
60
144
|
if out.include?('"permissionDecision":"deny"') || out.include?('"permissionDecision": "deny"')
|
|
61
145
|
print out
|
|
62
146
|
exit 0
|
|
@@ -91,6 +175,36 @@ when "savepoint-pre"
|
|
|
91
175
|
ops.each { |o| run_core("hook-savepoint-pre", o.path) }
|
|
92
176
|
exit 0
|
|
93
177
|
|
|
178
|
+
when "links-gate"
|
|
179
|
+
# Write-time belt for the PLASTIC.md ## Links contract (intent 192), reusing
|
|
180
|
+
# the SAME LinksGate.decision Claude's hook-links-gate already drives, so
|
|
181
|
+
# both harnesses share one decision function. before_content is the real
|
|
182
|
+
# on-disk file (File.read, or "" when it does not yet exist); after_content
|
|
183
|
+
# is the proposed result, o.added_content.
|
|
184
|
+
#
|
|
185
|
+
# DISCLOSED, NARROWER judgment than Claude's version (intent 198, Decision
|
|
186
|
+
# D3): ApplyPatchEnvelope.parse only ever captures a diff's ADDED lines,
|
|
187
|
+
# never its removed or context lines, so for an Update whose diff is a
|
|
188
|
+
# partial hunk rather than a full-file rewrite, after_content may not be the
|
|
189
|
+
# complete proposed file. This is the same class of disclosed limitation
|
|
190
|
+
# create-gate already carries for Update/Delete/Move (best effort, never a
|
|
191
|
+
# hard crash). links-gate has no PostToolUse backstop to defer to on Claude
|
|
192
|
+
# either, so running this best-effort compare on Update ops too is strictly
|
|
193
|
+
# more coverage than the prior total fail-open.
|
|
194
|
+
ops.each do |o|
|
|
195
|
+
next unless intent_file?(o.path)
|
|
196
|
+
|
|
197
|
+
before_content = File.exist?(o.path) ? File.read(o.path) : ""
|
|
198
|
+
plastic_home = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
|
|
199
|
+
reason = LinksGate.decision(file_path: o.path, before_content: before_content,
|
|
200
|
+
after_content: o.added_content.to_s, plastic_home: plastic_home)
|
|
201
|
+
next unless reason
|
|
202
|
+
|
|
203
|
+
$stderr.puts reason
|
|
204
|
+
exit 2
|
|
205
|
+
end
|
|
206
|
+
exit 0
|
|
207
|
+
|
|
94
208
|
else
|
|
95
209
|
exit 0
|
|
96
210
|
end
|