@zalom/plastic 1.0.0-beta.2 → 1.0.0-beta.21

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 (82) hide show
  1. package/PLASTIC.md +131 -7
  2. package/agents/plastic-brainstorming.md +9 -1
  3. package/agents/plastic-enforcer.md +1 -1
  4. package/agents/plastic-executor.md +11 -1
  5. package/agents/plastic-intent-curator.md +7 -5
  6. package/agents/plastic-planner.md +11 -1
  7. package/agents/plastic-spec-specialist.md +9 -1
  8. package/hooks/hooks.json +20 -0
  9. package/hooks/retrieval-gate +10 -0
  10. package/hooks/savepoint-pre +10 -0
  11. package/hooks/statusline +150 -41
  12. package/package.json +1 -1
  13. package/scripts/agent-report +163 -0
  14. package/scripts/doctor.rb +172 -0
  15. package/scripts/hook-auto-arm +1 -1
  16. package/scripts/hook-bash-gate +2 -2
  17. package/scripts/hook-code-gate +11 -6
  18. package/scripts/hook-create-gate +2 -2
  19. package/scripts/hook-gate-check +14 -23
  20. package/scripts/hook-retrieval-gate +136 -0
  21. package/scripts/hook-savepoint-pre +32 -0
  22. package/scripts/hook-session-start +1 -1
  23. package/scripts/insight-append +51 -0
  24. package/scripts/lib/bridge.rb +374 -34
  25. package/scripts/lib/frontmatter_writer.rb +130 -0
  26. package/scripts/lib/graph_rebuild.rb +328 -0
  27. package/scripts/lib/insights.rb +86 -0
  28. package/scripts/lib/installer_core.rb +23 -0
  29. package/scripts/lib/link_suggestions.rb +322 -0
  30. package/scripts/lib/links_projection.rb +160 -0
  31. package/scripts/lib/links_section.rb +207 -0
  32. package/scripts/lib/power_tools.rb +76 -0
  33. package/scripts/lib/qmd_hook.rb +38 -25
  34. package/scripts/lib/qmd_sync.rb +36 -0
  35. package/scripts/lib/retrieval_gate.rb +211 -0
  36. package/scripts/lib/worktree.rb +409 -0
  37. package/scripts/link-suggest +211 -0
  38. package/scripts/new-intent +138 -29
  39. package/scripts/project-links +287 -0
  40. package/scripts/qmd-sync +50 -3
  41. package/scripts/rebuild-graph +244 -0
  42. package/scripts/spawn-preamble +26 -1
  43. package/skills/auto/SKILL.md +58 -11
  44. package/skills/auto/evals/evals.json +48 -0
  45. package/skills/auto/references/agent-architecture.md +27 -4
  46. package/skills/auto/references/agent-report-contract.md +121 -0
  47. package/skills/brainstorming/SKILL.md +1 -0
  48. package/skills/brainstorming/evals/evals.json +22 -0
  49. package/skills/continuing/SKILL.md +30 -8
  50. package/skills/continuing/evals/evals.json +9 -0
  51. package/skills/creating-intent/SKILL.md +16 -2
  52. package/skills/creating-intent/evals/evals.json +16 -0
  53. package/skills/creating-intent/references/lifecycle.md +9 -4
  54. package/skills/creating-skills/SKILL.md +65 -0
  55. package/skills/creating-skills/evals/evals.json +108 -0
  56. package/skills/creating-skills/references/agents.md +168 -0
  57. package/skills/creating-skills/references/evals.md +41 -0
  58. package/skills/creating-skills/references/hooks.md +248 -0
  59. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  60. package/skills/creating-skills/references/scripts.md +166 -0
  61. package/skills/creating-skills/references/skills.md +165 -0
  62. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  63. package/skills/dashboard/SKILL.md +5 -0
  64. package/skills/dashboard/evals/evals.json +22 -0
  65. package/skills/executing-plan/SKILL.md +2 -2
  66. package/skills/humanizer/SKILL.md +39 -0
  67. package/skills/humanizer/evals/evals.json +70 -0
  68. package/skills/humanizer/references/always-on-snippet.md +9 -0
  69. package/skills/humanizer/references/examples.md +48 -0
  70. package/skills/intent-curator/SKILL.md +6 -1
  71. package/skills/intent-curator/evals/evals.json +22 -0
  72. package/skills/linking-intents/SKILL.md +54 -12
  73. package/skills/linking-intents/evals/evals.json +22 -0
  74. package/skills/linking-intents/references/zettelkasten.md +7 -0
  75. package/skills/managing-index/SKILL.md +8 -0
  76. package/skills/managing-index/evals/evals.json +22 -0
  77. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  78. package/skills/releasing/SKILL.md +32 -0
  79. package/skills/research/SKILL.md +8 -0
  80. package/skills/research/evals/evals.json +22 -0
  81. package/skills/writing-instructions/SKILL.md +0 -159
  82. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # agent-report - deterministic filesystem-derived completion report (intent 74).
