@zalom/plastic 1.0.0-beta.9 → 1.0.1

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.
Files changed (107) hide show
  1. package/PLASTIC-reference.md +133 -0
  2. package/PLASTIC.md +398 -82
  3. package/README.md +98 -82
  4. package/agents/plastic-brainstorming.md +12 -9
  5. package/agents/plastic-enforcer.md +52 -12
  6. package/agents/plastic-executor.md +10 -10
  7. package/agents/plastic-future-intent-researcher.md +4 -8
  8. package/agents/plastic-intent-curator.md +12 -14
  9. package/agents/plastic-intent-discovery.md +35 -0
  10. package/agents/plastic-planner.md +15 -10
  11. package/agents/plastic-spec-specialist.md +15 -10
  12. package/bin/plastic.js +7 -3
  13. package/hooks/code-gate +17 -2
  14. package/hooks/hooks.json +28 -3
  15. package/hooks/lock-gate +21 -0
  16. package/hooks/retrieval-gate +10 -0
  17. package/hooks/savepoint-pre +10 -0
  18. package/hooks/statusline +48 -6
  19. package/package.json +2 -1
  20. package/scripts/agent-report +21 -0
  21. package/scripts/dashboard.rb +98 -23
  22. package/scripts/doctor.rb +232 -5
  23. package/scripts/hook-auto-arm +1 -1
  24. package/scripts/hook-bash-gate +22 -4
  25. package/scripts/hook-code-gate +30 -7
  26. package/scripts/hook-continue +17 -0
  27. package/scripts/hook-create-gate +45 -9
  28. package/scripts/hook-gate-check +24 -23
  29. package/scripts/hook-lock-gate +83 -0
  30. package/scripts/hook-retrieval-gate +148 -0
  31. package/scripts/hook-savepoint-pre +32 -0
  32. package/scripts/hook-session-start +1 -1
  33. package/scripts/insight-append +51 -0
  34. package/scripts/install.rb +44 -7
  35. package/scripts/lib/agent_models.rb +43 -0
  36. package/scripts/lib/bridge.rb +924 -84
  37. package/scripts/lib/dashboard_banner.rb +42 -0
  38. package/scripts/lib/hook_registry.rb +95 -0
  39. package/scripts/lib/insights.rb +86 -0
  40. package/scripts/lib/installer_core.rb +101 -60
  41. package/scripts/lib/link_suggestions.rb +319 -0
  42. package/scripts/lib/lock.rb +375 -0
  43. package/scripts/lib/power_tools.rb +22 -21
  44. package/scripts/lib/preflight.rb +79 -0
  45. package/scripts/lib/qmd_sync.rb +48 -6
  46. package/scripts/lib/retrieval_gate.rb +211 -0
  47. package/scripts/lib/worktree.rb +384 -0
  48. package/scripts/link-suggest +213 -0
  49. package/scripts/new-intent +58 -17
  50. package/scripts/plastic-lock +164 -0
  51. package/scripts/read-config +4 -0
  52. package/scripts/spawn-preamble +36 -4
  53. package/scripts/update.rb +16 -7
  54. package/skills/auto/SKILL.md +127 -20
  55. package/skills/auto/references/agent-architecture.md +7 -4
  56. package/skills/auto/references/agent-report-contract.md +36 -1
  57. package/skills/auto/references/end-tail.md +56 -0
  58. package/skills/auto/references/human-report-contract.md +55 -0
  59. package/skills/auto/references/tiers.md +77 -0
  60. package/skills/brainstorming/SKILL.md +7 -34
  61. package/skills/brainstorming/references/design-principles.md +49 -0
  62. package/skills/continuing/SKILL.md +26 -7
  63. package/skills/creating-intent/SKILL.md +13 -28
  64. package/skills/creating-project/SKILL.md +11 -74
  65. package/skills/creating-project/references/project-scaffolding.md +97 -0
  66. package/skills/creating-skills/SKILL.md +65 -0
  67. package/skills/creating-skills/evals/evals.json +108 -0
  68. package/skills/creating-skills/references/agents.md +168 -0
  69. package/skills/creating-skills/references/evals.md +41 -0
  70. package/skills/creating-skills/references/hooks.md +248 -0
  71. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  72. package/skills/creating-skills/references/scripts.md +166 -0
  73. package/skills/creating-skills/references/skills.md +165 -0
  74. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  75. package/skills/dashboard/SKILL.md +13 -11
  76. package/skills/dashboard/references/classification.md +22 -0
  77. package/skills/doctor/SKILL.md +10 -7
  78. package/skills/executing-plan/SKILL.md +4 -4
  79. package/skills/humanizer/SKILL.md +39 -0
  80. package/skills/humanizer/evals/evals.json +70 -0
  81. package/skills/humanizer/references/always-on-snippet.md +9 -0
  82. package/skills/humanizer/references/examples.md +48 -0
  83. package/skills/install/SKILL.md +75 -84
  84. package/skills/intent-curator/SKILL.md +5 -1
  85. package/skills/intent-discovery/SKILL.md +47 -0
  86. package/skills/intent-starting/SKILL.md +130 -0
  87. package/skills/intent-starting/evals/evals.json +117 -0
  88. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  89. package/skills/linking-intents/SKILL.md +48 -12
  90. package/skills/lock/SKILL.md +41 -0
  91. package/skills/managing-index/SKILL.md +6 -0
  92. package/skills/releasing/SKILL.md +44 -29
  93. package/skills/releasing/references/promotion-and-tagging.md +60 -0
  94. package/skills/roadmap/SKILL.md +51 -0
  95. package/skills/roadmap/references/file-format.md +91 -0
  96. package/skills/roadmap/references/operations.md +88 -0
  97. package/skills/uninstall/SKILL.md +29 -11
  98. package/skills/update/SKILL.md +34 -23
  99. package/skills/versions/SKILL.md +27 -12
  100. package/skills/writing-plans/SKILL.md +10 -88
  101. package/skills/writing-plans/references/plan-format.md +102 -0
  102. package/templates/config.yml +8 -0
  103. package/templates/outcome.md +3 -0
  104. package/templates/revisions.md +58 -0
  105. package/templates/roadmap.md +30 -0
  106. package/skills/writing-instructions/SKILL.md +0 -159
  107. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
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).
@@ -472,7 +475,10 @@ class Doctor
472
475
  category: "conventions", name: "section_structure", status: "warn",
