@zalom/plastic 1.0.0-beta.8 → 1.0.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.
Files changed (116) hide show
  1. package/PLASTIC-reference.md +133 -0
  2. package/PLASTIC.md +407 -84
  3. package/README.md +99 -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 +10 -1
  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 +404 -5
  23. package/scripts/hook-auto-arm +1 -1
  24. package/scripts/hook-bash-gate +22 -4
  25. package/scripts/hook-code-gate +11 -6
  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/frontmatter_writer.rb +130 -0
  39. package/scripts/lib/graph_rebuild.rb +328 -0
  40. package/scripts/lib/hook_registry.rb +95 -0
  41. package/scripts/lib/insights.rb +86 -0
  42. package/scripts/lib/installer_core.rb +104 -60
  43. package/scripts/lib/link_suggestions.rb +319 -0
  44. package/scripts/lib/links_projection.rb +160 -0
  45. package/scripts/lib/links_section.rb +207 -0
  46. package/scripts/lib/lock.rb +375 -0
  47. package/scripts/lib/power_tools.rb +22 -21
  48. package/scripts/lib/preflight.rb +79 -0
  49. package/scripts/lib/qmd_sync.rb +15 -0
  50. package/scripts/lib/retrieval_gate.rb +211 -0
  51. package/scripts/lib/worktree.rb +384 -0
  52. package/scripts/link-suggest +213 -0
  53. package/scripts/new-intent +138 -29
  54. package/scripts/plastic-lock +164 -0
  55. package/scripts/project-links +287 -0
  56. package/scripts/read-config +4 -0
  57. package/scripts/rebuild-graph +244 -0
  58. package/scripts/spawn-preamble +11 -3
  59. package/scripts/update.rb +16 -7
  60. package/skills/auto/SKILL.md +126 -19
  61. package/skills/auto/references/agent-architecture.md +7 -4
  62. package/skills/auto/references/agent-report-contract.md +36 -1
  63. package/skills/auto/references/end-tail.md +56 -0
  64. package/skills/auto/references/human-report-contract.md +55 -0
  65. package/skills/auto/references/tiers.md +77 -0
  66. package/skills/brainstorming/SKILL.md +7 -34
  67. package/skills/brainstorming/references/design-principles.md +49 -0
  68. package/skills/continuing/SKILL.md +26 -7
  69. package/skills/creating-intent/SKILL.md +13 -28
  70. package/skills/creating-intent/references/lifecycle.md +9 -4
  71. package/skills/creating-project/SKILL.md +11 -74
  72. package/skills/creating-project/references/project-scaffolding.md +97 -0
  73. package/skills/creating-skills/SKILL.md +65 -0
  74. package/skills/creating-skills/evals/evals.json +108 -0
  75. package/skills/creating-skills/references/agents.md +168 -0
  76. package/skills/creating-skills/references/evals.md +41 -0
  77. package/skills/creating-skills/references/hooks.md +248 -0
  78. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  79. package/skills/creating-skills/references/scripts.md +166 -0
  80. package/skills/creating-skills/references/skills.md +165 -0
  81. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  82. package/skills/dashboard/SKILL.md +13 -11
  83. package/skills/dashboard/references/classification.md +22 -0
  84. package/skills/doctor/SKILL.md +10 -7
  85. package/skills/executing-plan/SKILL.md +4 -4
  86. package/skills/humanizer/SKILL.md +39 -0
  87. package/skills/humanizer/evals/evals.json +70 -0
  88. package/skills/humanizer/references/always-on-snippet.md +9 -0
  89. package/skills/humanizer/references/examples.md +48 -0
  90. package/skills/install/SKILL.md +75 -84
  91. package/skills/intent-curator/SKILL.md +5 -1
  92. package/skills/intent-discovery/SKILL.md +47 -0
  93. package/skills/intent-starting/SKILL.md +130 -0
  94. package/skills/intent-starting/evals/evals.json +117 -0
  95. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  96. package/skills/linking-intents/SKILL.md +48 -12
  97. package/skills/linking-intents/references/zettelkasten.md +7 -0
  98. package/skills/lock/SKILL.md +41 -0
  99. package/skills/managing-index/SKILL.md +6 -0
  100. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  101. package/skills/releasing/SKILL.md +44 -29
  102. package/skills/releasing/references/promotion-and-tagging.md +60 -0
  103. package/skills/roadmap/SKILL.md +51 -0
  104. package/skills/roadmap/references/file-format.md +91 -0
  105. package/skills/roadmap/references/operations.md +78 -0
  106. package/skills/uninstall/SKILL.md +29 -11
  107. package/skills/update/SKILL.md +34 -23
  108. package/skills/versions/SKILL.md +27 -12
  109. package/skills/writing-plans/SKILL.md +10 -88
  110. package/skills/writing-plans/references/plan-format.md +102 -0
  111. package/templates/config.yml +8 -0
  112. package/templates/outcome.md +3 -0
  113. package/templates/revisions.md +58 -0
  114. package/templates/roadmap.md +30 -0
  115. package/skills/writing-instructions/SKILL.md +0 -159
  116. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
