@zalom/plastic 1.0.0-beta.24 → 1.0.0-beta.26

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.md CHANGED
@@ -283,32 +283,15 @@ hygiene after each intent. Advisory self-check, not hard-verifiable.
283
283
 
284
284
  ## Retrieval Gate
285
285
 
286
- A single capability-aware PreToolUse gate enforces retrieval-first routing on the agent's own
287
- Bash/Read/Grep/Glob calls (and on subagents, since PreToolUse binds them). The gate is
288
- OPERATION-based: it separates searching from reading, and it never stands between you and
289
- reading something you have already located.
290
-
291
- - Only CONTENT SEARCH over a Plastic store is gated. The Grep tool and bash `grep`/`rg`/`ag`
292
- whose target is at or under a store route to QMD when QMD is present and the index is fresh:
293
- the raw scan is blocked and you use `qmd search`/`qmd query` (or `scripts/qmd-sync search`)
294
- instead. When QMD is present but stale, the search is allowed this turn and a background
295
- reindex is fired so the next turn enforces against a fresh index; reindex is never
296
- synchronous. When QMD is absent, the search is allowed.
297
- - Reading a known target (the Read tool, bash `cat`/`head`/`tail`) and structural discovery
298
- (the Glob tool, bash `find`/`ls`) are always allowed, including over the store. QMD cannot
299
- list directories or hand back one specific file, so these are never gated.
300
- - Code is never hard-gated here. Symbolic code navigation via Serena is a soft prompt mandate
301
- (the UserPromptSubmit power-tools hook), not a block: content grep over code is allowed,
302
- because Serena navigates symbols and cannot grep arbitrary strings.
303
- - QMD failure model. Absent or stale degrades to allow (stale also fires the background
304
- reindex). A broken QMD, where the freshness probe errors or times out, also fails open, and
305
- the hook emits a one-line warning so a degraded QMD is visible rather than silent.
306
- - Bypass: append a trailing `# qmd-ok` shell comment to a Bash command when you attempted
307
- discovery and it did not serve you (no hits, or results that do not answer your need by your
308
- reading of the snippets, not their score). A quoted or echoed occurrence does not bypass.
309
- Bypasses are logged. The gate enforces that discovery was attempted, never that it succeeded.
310
- - Scope: only the agent's tool calls. Ruby `File.read` inside a script is invisible to the gate
311
- and is out of scope by design.
286
+ Advisory. Hard gates guard writes, locks, and structure, never reads or searches. Read,
287
+ Grep, Glob, and bash search are always allowed, including over the stores. When QMD is
288
+ present and fresh, a content search over store markdown receives an advisory hint pointing
289
+ at `qmd search` alongside its result; when QMD is present but stale, a background reindex
290
+ fires so the next turn's hint runs against a fresh index (never synchronous). QMD and
291
+ Serena are recommendations, not obligations: the UserPromptSubmit power-tools hook appends
292
+ one recommendation line per present tool. The legacy trailing `# qmd-ok` token is still
293
+ accepted on Bash commands and simply silences the hint. Scope stays the agent's own tool
294
+ calls; Ruby `File.read` inside a script is invisible to the hook by design.
312
295
 
313
296
  ## Context-economy measurement buckets (84a)
314
297
 
@@ -332,11 +315,28 @@ Hard blocking — hooks exit code 2 on gate failure.
332
315
 
333
316
  ## Delivery Isolation and the Single-Owner Lock
334
317
 