473
476
  message: "#{bad_sections.size} intent file(s) have non-sanctioned ## sections",
474
477
  details: bad_sections.map { |b| "#{b[:dir]}: #{b[:issues].join(", ")}" },
475
- fixable: false
478
+ fixable: true,
479
+ fix_hint: "Dispatch plastic-intent-curator to relocate each unsanctioned section into the " \
480
+ "intent's revisions.md via move-and-record (a missing required section is restored " \
481
+ "or reprojected instead); see PLASTIC.md > Structural maintenance and revisions.md"
476
482
  )
477
483
  end
478
484
 
@@ -501,6 +507,184 @@ class Doctor
501
507
  checks
502
508
  end
503
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
+
504
688
  # Build the cross-store node maps (basename + label per store) + relocation map
505
689
  # from ALL stores, then for every intent compute its canonical `## Links`
506
690
  # projection and flag any whose ACTUAL `## Links` section differs (membership or
@@ -698,7 +882,9 @@ class Doctor
698
882
  checks << graph_finding_check(
699
883
  "graph_i4_danglers", i4,
700
884
  "Every sources/chain id resolves to a real intent (I4)",
701
- "Fix or remove the dangling id reference"
885
+ "Dispatch plastic-intent-curator to record the dangling sources/chain edge as a " \
886
+ "broken-source/broken-chain move-and-record entry in the intent's revisions.md (see " \
887
+ "PLASTIC.md > Structural maintenance and revisions.md), or restore the missing intent"
702
888
  )
703
889
  checks
704
890
  end
@@ -822,6 +1008,45 @@ class Doctor
822
1008
  fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
823
1009
  )