6
+ #
7
+ # The auto-mode report contract is decision-shaped (the spawn preamble's
8
+ # REPORT_CONTRACT plus the role prompts ask every dispatched agent to END with a
9
+ # structured completion report). Because child-agent honor is best-effort across
10
+ # harnesses (Tier B/C, docs/reference/harness-adapters.md), the contract is never a
11
+ # hard block. This script is the always-a-report fallback: when a dispatched agent
12
+ # returns no usable report, the enforcer runs it to synthesize one from the intent
13
+ # directory, so the handoff account always exists.
14
+ #
15
+ # Like scripts/spawn-preamble, it is a PURE function of the intent dir: no network,
16
+ # no randomness, no wall-clock reads. Two runs over the same on-disk state produce
17
+ # byte-identical output.
18
+ #
19
+ # Usage:
20
+ # agent-report <intent_dir> [--role ROLE]
21
+ #
22
+ # Exit codes: 0 (report emitted), 2 (usage).
23
+
24
+ require_relative "lib/bridge"
25
+
26
+ def parse_args(argv)
27
+ role = nil
28
+ positional = []
29
+ i = 0
30
+ while i < argv.length
31
+ case argv[i]
32
+ when "--role"
33
+ role = argv[i + 1]
34
+ i += 2
35
+ else
36
+ positional << argv[i]
37
+ i += 1
38
+ end
39
+ end
40
+ [positional.first, role]
41
+ end
42
+
43
+ # Read the intent file frontmatter via the same path spawn-preamble uses, so the
44
+ # id/intent we report match what the rest of Plastic sees. Never raises.
45
+ def frontmatter_for(intent_dir)
46
+ ifile = Bridge.intent_file(intent_dir)
47
+ return {} unless File.exist?(ifile)
48
+
49
+ content = File.read(ifile)
50
+ return {} unless content.start_with?("---")
51
+
52
+ parts = content.split("---", 3)
53
+ return {} if parts.length < 3
54
+
55
+ require "yaml"
56
+ require "date"
57
+ require "time"
58
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
59
+ rescue StandardError
60
+ {}
61
+ end
62
+
63
+ # Current stage: prefer the last non-empty savepoint line (the ledger encodes the
64
+ # furthest-reached milestone), else the file-derived stage. Same logic as
65
+ # spawn-preamble's current_stage.
66
+ STAGE_LABELS = {
67
+ "what" => "What", "why" => "Why", "how" => "How",
68
+ "exec" => "Exec", "done" => "Done"
69
+ }.freeze
70
+
71
+ def current_stage(intent_dir)
72
+ ledger = File.join(intent_dir, Bridge::SAVEPOINT_FILE)
73
+ if File.exist?(ledger)
74
+ last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
75
+ return last if last
76
+ end
77
+ STAGE_LABELS.fetch(Bridge.derive_stage(intent_dir), Bridge.derive_stage(intent_dir))
78
+ end
79
+
80
+ # checklist.md checked/total: count GFM task-list items. Returns "n/a" when there
81
+ # is no checklist (a checklist is only meaningful from How onward).
82
+ def checklist_progress(intent_dir)
83
+ path = File.join(intent_dir, "checklist.md")
84
+ return "n/a" unless Bridge.stage_file_present?(path)
85
+
86
+ lines = File.readlines(path)
87
+ total = lines.count { |l| l =~ /^\s*- \[[ xX]\]/ }
88
+ checked = lines.count { |l| l =~ /^\s*- \[[xX]\]/ }
89
+ return "n/a" if total.zero?
90
+
91
+ "#{checked}/#{total}"
92
+ end
93
+
94
+ # The outcome line: first content line under outcome.md's "## Summary" (or the first
95
+ # non-heading content line), else a placeholder. Pure read of the intent dir.
96
+ def outcome_line(intent_dir)
97
+ path = File.join(intent_dir, "outcome.md")
98
+ return "(no outcome yet)" unless Bridge.stage_file_present?(path)
99
+
100
+ lines = File.readlines(path).map(&:rstrip)
101
+ start = lines.index { |l| l.strip.downcase == "## summary" }
102
+ scan = start ? lines[(start + 1)..] : lines
103
+ (scan || []).each do |l|
104
+ s = l.strip
105
+ next if s.empty? || s.start_with?("#") || s.start_with?("<!--")
106
+ return s
107
+ end
108
+ "(no outcome yet)"
109
+ end
110
+
111
+ # The insights line: the LAST line of the intent file's `## Insights` section
112
+ # (the newest nugget, since entries are append-only newest-at-bottom), else
113
+ # `(none)`. Pure read of the intent dir, no clock.
114
+ def insights_line(intent_dir)
115
+ ifile = Bridge.intent_file(intent_dir)
116
+ return "(none)" unless File.exist?(ifile)
117
+
118
+ lines = File.read(ifile).split("\n", -1)
119
+ idx = lines.index { |l| l.strip == "## Insights" }
120
+ return "(none)" if idx.nil?
121
+
122
+ last = nil
123
+ (idx + 1).upto(lines.length - 1) do |i|
124
+ break if lines[i].start_with?("## ")
125
+
126
+ last = lines[i] unless lines[i].strip.empty?
127
+ end
128
+ last || "(none)"
129
+ end
130
+
131
+ intent_dir_arg, role = parse_args(ARGV)
132
+
133
+ if intent_dir_arg.nil? || intent_dir_arg.empty?
134
+ warn "usage: agent-report <intent_dir> [--role ROLE]"
135
+ exit 2
136
+ end
137
+
138
+ intent_dir = File.expand_path(intent_dir_arg)
139
+
140
+ fm = frontmatter_for(intent_dir)
141
+ id = fm["id"].to_s.strip
142
+ intent_name = fm["intent"].to_s.strip
143
+ id = "(unknown)" if id.empty?
144
+ intent_name = "(unknown)" if intent_name.empty?
145
+
146
+ stage = current_stage(intent_dir)
147
+ role_label = (role && !role.empty?) ? role : stage
148
+ artifacts = Bridge.has_files(intent_dir)
149
+ artifacts_str = artifacts.empty? ? "(none)" : artifacts.join(", ")
150
+
151
+ lines = []
152
+ lines << "=== Plastic agent report (synthesized) ==="
153
+ lines << "Intent: #{id} - #{intent_name}"
154
+ lines << "Stage: #{stage}"
155
+ lines << "Role: #{role_label}"
156
+ lines << "Status: synthesized (filesystem-derived; no agent-authored report)"
157
+ lines << "Artifacts present: #{artifacts_str}"
158
+ lines << "Checklist: #{checklist_progress(intent_dir)}"
159
+ lines << "Outcome: #{outcome_line(intent_dir)}"
160
+ lines << "Insights: #{insights_line(intent_dir)}"
161
+ lines << "=== end report ==="
162
+
163
+ puts lines.join("\n")
package/scripts/doctor.rb CHANGED
@@ -17,6 +17,9 @@ 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"
20
23
 