335
- Exactly one session or agent develops an intent's delivery at a time. Ownership is the armed
336
- session bridge, which doubles as the delivery lock: arming records the owning session, the
337
- owner pid, an acquired-at timestamp, and the host. Another session that finds an armed bridge
338
- for the same intent with a live owner backs off; if the owner pid is dead the lock is
339
- reclaimable. This is mandatory, not a convention.
318
+ Exactly one session or agent develops an intent's delivery at a time. Ownership is
319
+ session-keyed and durable: arming acquires `delivery.lock` inside the intent directory
320
+ (atomically, O_EXCL), recording the owner session, the host, the acquired-at time, a
321
+ delegates list, and the lock type. Liveness is a lease: the owner's hooks refresh the lock
322
+ file's mtime on tool activity, and the lock counts as stale only when that heartbeat is
323
+ older than the TTL. No process id is consulted anywhere. The /tmp session bridge is a cache
324
+ of this state; on any disagreement, or when the bridge is missing, the lock file wins.
325
+ Another session that finds a fresh lock backs off; a stale lock is reclaimed only by
326
+ explicit takeover, which replaces the lock and appends an audit line to the intent's
327
+ savepoint.md. Subagents spawned by the owner write under the owner's lock once registered
328
+ as delegates. Disarm clears the lock; the End tail is ordered: verify, merge and remove
329
+ worktrees, clear the lock, and only then is the bridge purge-eligible. Repair is one
330
+ idempotent function with two entry points: the `plastic-lock` command (status, fix,
331
+ release, reclaim, delegate) and `/plastic-intent-starting`, so boarding self-heals. This is
332
+ mandatory, not a convention.
333
+
334
+ Two locks share this schema (the two-lock doctrine): `delivery.lock` (exclusive, one owner
335
+ plus delegates) and the future `maintenance.lock` (short TTL, structural move-and-record
336
+ only). They are mutually exclusive in either direction; maintenance is allowed at any
337
+ lifecycle stage provided no delivery lock is held. Intent 108 ships the delivery lock and
338
+ the mutual-exclusion seam; the maintenance lock implementation follows intent 93 in a
339
+ chained intent.
340
340
 
341
341
  Every code-touching intent gets its own git worktree named `{id}--{slug}`, and all code edits
342
342
  for that intent happen only inside it. Plastic provisions the worktree deterministically: it
@@ -350,6 +350,76 @@ Provisioning fails open for intents that touch no project code (pure research or
350
350
  intents in the global store, or a non-git repo): those get the lock only, and the worktree
351
351
  block stays unprovisioned. The fail-open path is always logged, never silent.
352
352
 