package/scripts/doctor.rb CHANGED
@@ -17,6 +17,12 @@ require "digest"
17
17
 
18
18
  require_relative "lib/qmd_sync"
19
19
  require_relative "lib/intent_validator"
20
+ require_relative "lib/graph_rebuild"
21
+ require_relative "lib/links_projection"
22
+ require_relative "lib/links_section"
23
+ require_relative "lib/hook_registry"
24
+ require_relative "lib/lock"
25
+ require_relative "lib/bridge"
20
26
 
21
27
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
22
28
  # run it hermetically (no eval, no global-constant rewriting).
@@ -469,7 +475,10 @@ class Doctor
469
475
  category: "conventions", name: "section_structure", status: "warn",
470
476
  message: "#{bad_sections.size} intent file(s) have non-sanctioned ## sections",
471
477
  details: bad_sections.map { |b| "#{b[:dir]}: #{b[:issues].join(", ")}" },
472
- 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"
473
482
  )
474
483
  end
475
484
 
@@ -480,9 +489,356 @@ class Doctor
480
489
  # flagged: validate_graph does not compute it.
481
490
  checks.concat(graph_invariant_checks(intent_dirs))
482
491
 
492
+ # cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
493
+ # `store:id` ref against the FULL store family via the relocation map
494
+ # (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
495
+ # Resolution always spans all stores even under `--store` scoping; only the
496
+ # REPORTED findings are filtered to refs originating in the scoped store(s).
497
+ checks << cross_store_resolution_check(scopes: scopes)
498
+
499
+ # graph_links_projection — the `## Links` section of every intent must EQUAL its
500
+ # canonical I5 frontmatter projection (intent 72), in BOTH set membership AND
501
+ # ordering (sources first, then chain). Recomputes the projection from each
502
+ # intent's sources/chain + the on-disk basenames using the SAME resolver the
503
+ # scripts/project-links tool uses, so the two can never diverge. Resolution
504
+ # spans all stores; only the REPORTED findings are filtered to the scoped store.
505
+ checks << links_projection_check(scopes: scopes)
506
+
507
+ checks
508
+ end
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
+
483
685
  checks
484
686
  end
485
687
 