21
24
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
22
25
  # run it hermetically (no eval, no global-constant rewriting).
@@ -480,9 +483,178 @@ class Doctor
480
483
  # flagged: validate_graph does not compute it.
481
484
  checks.concat(graph_invariant_checks(intent_dirs))
482
485
 
486
+ # cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
487
+ # `store:id` ref against the FULL store family via the relocation map
488
+ # (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
489
+ # Resolution always spans all stores even under `--store` scoping; only the
490
+ # REPORTED findings are filtered to refs originating in the scoped store(s).
491
+ checks << cross_store_resolution_check(scopes: scopes)
492
+
493
+ # graph_links_projection — the `## Links` section of every intent must EQUAL its
494
+ # canonical I5 frontmatter projection (intent 72), in BOTH set membership AND
495
+ # ordering (sources first, then chain). Recomputes the projection from each
496
+ # intent's sources/chain + the on-disk basenames using the SAME resolver the
497
+ # scripts/project-links tool uses, so the two can never diverge. Resolution
498
+ # spans all stores; only the REPORTED findings are filtered to the scoped store.
499
+ checks << links_projection_check(scopes: scopes)
500
+
483
501
  checks
484
502
  end
485
503
 
504
+ # Build the cross-store node maps (basename + label per store) + relocation map
505
+ # from ALL stores, then for every intent compute its canonical `## Links`
506
+ # projection and flag any whose ACTUAL `## Links` section differs (membership or
507
+ # ordering drift), or whose projection raises UnresolvedRef. `scopes` (nil = full
508
+ # run) filters only the REPORTED findings by origin scope.
509
+ def links_projection_check(scopes: nil)
510
+ all_dirs = all_intent_dirs
511
+
512
+ store_index = Hash.new { |h, k| h[k] = [] }
513
+ node_index = Hash.new { |h, k| h[k] = {} }
514
+ intents = [] # { scope:, id:, sources:, chain:, path: }
515
+
516
+ all_dirs.each do |d|
517
+ md = File.join(d[:path], "#{d[:name]}.md")
518
+ next unless File.exist?(md)
519
+
520
+ fm = parse_frontmatter(md)
521
+ next unless fm.is_a?(Hash) && fm["id"]
522
+
523
+ id = fm["id"].to_s
524
+ store_index[d[:scope]] << id
525
+ node_index[d[:scope]][id] = { basename: d[:name], label: fm["intent"].to_s.strip }
526
+ intents << {
527
+ scope: d[:scope], id: id, path: md,
528
+ sources: Array(fm["sources"]).map(&:to_s),
529
+ chain: Array(fm["chain"]).map(&:to_s),
530
+ }
531
+ end
532
+
533
+ relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
534
+
535
+ findings = []
536
+ intents.each do |node|
537
+ next if scopes && !scopes.include?(node[:scope])
538
+
539
+ resolve = ->(ref) do
540
+ LinksProjection.resolve_ref_projection(
541
+ ref, referer_store: node[:scope],
542
+ relocation_map: relocation_map, store_index: store_index, node_index: node_index
543
+ )
544
+ end
545
+
546
+ begin
547
+ expected = LinksProjection.section(sources: node[:sources], chain: node[:chain], resolve: resolve)
548
+ actual = actual_links_section(node[:path])
549
+ rescue LinksProjection::UnresolvedRef => e
550
+ findings << "#{node[:id]} ## Links projection failed: #{e.message}"
551
+ next
552
+ rescue LinksSection::AmbiguousLinks => e
553
+ findings << "#{node[:id]} ## Links ambiguous: #{e.message}"
554
+ next
555
+ end
556
+
557
+ next if actual == expected
558
+
559
+ findings << "#{node[:id]} ## Links does not match its frontmatter projection (membership/ordering drift)"
560
+ end
561
+
562
+ graph_finding_check(
563
+ "graph_links_projection", findings,
564
+ "Every intent's ## Links equals its frontmatter projection (membership and ordering)",
565
+ "Run scripts/project-links to regenerate the canonical ## Links sections"
566
+ )
567
+ end
568
+
569
+ # Extract a file's ACTUAL REAL `## Links` section text (FENCE-AWARE), normalized
570
+ # to the canonical block shape the projection emits. Delegates to the shared
571
+ # LinksSection.extract_section so the doctor check and the project-links tool
572
+ # agree on the section location and never match a `## Links` heading inside an
573
+ # example code fence. Returns "" when the section is absent (which differs from
574
+ # any real projection, so a missing section is a finding).
575
+ def actual_links_section(path)
576
+ LinksSection.extract_section(IntentValidator.body_of(File.read(path)))
577
+ end
578
+
579
+ # Build the relocation map + cross-store store_index from ALL stores, then for
580
+ # every intent's cross-store `sources`/`chain` ref resolve it and flag:
581
+ # - DEAD: the target resolves nowhere
582
+ # - RELOCATED-STALE: the ref points at an old location the relocation log has
583
+ # moved (the resolved location differs from the literal ref), e.g. the
584
+ # `global:24` id-reuse hazard that direct resolution would silently accept.
585
+ # `scopes` (nil = full run) filters only the REPORTED findings by origin scope.
586
+ def cross_store_resolution_check(scopes: nil)
587
+ all_dirs = all_intent_dirs
588
+
589
+ # Per-scope node maps + store_index over the WHOLE family.
590
+ nodes_by_scope = Hash.new { |h, k| h[k] = {} }
591
+ store_index = Hash.new { |h, k| h[k] = [] }
592
+ all_dirs.each do |d|
593
+ md = File.join(d[:path], "#{d[:name]}.md")
594
+ next unless File.exist?(md)
595
+
596
+ fm = parse_frontmatter(md)
597
+ next unless fm.is_a?(Hash) && fm["id"]
598
+
599
+ id = fm["id"].to_s
600
+ store_index[d[:scope]] << id
601
+ nodes_by_scope[d[:scope]][id] = {
602
+ sources: Array(fm["sources"]).map(&:to_s),
603
+ chain: Array(fm["chain"]).map(&:to_s),
604
+ }
605
+ end
606
+
607
+ relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
608
+
609
+ findings = []
610
+ nodes_by_scope.each do |scope, nodes|
611
+ next if scopes && !scopes.include?(scope)
612
+
613
+ nodes.each do |id, edges|
614
+ %i[sources chain].each do |field|
615
+ edges[field].each do |ref|
616
+ next unless ref.include?(":") # only cross-store refs are resolved here
617
+
618
+ res = GraphRebuild.resolve_ref(ref, referer_store: scope,
619
+ relocation_map: relocation_map,
620
+ store_index: store_index)
621
+ case res[:status]
622
+ when :dead
623
+ findings << "#{id}.#{field} cross-store ref #{ref} resolves to no intent (dead)"
624
+ when :same_store
625
+ findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now same-store #{res[:id]})"
626
+ when :cross_store
627
+ findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now #{res[:ref]})" if res[:ref] != ref
628
+ end
629
+ end
630
+ end
631
+ end
632
+ end
633
+
634
+ graph_finding_check(
635
+ "graph_cross_store_resolution", findings,
636
+ "Every cross-store sources/chain ref resolves to a live, current intent",
637
+ "Run scripts/rebuild-graph to repoint/collapse/drop stale cross-store refs"
638
+ )
639
+ end
640
+
641
+ # { store_key => INDEX.md text } for every store (global + all projects), for the
642
+ # relocation-map builder. Reads INDEX.md one level above each store dir.
643
+ def cross_store_index_texts
644
+ texts = {}
645
+ global_index = File.join(plastic_home, "INDEX.md")
646
+ texts["global"] = File.read(global_index) if File.exist?(global_index)
647
+
648
+ projects_root = File.join(plastic_home, "projects")
649
+ if File.directory?(projects_root)
650
+ Dir.children(projects_root).each do |project|
651
+ idx = File.join(projects_root, project, "INDEX.md")
652
+ texts["project:#{project}"] = File.read(idx) if File.exist?(idx)
653
+ end
654
+ end
655
+ texts
656
+ end
657
+
486
658
  # Build a per-scope `nodes` map and surface IntentValidator.validate_graph