353
+ ### Intent delivery, station by station
354
+
355
+ How one intent travels from boarding to Done, and what the lock, bridge, and gates do at
356
+ each station.
357
+
358
+ | Station | Delivered artifact | Lock and bridge steps | Pre-stage gate | Post-stage record |
359
+ |---|---|---|---|---|
360
+ | Start (board) | none (a procedure, not a stage) | `plastic-lock fix` self-heals stale, corrupt, or legacy state; arm acquires `delivery.lock` (O_EXCL, session-keyed), provisions the code worktree, writes the bridge cache | lock-gate denies any write into an active intent dir without this intent's lock; every deny names the resolving command | savepoint confirms the boarding station |
361
+ | What (create) | `<id>--<slug>.md`, born complete | no lock yet; no bridge | create-gate validates the proposed intent content (Write, Edit, and MCP edits) | savepoint `What` line; intent listed in INDEX `## Active` |
362
+ | Why | `spec.md` | owner writes refresh the lease (lock file mtime heartbeat) | gate-check requires the intent file with `## Intent` before spec.md; lock-gate admits only the owner or a delegate | savepoint `Why started`, `Why spec.md created` |
363
+ | How | `plan.md`, `actions/`, `checklist.md` | heartbeat on writes; the code gate stays closed until plan.md plus checklist.md exist | gate-check requires spec.md before plan.md, and plan.md plus actions/ before checklist.md | savepoint `How started`, `How plan.md created`, `How checklist.md created`, `Exec started` |
364
+ | 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 |
365
+ | 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 |
366
+ | 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 |
367
+
368
+ ### What "intent done" means (intent 93)
369
+
370
+ Done is one law with three signals, and they must agree. INDEX `## Completed` /
371
+ `## Abandoned` is the single canonical terminal marker: it is the store-wide ledger a fresh
372
+ session reads first, so it wins on any conflict. `outcome.md` is the "deliverable exists"
373
+ signal, and the savepoint `Done delivered|abandoned` line is the audit echo. All three must
374
+ agree; when they disagree, INDEX is authoritative and `doctor` flags the mismatch (the
375
+ `done_signals` check: `outcome.md` real but still under `## Active`, or terminal without a
376
+ real `outcome.md`, or a terminal intent whose savepoint carries no `Done` line).
377
+
378
+ `outcome.md` is mandatory at every terminal transition, delivered and abandoned alike. It
379
+ self-declares its disposition through a `disposition: delivered|abandoned` frontmatter
380
+ header. The delivered path authors it with the result; the abandoned path authors it with
381
+ the abandonment reason and no longer leaves the scaffolded placeholder sentinel in place.
382
+
383
+ The canonical End tail runs in this order, and the QMD reindex is always LAST, after the
384
+ purge: `outcome.md -> INDEX terminal -> savepoint Done -> commit -> disarm (Worktree.release
385
+ -> Lock.release -> purge) -> QMD reindex`. Running the reindex last keeps the index from
386
+ ever referencing a bridge or lock that disarm is about to remove.
387
+
388
+ The post-done access window is lock-bounded: `[INDEX terminal -> Lock.release]`. Through it
389
+ the completing session keeps full read and write access to the terminal directory and no
390
+ purge can fire (108's lock-held keep-guard keeps the bridge while `delivery.lock` exists).
391
+ Once the lock is released the window closes: the bridge becomes purge-eligible and the
392
+ directory is frozen. A crash mid-tail is recovered by stale-lock reclaim plus finishing the
393
+ tail; `doctor` surfaces this as a "stalled completion" (terminal in INDEX but the lock is
394
+ still present or stale). Finishing the tail is FINISHING a completion, never a reactivation:
395
+ a done intent is never moved back to `## Active`.
396
+
397
+ Terminal immutability (the contract intent 112 enforces): a terminal directory is writable
398
+ ONLY while a lock is held. The delivery lock covers the completing session's End tail up to
399
+ `Lock.release`; the maintenance lock covers sanctioned structural move-and-record edits
400
+ after. Terminal with no lock held is frozen. There are only two locks in the system,
401
+ delivery and maintenance (108 D11). This governs WRITES only: reads of a terminal intent are
402
+ always allowed and unbounded (curator reindex, dashboards, and future intents that reference
403
+ its id or chain), so a done intent stays fully readable forever. Intent 93 states this rule;
404
+ intent 112 builds the gate that enforces it.
405
+
406
+ Fail-safe lock doctrine (the contract intent 111 implements): the lock system never traps a
407
+ session or burns credits. When a gate cannot verify lock integrity it fails open, degrading
408
+ to advisory (warn) rather than hard-blocking. Repair is orchestrator-driven: on a lock-issue
409
+ signal the orchestrator inspects and repairs the lock automatically, and the human
410
+ `plastic-lock` command is a fallback path, not the trigger. Intent 93 states this doctrine;
411
+ intent 111 builds the fail-open behavior, the lock-liveness surface, the lock-issue message,
412
+ and the auto-repair.
413
+
414
+ Scope split. Intent 93 ships doctrine plus the low-risk reconciliation that needs no new
415
+ lock: the canonical done-marker and three-signal reconciliation, the mandatory `outcome.md`
416
+ plus `disposition` header at both terminals, the End tail with the reindex moved last, the
417
+ `done_signals` doctor check (three-signal agreement plus stalled-completion detection), and
418
+ the lock-bounded post-done window with its keep-guard test. Intent 111 owns the lock
419
+ liveness surface, the lock-issue message, orchestrator auto-repair, and the fail-open
420
+ behavior itself. Intent 112 owns the maintenance lock and the immutability gate (it inherits
421
+ fail-open from 111). Intent 4a1b1 owns deep agent stuck-detection and is not superseded.
422
+
353
423
  ## Deprecation Process
354
424
 
355
425
  Deprecations live in `deprecations.yml` and are shown at SessionStart. While Plastic is
@@ -32,7 +32,10 @@ You are the Plastic Intent Curator. Your role is to maintain the health and navi
32
32
  2. Read `~/.plastic/INDEX.md` (or project INDEX.md) to understand current organization
33
33
  3. Compare: are there intents not in any cluster? Missing from Active/Completed/Abandoned? Status mismatches?
34
34
  4. Make targeted edits to INDEX.md and intent frontmatter/links
35
- 5. On a terminal-state transition: whenever you move an intent to Completed OR Abandoned, refresh the QMD index for that store so the new outcome (or abandonment rationale) is searchable. This is mandatory on any terminal-state move and a no-op when QMD is absent, and it runs in the background so it never blocks: `ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root> --async`
35
+ 5. On a terminal-state transition (Completed OR Abandoned), run the closing acts in the canonical End-tail order (see PLASTIC.md `## Delivery Isolation and the Single-Owner Lock`):
36
+ 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).
37
+ b. Stamp the terminal savepoint bookend (intent 81) recording the disposition. Idempotent.
38
+ c. Refresh the QMD index for that store LAST, after the terminal move, outcome.md, and savepoint, so the new outcome (or abandonment rationale) is searchable. This is mandatory on any terminal-state move and a no-op when QMD is absent, and it runs in the background so it never blocks: `ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root> --async`
36
39
  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.