688
+ # Build the cross-store node maps (basename + label per store) + relocation map
689
+ # from ALL stores, then for every intent compute its canonical `## Links`
690
+ # projection and flag any whose ACTUAL `## Links` section differs (membership or
691
+ # ordering drift), or whose projection raises UnresolvedRef. `scopes` (nil = full
692
+ # run) filters only the REPORTED findings by origin scope.
693
+ def links_projection_check(scopes: nil)
694
+ all_dirs = all_intent_dirs
695
+
696
+ store_index = Hash.new { |h, k| h[k] = [] }
697
+ node_index = Hash.new { |h, k| h[k] = {} }
698
+ intents = [] # { scope:, id:, sources:, chain:, path: }
699
+
700
+ all_dirs.each do |d|
701
+ md = File.join(d[:path], "#{d[:name]}.md")
702
+ next unless File.exist?(md)
703
+
704
+ fm = parse_frontmatter(md)
705
+ next unless fm.is_a?(Hash) && fm["id"]
706
+
707
+ id = fm["id"].to_s
708
+ store_index[d[:scope]] << id
709
+ node_index[d[:scope]][id] = { basename: d[:name], label: fm["intent"].to_s.strip }
710
+ intents << {
711
+ scope: d[:scope], id: id, path: md,
712
+ sources: Array(fm["sources"]).map(&:to_s),
713
+ chain: Array(fm["chain"]).map(&:to_s),
714
+ }
715
+ end
716
+
717
+ relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
718
+
719
+ findings = []
720
+ intents.each do |node|
721
+ next if scopes && !scopes.include?(node[:scope])
722
+
723
+ resolve = ->(ref) do
724
+ LinksProjection.resolve_ref_projection(
725
+ ref, referer_store: node[:scope],
726
+ relocation_map: relocation_map, store_index: store_index, node_index: node_index
727
+ )
728
+ end
729
+
730
+ begin
731
+ expected = LinksProjection.section(sources: node[:sources], chain: node[:chain], resolve: resolve)
732
+ actual = actual_links_section(node[:path])
733
+ rescue LinksProjection::UnresolvedRef => e
734
+ findings << "#{node[:id]} ## Links projection failed: #{e.message}"
735
+ next
736
+ rescue LinksSection::AmbiguousLinks => e
737
+ findings << "#{node[:id]} ## Links ambiguous: #{e.message}"
738
+ next
739
+ end
740
+
741
+ next if actual == expected
742
+
743
+ findings << "#{node[:id]} ## Links does not match its frontmatter projection (membership/ordering drift)"
744
+ end
745
+
746
+ graph_finding_check(
747
+ "graph_links_projection", findings,
748
+ "Every intent's ## Links equals its frontmatter projection (membership and ordering)",
749
+ "Run scripts/project-links to regenerate the canonical ## Links sections"
750
+ )
751
+ end
752
+
753
+ # Extract a file's ACTUAL REAL `## Links` section text (FENCE-AWARE), normalized
754
+ # to the canonical block shape the projection emits. Delegates to the shared
755
+ # LinksSection.extract_section so the doctor check and the project-links tool
756
+ # agree on the section location and never match a `## Links` heading inside an
757
+ # example code fence. Returns "" when the section is absent (which differs from
758
+ # any real projection, so a missing section is a finding).
759
+ def actual_links_section(path)
760
+ LinksSection.extract_section(IntentValidator.body_of(File.read(path)))
761
+ end
762
+
763
+ # Build the relocation map + cross-store store_index from ALL stores, then for
764
+ # every intent's cross-store `sources`/`chain` ref resolve it and flag:
765
+ # - DEAD: the target resolves nowhere
766
+ # - RELOCATED-STALE: the ref points at an old location the relocation log has
767
+ # moved (the resolved location differs from the literal ref), e.g. the
768
+ # `global:24` id-reuse hazard that direct resolution would silently accept.
769
+ # `scopes` (nil = full run) filters only the REPORTED findings by origin scope.
770
+ def cross_store_resolution_check(scopes: nil)
771
+ all_dirs = all_intent_dirs
772
+
773
+ # Per-scope node maps + store_index over the WHOLE family.
774
+ nodes_by_scope = Hash.new { |h, k| h[k] = {} }
775
+ store_index = Hash.new { |h, k| h[k] = [] }
776
+ all_dirs.each do |d|
777
+ md = File.join(d[:path], "#{d[:name]}.md")
778
+ next unless File.exist?(md)
779
+
780
+ fm = parse_frontmatter(md)
781
+ next unless fm.is_a?(Hash) && fm["id"]
782
+
783
+ id = fm["id"].to_s
784
+ store_index[d[:scope]] << id
785
+ nodes_by_scope[d[:scope]][id] = {
786
+ sources: Array(fm["sources"]).map(&:to_s),
787
+ chain: Array(fm["chain"]).map(&:to_s),
788
+ }
789
+ end
790
+
791
+ relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
792
+
793
+ findings = []
794
+ nodes_by_scope.each do |scope, nodes|
795
+ next if scopes && !scopes.include?(scope)
796
+
797
+ nodes.each do |id, edges|
798
+ %i[sources chain].each do |field|
799
+ edges[field].each do |ref|
800
+ next unless ref.include?(":") # only cross-store refs are resolved here
801
+
802
+ res = GraphRebuild.resolve_ref(ref, referer_store: scope,
803
+ relocation_map: relocation_map,
804
+ store_index: store_index)
805
+ case res[:status]
806
+ when :dead
807
+ findings << "#{id}.#{field} cross-store ref #{ref} resolves to no intent (dead)"
808
+ when :same_store
809
+ findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now same-store #{res[:id]})"
810
+ when :cross_store
811
+ findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now #{res[:ref]})" if res[:ref] != ref
812
+ end
813
+ end
814
+ end
815
+ end
816
+ end
817
+
818
+ graph_finding_check(
819
+ "graph_cross_store_resolution", findings,
820
+ "Every cross-store sources/chain ref resolves to a live, current intent",
821
+ "Run scripts/rebuild-graph to repoint/collapse/drop stale cross-store refs"
822
+ )
823
+ end
824
+
825
+ # { store_key => INDEX.md text } for every store (global + all projects), for the
826
+ # relocation-map builder. Reads INDEX.md one level above each store dir.
827
+ def cross_store_index_texts
828
+ texts = {}
829
+ global_index = File.join(plastic_home, "INDEX.md")
830
+ texts["global"] = File.read(global_index) if File.exist?(global_index)
831
+
832
+ projects_root = File.join(plastic_home, "projects")
833
+ if File.directory?(projects_root)
834
+ Dir.children(projects_root).each do |project|
835
+ idx = File.join(projects_root, project, "INDEX.md")
836
+ texts["project:#{project}"] = File.read(idx) if File.exist?(idx)
837
+ end
838
+ end
839
+ texts
840
+ end
841
+
486
842
  # Build a per-scope `nodes` map and surface IntentValidator.validate_graph