824
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
+ matches = live.select { |h| h.is_a?(Hash) && h["matcher"] == g["matcher"] }
1023
+ wanted = g["hooks"].map { |h| h["command"] }
1024
+ got = matches.flat_map { |m| Array(m["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
825
1050
  end
826
1051
 
827
1052
  # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
@@ -1412,6 +1637,7 @@ class Doctor
1412
1637
  all_checks += check_project_stores
1413
1638
  all_checks += check_deprecations
1414
1639
  all_checks += check_qmd
1640
+ all_checks += check_done_signals
1415
1641
 
1416
1642
  summarize(all_checks, agent_key)
1417
1643
  end
@@ -1438,9 +1664,9 @@ class Doctor
1438
1664
  all_checks =
1439
1665
  case store
1440
1666
  when :all
1441
- check_global_store + check_project_stores + check_conventions
1667
+ check_global_store + check_project_stores + check_conventions + check_done_signals
1442
1668
  when :global
1443
- check_global_store + check_conventions(scopes: ["global"])
1669
+ check_global_store + check_conventions(scopes: ["global"]) + check_done_signals(scopes: ["global"])
1444
1670
  else
1445
1671
  all_checks_for_project_slug(store)
1446
1672
  end
@@ -1463,7 +1689,8 @@ class Doctor
1463
1689
  end
1464
1690
 
1465
1691
  check_project_store(slug, projects[slug]) +
1466
- check_conventions(scopes: ["project:#{slug}"])
1692
+ check_conventions(scopes: ["project:#{slug}"]) +
1693
+ check_done_signals(scopes: ["project:#{slug}"])
1467
1694
  end
1468
1695
 
1469
1696
  # Roll a list of checks up into the standard result envelope.
@@ -26,7 +26,7 @@ if triggered
26
26
  end
27
27
 
28
28
  # --- 2. Standing gate reminder when armed and pre-How ---
29
- bridge_data = Bridge.discover_bridge(session: ENV["CLAUDE_SESSION_ID"], cwd: Dir.pwd)
29
+ bridge_data = Bridge.discover_bridge(session: ENV["CLAUDE_CODE_SESSION_ID"], cwd: Dir.pwd)
30
30
  if bridge_data && bridge_data.dig("build", "auto") == true
31
31
  intent = bridge_data["intent"] || {}
32
32
  store = intent["store"]; dir = intent["dir"]
@@ -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 -> env -> /tmp scan) ---
32
31
  session = payload["session_id"]
33
- session = ENV["CLAUDE_SESSION_ID"] if session.nil? || session.to_s.empty?
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}"
@@ -3,24 +3,47 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  # Usage: hook-code-gate <file_path>
6
- # PreToolUse gate (intent 27): when auto mode is armed and the active intent has not
7
- # reached How (plan.md + checklist.md), block edits to project code outside the store.
6
+ # PreToolUse gate. Composes two independent block rules; EITHER blocks the edit:
7
+ # - Stage rule (intent 27): when auto mode is armed and the active intent has not
8
+ # reached How (plan.md + checklist.md), block edits to project code outside the store.
9
+ # - Worktree isolation rule (intent 73c2): when the intent has a provisioned code
10
+ # worktree, block project-code edits outside it; and block edits to another
11
+ # intent's store dir whose delivery lock is held by a live non-owner session.
8
12
  #
9
13
  # Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
10
- # No bridge / auto not armed / How reached / path under ~/.plastic or the intent dir = allow.
14
+ # No bridge = allow. Each rule fails open on its own conditions (see bridge.rb).
11
15
 
12
16
  require_relative "lib/bridge"
13
17
 
14
18
  file_path = ARGV[0]
15
19
  exit 0 unless file_path && !file_path.empty?
16
20
 
17
- session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
21
+ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
18
22
 
19
- # --- Load bridge (shared resolution; stdin session_id -> env -> /tmp scan) ---
20
- bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
23
+ # Auditable escape (intent 150, mirrors the Bash gate's intent 108 D7): a trailing
24
+ # `# plastic-ok` in the new Write/Edit content allows the edit and logs it, so a
25
+ # deliberately sanctioned code edit no longer needs the Bash-funneling workaround.
26
+ new_content = ARGV[2]
27
+ if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
28
+ begin
29
+ require "fileutils"
30
+ log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
31
+ FileUtils.mkdir_p(File.dirname(log))
32
+ File.open(log, "a") do |io|
33
+ io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{file_path}")
34
+ end
35
+ rescue StandardError
36
+ # the escape still applies; logging is best-effort
37
+ end
38
+ exit 0
39
+ end
40
+
41
+ # --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
42
+ bridge_data = Bridge.discover_bridge(session: session, cwd: file_path)
21
43
  exit 0 unless bridge_data
22
44
 
23
- reason = Bridge.code_gate_decision(bridge_data, file_path)
45
+ reason = Bridge.code_gate_decision(bridge_data, file_path) ||
46
+ Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
24
47
  exit 0 unless reason
25
48
 
26
49
  $stderr.puts "PLASTIC GATE — #{reason}"
@@ -7,6 +7,7 @@
7
7
 
8
8
  require "json"
9
9
  require "open3"
10
+ require_relative "lib/dashboard_banner"
10
11
 
11
12
  index_path, _store_root, _mode = ARGV
12
13
  exit 0 unless index_path && File.exist?(index_path)
@@ -28,4 +29,20 @@ payload = {
28
29
  "additionalContext" => context
29
30
  }
30
31
  }