487
659
  # findings as warn-level checks. Scope-aware (the caller already filtered
488
660
  # `intent_dirs` by scope), so a `global` id is not falsely flagged as a dangler
@@ -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,9 +28,9 @@ 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) ---
31
+ # --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
32
32
  session = payload["session_id"]
33
- session = ENV["CLAUDE_SESSION_ID"] if session.nil? || session.to_s.empty?
33
+ session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.to_s.empty?
34
34
  bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
35
35
  exit 0 unless bridge_data
36
36
 
@@ -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}"
@@ -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
  #
@@ -13,7 +13,7 @@ require_relative "lib/intent_validator"
13
13
  file_path = ARGV[0]
14
14
  exit 0 unless file_path && !file_path.empty?
15
15
 
16
- session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
16
+ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
17
17
 
18
18
  file_path_abs = File.expand_path(file_path)
19
19
 
@@ -24,6 +24,12 @@ intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
24
24
  if intent_dir_abs
25
25
  begin
26
26
  Bridge.append_savepoint(intent_dir_abs, file_path_abs)
27
+ # When checklist.md lands, How ends and Exec begins: emit the `Exec started`
28
+ # companion in the same event (intent 81). Guard on a real (non-placeholder)
29
+ # checklist so a scaffold sentinel does not trip it.
30
+ if File.basename(file_path_abs) == "checklist.md" && Bridge.stage_file_present?(file_path_abs)
31
+ Bridge.append_exec_started(intent_dir_abs)
32
+ end
27
33
  rescue StandardError