487
843
  # findings as warn-level checks. Scope-aware (the caller already filtered
488
844
  # `intent_dirs` by scope), so a `global` id is not falsely flagged as a dangler
@@ -526,7 +882,9 @@ class Doctor
526
882
  checks << graph_finding_check(
527
883
  "graph_i4_danglers", i4,
528
884
  "Every sources/chain id resolves to a real intent (I4)",
529
- "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"
530
888
  )
531
889
  checks
532
890
  end
@@ -650,6 +1008,45 @@ class Doctor
650
1008
  fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
651
1009
  )
652
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
653
1050
  end
654
1051
 
655
1052
  # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
@@ -1240,6 +1637,7 @@ class Doctor
1240
1637
  all_checks += check_project_stores
1241
1638
  all_checks += check_deprecations
1242
1639
  all_checks += check_qmd
1640
+ all_checks += check_done_signals
1243
1641
 
1244
1642
  summarize(all_checks, agent_key)
1245
1643
  end
@@ -1266,9 +1664,9 @@ class Doctor
1266
1664
  all_checks =
1267
1665
  case store
1268
1666
  when :all
1269
- check_global_store + check_project_stores + check_conventions
1667
+ check_global_store + check_project_stores + check_conventions + check_done_signals
1270
1668
  when :global
1271
- check_global_store + check_conventions(scopes: ["global"])
1669
+ check_global_store + check_conventions(scopes: ["global"]) + check_done_signals(scopes: ["global"])
1272
1670
  else
1273
1671
  all_checks_for_project_slug(store)
1274
1672
  end
@@ -1291,7 +1689,8 @@ class Doctor
1291
1689
  end
1292
1690
 
1293
1691
  check_project_store(slug, projects[slug]) +
1294
- check_conventions(scopes: ["project:#{slug}"])
1692
+ check_conventions(scopes: ["project:#{slug}"]) +
1693
+ check_done_signals(scopes: ["project:#{slug}"])
1295
1694
  end
1296
1695
 
1297
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,29 @@
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) ---
23
+ # --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
20
24
  bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
21
25
  exit 0 unless bridge_data
22
26
 
23
- reason = Bridge.code_gate_decision(bridge_data, file_path)
27
+ reason = Bridge.code_gate_decision(bridge_data, file_path) ||
28
+ Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
24
29
  exit 0 unless reason
25
30
 
26
31
  $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:"