32
+
33
+ # Hook-owned systemMessage floor (intent 125, Task 6): a hard fallback summary
34
+ # independent of the agent's reply, mirroring hook-session-start's BootBanner
35
+ # pattern. Best-effort only — any failure here (subprocess, JSON, renderer)
36
+ # degrades silently, omitting systemMessage; this hook must never crash
37
+ # UserPromptSubmit over a summary line.
38
+ begin
39
+ data_json, _data_err, data_status = Open3.capture3("ruby", dashboard, "continue", "--data")
40
+ if data_status.success?
41
+ banner = DashboardBanner.render(JSON.parse(data_json))
42
+ payload["systemMessage"] = banner if banner
43
+ end
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
31
48
  puts JSON.generate(payload)
@@ -9,8 +9,8 @@
9
9
  # the on-disk file (which does not exist yet at PreToolUse), using IntentValidator
10
10
  # for born-complete frontmatter plus the sanctioned section structure.
11
11
  #
12
- # It depends ONLY on the stdin path + content, never on the auto-bridge or
13
- # CLAUDE_SESSION_ID, so it runs unconditionally (headless / background sessions).
12
+ # It depends ONLY on the stdin path + content, never on the auto-bridge or any
13
+ # session id, so it runs unconditionally (headless / background sessions).
14
14
  # It validates ONLY the intent file, never sentinel placeholder lifecycle files
15
15
  # (spec.md/plan.md/etc.); the path matcher excludes them.
16
16
  #
@@ -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,11 +9,12 @@
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?
15
16
 
16
- session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
17
+ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
17
18
 
18
19
  file_path_abs = File.expand_path(file_path)
19
20
 
@@ -24,6 +25,12 @@ intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
24
25
  if intent_dir_abs
25
26
  begin
26
27
  Bridge.append_savepoint(intent_dir_abs, file_path_abs)
28
+ # When checklist.md lands, How ends and Exec begins: emit the `Exec started`
29
+ # companion in the same event (intent 81). Guard on a real (non-placeholder)
30
+ # checklist so a scaffold sentinel does not trip it.
31
+ if File.basename(file_path_abs) == "checklist.md" && Bridge.stage_file_present?(file_path_abs)
32
+ Bridge.append_exec_started(intent_dir_abs)
33
+ end
27
34
  rescue StandardError
28
35
  # ignore — rebuildable from disk
29
36
  end
@@ -66,6 +73,15 @@ intent_dir = "#{store}/#{dir}"
66
73
  # Normalize both paths for comparison
67
74
  bridge_intent_dir_abs = File.expand_path(intent_dir)
68
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
+
69
85
  # Not inside intent dir = not our business
70
86
  exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
71
87
 
@@ -112,32 +128,17 @@ if is_stage_file || is_action_file
112
128
 
113
129
  Bridge.write(session, bridge_data)
114
130
 
115
- # Build transition context
116
- stage_labels = { "what" => "What", "why" => "Why", "how" => "How", "exec" => "Exec", "done" => "Done" }
117
- next_hints = {
118
- "why" => "write spec.md",
119
- "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
120
- "exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
121
- "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
122
- }
123
-
124
- context_parts = ["PLASTIC"]
125
- if old_stage != new_stage
126
- context_parts[0] = "PLASTIC — Stage transition: #{stage_labels[old_stage] || old_stage} → #{stage_labels[new_stage] || new_stage}."
127
- else
128
- context_parts[0] = "PLASTIC — #{basename} written (stage: #{stage_labels[new_stage] || new_stage})."
129
- end
130
- context_parts << "#{basename} written." if old_stage != new_stage
131
- if new_missing.any?
132
- context_parts << "Next: #{new_missing.join(", ")}"
133
- elsif next_hints[new_stage]
134
- context_parts << "Next: #{next_hints[new_stage]}"
135
- end
131
+ # Build transition context — ONE concise sentence (intent 84, Lever 1),
132
+ # preserving the `Next: ...` hint. Formatting is pure in Bridge.gate_narration.
133
+ context = Bridge.gate_narration(
134
+ old_stage: old_stage, new_stage: new_stage,
135
+ basename: basename, new_missing: new_missing
136
+ )
136
137
 