28
34
  # ignore — rebuildable from disk
29
35
  end
@@ -112,32 +118,17 @@ if is_stage_file || is_action_file
112
118
 
113
119
  Bridge.write(session, bridge_data)
114
120
 
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
121
+ # Build transition context — ONE concise sentence (intent 84, Lever 1),
122
+ # preserving the `Next: ...` hint. Formatting is pure in Bridge.gate_narration.
123
+ context = Bridge.gate_narration(
124
+ old_stage: old_stage, new_stage: new_stage,
125
+ basename: basename, new_missing: new_missing
126
+ )
136
127
 
137
128
  payload = {
138
129
  "hookSpecificOutput" => {
139
130
  "hookEventName" => "PostToolUse",
140
- "additionalContext" => context_parts.join(" ")
131
+ "additionalContext" => context
141
132
  }
142
133
  }
143
134
  puts JSON.generate(payload)
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # PreToolUse retrieval gate (intent 84, Lever 2; operation-based redesign 89a).
6
+ # Reads the tool call (JSON on stdin: tool_name + tool_input), computes
7
+ # capabilities (QMD detect + freshness), delegates the decision to RetrievalGate,
8
+ # and enforces:
9
+ # ALLOW = exit 0 ; BLOCK = exit 2 with reason on stderr (shown to the agent).
10
+ # Fail-open: any parse error, timeout, or unexpected exception exits 0. On the
11
+ # STALE QMD path RetrievalGate fires QmdSync.reindex_async (NEVER synchronous).
12
+ # Binds subagents (PreToolUse hooks apply to subagent tool calls too).
13
+ #
14
+ # Only CONTENT SEARCH over store markdown is gated; reads and structural ops are
15
+ # allowed. Code navigation is a soft prompt mandate (UserPromptSubmit power-tools),
16
+ # not enforced here, so this hook no longer detects Serena.
17
+ #
18
+ # Scope: only the agent's own Bash/Read/Grep/Glob calls. Ruby `File.read` inside
19
+ # scripts is invisible to a PreToolUse hook and is out of scope (no exemptions).
20
+ #
21
+ # ARGV[0] is plastic_home (passed by the launcher), like hook-qmd-search.
22
+
23
+ require "json"
24
+ require "timeout"
25
+ require_relative "lib/retrieval_gate"
26
+ require_relative "lib/qmd_sync"
27
+
28
+ module RetrievalGateHook
29
+ module_function
30
+
31
+ # Pure-ish core: capabilities and reindex are injected so this is unit-testable
32
+ # with no real qmd. Returns [exit_code, stderr_string].
33
+ # stdin: raw PreToolUse JSON
34
+ # capabilities: { qmd:, qmd_fresh: }
35
+ # reindex: callable fired on the STALE path
36
+ def run(stdin:, plastic_home:, cwd:, capabilities:, reindex: -> {})
37
+ payload = parse(stdin)
38
+ return [0, nil] unless payload
39
+
40
+ tool_name = payload["tool_name"].to_s
41
+ tool_input = payload["tool_input"]
42
+ tool_input = {} unless tool_input.is_a?(Hash)
43
+
44
+ bypassed = false
45
+ reason = RetrievalGate.decision(
46
+ tool_name: tool_name, tool_input: tool_input,
47
+ plastic_home: plastic_home, cwd: cwd,
48
+ capabilities: capabilities, reindex: reindex
49
+ ) { |_sig| bypassed = true }
50
+
51
+ if reason
52
+ [2, "PLASTIC GATE — #{reason}"]
53
+ elsif bypassed
54
+ [0, "PLASTIC GATE — bypassed via # qmd-ok"]
55
+ else
56
+ [0, nil]
57
+ end
58
+ rescue StandardError
59
+ [0, nil] # fail-open
60
+ end
61
+
62
+ def parse(raw)
63
+ data = JSON.parse(raw.to_s)
64
+ data.is_a?(Hash) ? data : nil
65
+ rescue StandardError
66
+ nil
67
+ end
68
+
69
+ # Detect real capabilities for the live executable. Probes are injected so the
70
+ # three-tier QMD failure model is unit-testable:
71
+ # - absent : QMD not on PATH -> allow, no warn.
72
+ # - broken : QMD present but freshness probe -> tier-b: WARN once, then treat
73
+ # raises/times out as absent this turn (allow).
74
+ # - fresh : probe returns truthy -> gate is live.
75
+ # A slow `qmd status` cannot stall a tool call: a 2s Timeout bounds the probe.
76
+ def detect_capabilities(cwd:,
77
+ detect: -> { QmdSync.detect },
78
+ fresh: -> { QmdSync.fresh? },
79
+ warn: ->(m) { $stderr.puts(m) })
80
+ qmd = detect.call
81
+ qmd_fresh = false
82
+ if qmd
83
+ begin
84
+ qmd_fresh = Timeout.timeout(2) { fresh.call }
85
+ rescue StandardError
86
+ # Tier-b: QMD is present but its freshness probe broke/stalled. Distinct
87
+ # from QMD being absent — warn so a degraded QMD is visible, then fail open
88
+ # (allow this turn, no reindex).
89
+ warn.call("PLASTIC GATE — QMD is present but its freshness probe failed; " \
90
+ "allowing this turn without routing search to QMD (check qmd).")
91
+ qmd = false
92
+ qmd_fresh = false
93
+ end
94
+ end
95
+ { qmd: qmd, qmd_fresh: qmd_fresh }
96
+ end
97
+
98
+ # Best-effort reindex callable for the STALE path. Resolves the collection from
99
+ # cwd (project + global) and fires the async reindexer for each; never raises.
100
+ def reindex_for(cwd:, plastic_home:)
101
+ lambda do
102
+ begin
103
+ cols = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
104
+ cols.each { |c| QmdSync.reindex_async(collection: c) }
105
+ rescue StandardError
106
+ # non-fatal; the read is already allowed this turn
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ # --- executable entrypoint ---
113
+ if $PROGRAM_NAME == __FILE__
114
+ raw = begin
115
+ $stdin.read
116
+ rescue StandardError
117
+ ""
118
+ end
119
+
120
+ plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
121
+ cwd = Dir.pwd
122
+
123
+ code, err = begin
124
+ caps = RetrievalGateHook.detect_capabilities(cwd: cwd)
125
+ RetrievalGateHook.run(
126
+ stdin: raw, plastic_home: plastic_home, cwd: cwd,
127
+ capabilities: caps,
128
+ reindex: RetrievalGateHook.reindex_for(cwd: cwd, plastic_home: plastic_home)
129
+ )
130
+ rescue StandardError
131
+ [0, nil] # fail-open at the outermost boundary too
132
+ end
133
+
134
+ $stderr.puts(err) if err && !err.empty?
135
+ exit code
136
+ end
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+ # Usage: hook-savepoint-pre <file_path>
5
+ #
6
+ # PreToolUse savepoint trigger (intent 81). When a stage-opening lifecycle file
7
+ # (spec.md => Why, plan.md => How) is ABOUT to be written into an intent dir,
8
+ # append the pre-stage `started` ledger line so the ledger records "this stage
9
+ # was entered" before its artifact lands.
10
+ #
11
+ # Like the PostToolUse decoupled savepoint write (intent 52), it is derived from
12
+ # the file path alone: no bridge, no session, fires headless. It NEVER blocks a
13
+ # write: any non-match or failure exits 0. The append is idempotent and only
14
+ # fires while the stage is genuinely starting (the artifact is not yet a real,
15
+ # non-placeholder file), so re-edits add nothing.
16
+
17
+ require_relative "lib/bridge"
18
+
19
+ file_path = ARGV[0]
20
+ exit 0 if file_path.nil? || file_path.empty?
21
+
22
+ file_path_abs = File.expand_path(file_path)
23
+ intent_dir = Bridge.intent_dir_for(file_path_abs)
24
+ exit 0 unless intent_dir
25
+
26
+ begin
27
+ Bridge.append_started_savepoint(intent_dir, file_path_abs)
28
+ rescue StandardError
29
+ # best-effort; the ledger is rebuildable and the post line still lands
30
+ end
31
+
32
+ exit 0
@@ -48,7 +48,7 @@ bridge_data = nil
48
48
  if active.length == 1 && active.first =~ /store\/([\w-]+)\//
49
49
  dir_name = $1
50
50
  intent_dir = "#{store_root}/store/#{dir_name}"
51
- session = ENV["CLAUDE_SESSION_ID"] || Process.pid.to_s
51
+ session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
52
52
  intent_id = dir_name.split("--").first
53
53
  intent_name = active.first[/\[([^\]]+)\]/, 1] || "unknown"
54
54
  bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store_root, name: intent_name)