37
40
  7. Report what you changed
38
41
 
package/hooks/code-gate CHANGED
@@ -1,6 +1,15 @@
1
1
  #!/bin/bash
2
2
  INPUT=$(cat)
3
- FILE_PATH=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("tool_params", "file_path") || data.dig("tool_input", "file_path") || ""' 2>/dev/null)
3
+ FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
4
+ data = JSON.parse(STDIN.read)
5
+ ti = data["tool_input"] || data["tool_params"] || {}
6
+ path = ti["file_path"] || ti["notebook_path"] || ti["relative_path"] || ""
7
+ if !path.empty? && !path.start_with?("/")
8
+ root = ti["project_root"] || data["cwd"] || ""
9
+ path = File.join(root, path) unless root.empty?
10
+ end
11
+ puts path
12
+ ' 2>/dev/null)
4
13
 
5
14
  if [ -z "$FILE_PATH" ]; then
6
15
  exit 0
package/hooks/hooks.json CHANGED
@@ -19,7 +19,7 @@
19
19
  ],
20
20
  "PreCompact": [
21
21
  {
22
- "matcher": "manual|auto",
22
+ "matcher": "",
23
23
  "hooks": [
24
24
  {
25
25
  "type": "command",
@@ -31,7 +31,7 @@
31
31
  ],
32
32
  "PreToolUse": [
33
33
  {
34
- "matcher": "Write|Edit|NotebookEdit",
34
+ "matcher": "Write|Edit|NotebookEdit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
35
35
  "hooks": [
36
36
  {
37
37
  "type": "command",
@@ -56,7 +56,7 @@
56
56
  ]
57
57
  },
58
58
  {
59
- "matcher": "Write",
59
+ "matcher": "Write|Edit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
60
60
  "hooks": [
61
61
  {
62
62
  "type": "command",
package/hooks/lock-gate CHANGED
@@ -1,6 +1,15 @@
1
1
  #!/bin/bash
2
2
  INPUT=$(cat)
3
- FILE_PATH=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("tool_params", "file_path") || data.dig("tool_input", "file_path") || ""' 2>/dev/null)
3
+ FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
4
+ data = JSON.parse(STDIN.read)
5
+ ti = data["tool_input"] || data["tool_params"] || {}
6
+ path = ti["file_path"] || ti["notebook_path"] || ti["relative_path"] || ""
7
+ if !path.empty? && !path.start_with?("/")
8
+ root = ti["project_root"] || data["cwd"] || ""
9
+ path = File.join(root, path) unless root.empty?
10
+ end
11
+ puts path
12
+ ' 2>/dev/null)
4
13
 
5
14
  if [ -z "$FILE_PATH" ]; then
6
15
  exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-beta.24",
3
+ "version": "1.0.0-beta.26",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.rb CHANGED
@@ -20,6 +20,9 @@ require_relative "lib/intent_validator"
20
20
  require_relative "lib/graph_rebuild"
21
21
  require_relative "lib/links_projection"
22
22
  require_relative "lib/links_section"
23
+ require_relative "lib/hook_registry"
24
+ require_relative "lib/lock"
25
+ require_relative "lib/bridge"
23
26
 
24
27
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
25
28
  # run it hermetically (no eval, no global-constant rewriting).
@@ -504,6 +507,184 @@ class Doctor
504
507
  checks
505
508
  end
506
509
 
510
+ # --- Check: the three done-signals agree + stalled completion (intent 93) ---
511
+ #
512
+ # "Done" is one law with three signals that MUST agree: INDEX
513
+ # `## Completed`/`## Abandoned` is the single canonical terminal marker;
514
+ # `outcome.md` is the "deliverable exists" signal (real, non-placeholder, with a
515
+ # `disposition:` header); the savepoint `Done` line is the audit echo. INDEX
516
+ # wins on conflict. This check flags any disagreement, and separately surfaces a
517
+ # "stalled completion": an intent that is terminal in INDEX but whose End tail
518
+ # never released its `delivery.lock` (the post-done window never closed). Read
519
+ # only, dependency-light: it uses INDEX parsing, file presence, the placeholder
520
+ # sentinel, and `Lock.fresh?` — no new lock, no 111 lock-liveness surface.
521
+
522
+ # For one store's INDEX.md, map each referenced intent directory name to the
523
+ # `## ...` section(s) it appears under: { "<id>--<slug>" => ["Active", ...] }.
524
+ def index_sections_by_dir(index_path)
525
+ sections = Hash.new { |h, k| h[k] = [] }
526
+ return sections unless File.exist?(index_path)
527
+
528
+ current = nil
529
+ File.foreach(index_path) do |line|
530
+ if (m = line.match(/^##\s+(.+?)\s*$/))
531
+ current = m[1]
532
+ next
533
+ end
534
+ next unless current
535
+
536
+ line.scan(%r{store/([\w][\w.-]*?)(?:/|\))}) do |(dirname)|
537
+ sections[dirname] << current unless sections[dirname].include?(current)
538
+ end
539
+ end
540
+ sections
541
+ end
542
+
543
+ # Stores to reconcile: the global store plus each project store, filtered by
544
+ # `scopes` (nil = all) exactly like check_conventions.
545
+ def done_signal_stores(scopes)
546
+ stores = []
547
+
548
+ if scopes.nil? || scopes.include?("global")
549
+ stores << {
550
+ scope: "global",
551
+ index: File.join(plastic_home, "INDEX.md"),
552
+ store_dir: File.join(plastic_home, "store"),
553
+ }
554
+ end
555
+
556
+ projects_root = File.join(plastic_home, "projects")
557
+ if File.directory?(projects_root)
558
+ Dir.children(projects_root).sort.each do |project|
559
+ scope = "project:#{project}"
560
+ next unless scopes.nil? || scopes.include?(scope)
561
+
562
+ store_dir = File.join(projects_root, project, "store")
563
+ next unless File.directory?(store_dir)
564
+
565
+ stores << {
566
+ scope: scope,
567
+ index: File.join(projects_root, project, "INDEX.md"),
568
+ store_dir: store_dir,
569
+ }
570
+ end
571
+ end
572
+
573
+ stores
574
+ end
575
+
576
+ def check_done_signals(scopes: nil)
577
+ conflicts = [] # canonical INDEX-wins disagreement (fail)
578
+ gaps = [] # terminal completeness gaps on immutable/legacy history (warn)
579
+ stalled = [] # terminal but End tail unfinished (warn)
580
+
581
+ done_signal_stores(scopes).each do |store|
582
+ index_sections_by_dir(store[:index]).each do |dirname, in_sections|
583
+ dir = File.join(store[:store_dir], dirname)
584
+ next unless File.directory?(dir)
585
+
586
+ terminal = (in_sections & ["Completed", "Abandoned"]).any?
587
+ active = in_sections.include?("Active") && !terminal
588
+ outcome = File.join(dir, "outcome.md")
589
+ outcome_real = Bridge.stage_file_present?(outcome)
590
+ label = "#{store[:scope]} store/#{dirname}"
591
+
592
+ # HARD conflict: the deliverable exists but INDEX still says Active. This
593
+ # is the one true INDEX-wins disagreement, so it stays a fail.
594
+ if active && outcome_real
595
+ conflicts << "#{label}: outcome.md is real but the intent is still under ## Active " \
596
+ "(INDEX is canonical — move it to its terminal section or revert outcome.md)"
597
+ end
598
+
599
+ # Completeness gap (warn, not fail): a terminal intent whose outcome.md is
600
+ # missing/placeholder. Legacy terminal intents predate this convention and
601
+ # are immutable, so this is advisory, never breakage.
602
+ if terminal && !outcome_real
603
+ state = File.exist?(outcome) ? "still a placeholder" : "missing"
604
+ gaps << "#{label}: terminal in INDEX but outcome.md is #{state} " \
605
+ "(author outcome.md with the disposition header)"
606
+ end
607
+
608
+ next unless terminal
609
+
610
+ # Audit echo (weakest signal, D1): the savepoint should carry a
611
+ # `Done delivered|abandoned` line. Missing on legacy history -> advisory gap.
612
+ savepoint = File.join(dir, "savepoint.md")
613
+ if File.exist?(savepoint) && File.read(savepoint) !~ /\bDone\b.*\b(delivered|abandoned)\b/
614
+ gaps << "#{label}: terminal in INDEX but savepoint.md has no " \
615
+ "`Done delivered|abandoned` line (audit echo missing)"
616
+ end
617
+
618
+ # Stalled completion: the End tail never released the delivery lock, so the
619
+ # post-done window `[INDEX terminal -> Lock.release]` never closed.
620
+ if File.exist?(Lock.path(dir))
621
+ note = Lock.fresh?(dir) ? "delivery.lock still present (post-done window not closed)"
622
+ : "delivery.lock is present and STALE"
623
+ stalled << "#{label}: #{note} — the End tail did not finish"
624
+ end
625
+ end
626
+ end
627
+
628
+ checks = []
629
+
630
+ # signals_agree: only the canonical INDEX-wins conflict is a hard fail, so
631
+ # `doctor` never goes red on immutable legacy terminal intents.
632
+ if conflicts.empty?
633
+ checks << check(
634
+ category: "done_signals", name: "signals_agree", status: "pass",
635
+ message: "No done-signal conflicts (no intent has a real outcome.md while still Active)"
636
+ )
637
+ else
638
+ checks << check(
639
+ category: "done_signals", name: "signals_agree", status: "fail",
640
+ message: "#{conflicts.size} done-signal conflict#{conflicts.size == 1 ? "" : "s"} " \
641
+ "(INDEX ## Completed/## Abandoned is canonical; a real outcome.md must not stay Active)",
642
+ details: conflicts, fixable: true,
643
+ fix_hint: "Reconcile to INDEX (canonical): move the intent to its terminal section, or revert " \
644
+ "outcome.md to a placeholder. Deliverable-exists but still-Active is the one done-signal " \
645
+ "state that must never persist."
646
+ )
647
+ end
648
+
649
+ # signals_complete: terminal completeness gaps are advisory (warn), because
650
+ # completed intents are immutable and legacy history predates this convention.
651
+ if gaps.empty?
652
+ checks << check(
653
+ category: "done_signals", name: "signals_complete", status: "pass",
654
+ message: "Every terminal intent carries a real outcome.md and a Done savepoint echo"
655
+ )
656
+ else
657
+ checks << check(
658
+ category: "done_signals", name: "signals_complete", status: "warn",
659
+ message: "#{gaps.size} terminal completeness gap#{gaps.size == 1 ? "" : "s"} " \
660
+ "(outcome.md or the savepoint Done echo is missing; advisory on immutable history)",
661
+ details: gaps, fixable: true,
662
+ fix_hint: "For live terminals, author a real outcome.md with the `disposition:` header via the " \
663
+ "completion/abandon path (plastic-auto or plastic-intent-curator) and stamp the terminal " \
664
+ "savepoint. Legacy terminal intents are immutable, so pre-convention gaps stay advisory."
665
+ )
666
+ end
667
+
668
+ if stalled.empty?
669
+ checks << check(
670
+ category: "done_signals", name: "stalled_completion", status: "pass",
671
+ message: "No stalled completions (every terminal intent released its delivery lock)"
672
+ )
673
+ else
674
+ checks << check(
675
+ category: "done_signals", name: "stalled_completion", status: "warn",
676
+ message: "#{stalled.size} stalled completion#{stalled.size == 1 ? "" : "s"} " \
677
+ "(terminal in INDEX but the End tail did not finish)",
678
+ details: stalled, fixable: true,
679
+ fix_hint: "Finish the End tail via stale-lock reclaim: `plastic-lock reclaim`, then complete the " \
680
+ "tail (Worktree.release -> Lock.release -> purge -> QMD reindex last). This FINISHES a " \
681
+ "completion; it is NOT a reactivation of a done intent."
682
+ )
683
+ end
684
+
685
+ checks
686
+ end
687
+
507
688
  # Build the cross-store node maps (basename + label per store) + relocation map
508
689
  # from ALL stores, then for every intent compute its canonical `## Links`
509
690
  # projection and flag any whose ACTUAL `## Links` section differs (membership or
@@ -827,6 +1008,45 @@ class Doctor
827
1008
  fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
828
1009
  )
829
1010
  end
1011
+
1012
+ # hooks_match_registry (intent 108, D7): the live settings must carry
1013
+ # EXACTLY the registrations HookRegistry defines; any drift (a missing
1014
+ # gate, a stray plastic hook, a stale matcher) is how bash-gate shipped
1015
+ # dead once already.
1016
+ expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
1017
+ diffs = []
1018
+ expected.each do |event, group|
1019
+ groups = group.is_a?(Array) ? group : [group]
1020
+ live = settings.dig("hooks", event) || []
1021
+ groups.each do |g|
1022
+ match = live.find { |h| h.is_a?(Hash) && h["matcher"] == g["matcher"] }
1023
+ wanted = g["hooks"].map { |h| h["command"] }
1024
+ got = match ? Array(match["hooks"]).map { |h| h["command"] } : []
1025
+ missing = wanted - got
1026
+ diffs << "#{event}[#{g['matcher']}] missing: #{missing.join(', ')}" unless missing.empty?
1027
+ end
1028
+ end
1029
+ live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
1030
+ Array(groups).flat_map do |g|
1031
+ next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
1032
+ g["hooks"].map { |h| h["command"].to_s }.select { |c| c.include?("plastic-") }
1033
+ .map { |c| "#{event}: #{c}" }
1034
+ end
1035
+ end
1036
+ expected_cmds = expected.values.flat_map { |g| g.is_a?(Array) ? g : [g] }
1037
+ .flat_map { |g| g["hooks"].map { |h| h["command"] } }
1038
+ strays = live_plastic.reject { |lp| expected_cmds.any? { |c| lp.end_with?(c) } }
1039
+ diffs.concat(strays.map { |s| "stray: #{s}" })
1040
+
1041
+ checks << if diffs.empty?
1042
+ check(category: "agent_registration", name: "hooks_match_registry",
1043
+ status: "pass", message: "settings.json hooks match HookRegistry")
1044
+ else
1045
+ check(category: "agent_registration", name: "hooks_match_registry",
1046
+ status: "fail", message: "#{diffs.size} hook registration(s) diverge from HookRegistry",
1047
+ details: diffs, fixable: true,
1048
+ fix_hint: "Re-run the installer merge: npx @zalom/plastic update (or ruby ~/.plastic/scripts/install.rb)")
1049
+ end
830
1050
  end
831
1051
 
832
1052
  # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
@@ -1417,6 +1637,7 @@ class Doctor
1417
1637
  all_checks += check_project_stores
1418
1638
  all_checks += check_deprecations
1419
1639
  all_checks += check_qmd
1640
+ all_checks += check_done_signals
1420
1641
 
1421
1642
  summarize(all_checks, agent_key)
1422
1643
  end
@@ -1443,9 +1664,9 @@ class Doctor
1443
1664
  all_checks =
1444
1665
  case store
1445
1666
  when :all
1446
- check_global_store + check_project_stores + check_conventions
1667
+ check_global_store + check_project_stores + check_conventions + check_done_signals
1447
1668
  when :global
1448
- check_global_store + check_conventions(scopes: ["global"])
1669
+ check_global_store + check_conventions(scopes: ["global"]) + check_done_signals(scopes: ["global"])
1449
1670
  else
1450
1671
  all_checks_for_project_slug(store)
1451
1672
  end
@@ -1468,7 +1689,8 @@ class Doctor
1468
1689
  end
1469
1690
 
1470
1691
  check_project_store(slug, projects[slug]) +
1471
- check_conventions(scopes: ["project:#{slug}"])
1692
+ check_conventions(scopes: ["project:#{slug}"]) +
1693
+ check_done_signals(scopes: ["project:#{slug}"])
1472
1694
  end
1473
1695
 
1474
1696
  # Roll a list of checks up into the standard result envelope.
@@ -28,13 +28,31 @@ exit 0 if command.nil? || command.to_s.strip.empty?
28
28
  cwd = payload["cwd"]
29
29
  cwd = Dir.pwd if cwd.nil? || cwd.to_s.empty?
30
30
 
31
- # --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
32
31
  session = payload["session_id"]
33
32
  session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.to_s.empty?
33
+
34
+ # Auditable escape (intent 108, D7): a trailing `# plastic-ok` allows the
35
+ # command and logs it, so sanctioned writes are visible, not silent.
36
+ if Bridge.bash_escape?(command)
37
+ begin
38
+ require "fileutils"
39
+ log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
40
+ FileUtils.mkdir_p(File.dirname(log))
41
+ File.open(log, "a") do |io|
42
+ io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{command.gsub(/\s+/, ' ').strip}")
43
+ end
44
+ rescue StandardError
45
+ # the escape still applies; logging is best-effort
46
+ end
47
+ exit 0
48
+ end
49
+
50
+ # --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
51
+ # A nil bridge does NOT short-circuit (intent 108): the lock gate decides from
52
+ # the durable delivery.lock file, so it must run even without a bridge cache.
34
53
  bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
35
- exit 0 unless bridge_data
36
54
 
37
- reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd)
55
+ reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd, session: session)
38
56
  exit 0 unless reason
39
57
 
40
58
  $stderr.puts "PLASTIC GATE — #{reason}"
@@ -19,8 +19,13 @@
19
19
  # Reads the Claude Code PreToolUse payload as JSON on STDIN:
20
20
  # { "tool_input": { "file_path": "...", "content": "..." } }
21
21
  # Empty / unparseable / non-matching path => exit 0 (cannot judge, allow).
22
- # Matching path but missing content => exit 2 (fail-safe: refuse to allow an
23
- # unvalidated intent write).
22
+ # Three payload shapes on a matching path (intent 108, D7):
23
+ # Write (content): validate the proposed content.
24
+ # Edit (old_string): simulate the replacement on the on-disk file and
25
+ # validate the RESULT; a missing file blocks.
26
+ # Pathless MCP mutation: validate the CURRENT on-disk file (the PostToolUse
27
+ # gate-check backstop validates the result); a
28
+ # missing file blocks (fail-safe).
24
29
 
25
30
  require "json"
26
31
  require_relative "lib/intent_validator"
@@ -43,14 +48,45 @@ is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
43
48
  File.basename(abs) == "#{File.basename(dir)}.md"
44
49
  exit 0 unless is_intent_file
45
50
 
46
- content = payload.dig("tool_input", "content") || payload.dig("tool_params", "content")
47
- if content.nil?
48
- $stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed content; " \
49
- "refusing to allow an unvalidated intent write."
51
+ input = payload["tool_input"] || payload["tool_params"] || {}
52
+ content = input["content"]
53
+ old_string = input["old_string"]
54
+
55
+ def block!(lines)
56
+ Array(lines).each { |l| $stderr.puts l }
50
57
  exit 2
51
58
  end
52
59
 
53
- result = IntentValidator.validate_content(content)
60
+ result =
61
+ if !content.nil?
62
+ # Write: validate the proposed content (intent 60b, unchanged).
63
+ IntentValidator.validate_content(content)
64
+ elsif !old_string.nil?
65
+ # Edit (intent 108, D7): simulate the replacement on the on-disk file and
66
+ # validate the RESULT, so an Edit can no longer sneak an intent file past
67
+ # the validator.
68
+ unless File.exist?(abs)
69
+ block!("PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
70
+ "create intents via new-intent / plastic-creating-intent.")
71
+ end
72
+ current = File.read(abs)
73
+ unless current.include?(old_string)
74
+ exit 0 # the Edit tool itself will fail; nothing lands, nothing to judge
75
+ end
76
+ simulated = input["replace_all"] ? current.gsub(old_string, input["new_string"].to_s)
77
+ : current.sub(old_string, input["new_string"].to_s)
78
+ IntentValidator.validate_content(simulated)
79
+ else
80
+ # Pathless mutation (MCP symbolic edits): the proposal is not visible at
81
+ # PreToolUse. Judge the CURRENT file: valid -> allow (the PostToolUse
82
+ # gate-check backstop validates the result), invalid/missing -> block.
83
+ unless File.exist?(abs)
84
+ block!("PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed " \
85
+ "content and no file exists; refusing to allow an unvalidated intent write.")
86
+ end
87
+ IntentValidator.validate_content(File.read(abs))
88
+ end
89
+
54
90
  exit 0 if result[:ok]
55
91
 
56
92
  $stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
@@ -9,6 +9,7 @@
9
9
  require "json"
10
10
  require_relative "lib/bridge"
11
11
  require_relative "lib/intent_validator"
12
+ require_relative "lib/lock"
12
13
 
13
14
  file_path = ARGV[0]
14
15
  exit 0 unless file_path && !file_path.empty?
@@ -72,6 +73,15 @@ intent_dir = "#{store}/#{dir}"
72
73
  # Normalize both paths for comparison
73
74
  bridge_intent_dir_abs = File.expand_path(intent_dir)
74
75
 
76
+ # Lease heartbeat (intent 108, D1): every write by the session that owns (or
77
+ # delegates under) the lock refreshes the delivery.lock mtime. Best-effort:
78
+ # a heartbeat failure must never break the gate hook.
79
+ begin
80
+ Lock.heartbeat(bridge_intent_dir_abs, session: session)
81
+ rescue StandardError
82
+ # ignore
83
+ end
84
+
75
85
  # Not inside intent dir = not our business
76
86
  exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
77
87