137
138
  payload = {
138
139
  "hookSpecificOutput" => {
139
140
  "hookEventName" => "PostToolUse",
140
- "additionalContext" => context_parts.join(" ")
141
+ "additionalContext" => context
141
142
  }
142
143
  }
143
144
  puts JSON.generate(payload)
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+ #
5
+ # Usage: hook-lock-gate <file_path> [session_id]
6
+ # Fail-CLOSED PreToolUse gate (intent 96). Blocks a mutating write to an active
7
+ # intent's lifecycle dir when THIS session holds no live lock. Project code is NOT
8
+ # gated here (D2). Emits the PreToolUse JSON deny contract at exit 0; reserves
9
+ # non-zero exit for hook-internal errors only. Matcher Write|Edit|NotebookEdit, so
10
+ # reads never reach here.
11
+ require "json"
12
+ require_relative "lib/bridge"
13
+ require_relative "lib/lock"
14
+
15
+ begin
16
+ file_path = ARGV[0]
17
+ exit 0 unless file_path && !file_path.empty?
18
+ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
19
+
20
+ bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no bridge cache
21
+ reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session)
22
+ unless reason
23
+ # Allow path: refresh the lease for the session that holds this target's
24
+ # lock (owner or delegate). Best-effort, never blocks.
25
+ begin
26
+ dir = Bridge.intent_dir_for(file_path)
27
+ Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
28
+ rescue StandardError
29
+ # ignore
30
+ end
31
+
32
+ # Second, independent gate (intent 111 D7): the artifact-grain claim.
33
+ # Composes UNDER the delivery lock above; only reached once the delivery
34
+ # lock already allows. Dormant (nil) unless a claim file exists for this
35
+ # artifact, so AC7 (existing single-owner flows) is unaffected.
36
+ begin
37
+ dir = Bridge.intent_dir_for(file_path)
38
+ artifact = File.basename(file_path) if dir
39
+ if dir && artifact
40
+ claim_reason = Claim.claim_gate_reason(dir, artifact, session: session)
41
+ if claim_reason
42
+ print JSON.generate(
43
+ "hookSpecificOutput" => {
44
+ "hookEventName" => "PreToolUse",
45
+ "permissionDecision" => "deny",
46
+ "permissionDecisionReason" => claim_reason,
47
+ }
48
+ )
49
+ exit 0
50
+ end
51
+
52
+ # Fail-open surface (AC3): allow, but make the yielded/stale claim visible.
53
+ if Claim.fail_open?(dir, artifact)
54
+ $stderr.puts "plastic claim gate: unresolvable claim on #{artifact} in " \
55
+ "intent #{Bridge.intent_id_from_dir(dir)} yielded; write " \
56
+ "proceeds (see /plastic-lock status)"
57
+ end
58
+
59
+ # Heartbeat the session's own claim on the allow path (best-effort).
60
+ Claim.heartbeat(dir, artifact, session: session) rescue nil
61
+ end
62
+ rescue StandardError
63
+ # ignore: a claim-gate bug must never block a write (fail open)
64
+ end
65
+
66
+ exit 0
67
+ end
68
+
69
+ print JSON.generate(
70
+ "hookSpecificOutput" => {
71
+ "hookEventName" => "PreToolUse",
72
+ "permissionDecision" => "deny",
73
+ "permissionDecisionReason" => reason # "run /plastic-intent-starting to lock and begin"
74
+ }
75
+ )
76
+ exit 0
77
+ rescue => e
78
+ # Hook-internal failure only: do NOT impersonate a user-deny. Log and fail open
79
+ # here so a gate bug never bricks editing; the fail-CLOSED intent is about MISSING
80
+ # locks, not crashes.
81
+ $stderr.puts "plastic lock-gate error: #{e.message}"
82
+ exit 0
83
+ end