@zalom/plastic 1.0.0-beta.11 → 1.0.0-beta.13

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.
@@ -6,6 +6,8 @@ require "yaml"
6
6
  require "fileutils"
7
7
  require "tempfile"
8
8
  require "digest"
9
+ require "socket"
10
+ require_relative "worktree"
9
11
 
10
12
  module Bridge
11
13
  STAGES = %w[what why how exec done].freeze
@@ -272,6 +274,51 @@ module Bridge
272
274
  end
273
275
  end
274
276
 
277
+ # --- Gate-boundary narration (intent 84, Lever 1) -------------------------
278
+ #
279
+ # ONE concise sentence that states what happened AND what's next, preserving
280
+ # the `Next: ...` hint the agent consumes. Pure and side-effect-free so the
281
+ # hook stays a thin caller and the formatter is unit-testable in isolation.
282
+ # No "Stage transition: X -> Y" prose, no arrow; a colon/parentheses carry the
283
+ # stage word. Returns a single line (no embedded newlines).
284
+ STAGE_LABELS = {
285
+ "what" => "What", "why" => "Why", "how" => "How",
286
+ "exec" => "Exec", "done" => "Done"
287
+ }.freeze
288
+
289
+ NEXT_HINTS = {
290
+ "why" => "write spec.md",
291
+ "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
292
+ "exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
293
+ "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
294
+ }.freeze
295
+
296
+ def self.stage_label(stage)
297
+ STAGE_LABELS[stage] || stage.to_s
298
+ end
299
+
300
+ # Build the gate-hook `additionalContext` sentence.
301
+ # transition: "PLASTIC: How reached (plan.md written). Next: <hint>"
302
+ # same-stage write: "PLASTIC: plan.md written (How). Next: <hint>"
303
+ # `new_missing` (missing files for the new stage) takes precedence over the
304
+ # stage hint, exactly as before, so the `Next:` content is unchanged.
305
+ def self.gate_narration(old_stage:, new_stage:, basename:, new_missing:, next_hints: NEXT_HINTS)
306
+ head = if old_stage != new_stage
307
+ "PLASTIC: #{stage_label(new_stage)} reached (#{basename} written)."
308
+ else
309
+ "PLASTIC: #{basename} written (#{stage_label(new_stage)})."
310
+ end
311
+
312
+ nxt =
313
+ if Array(new_missing).any?
314
+ "Next: #{Array(new_missing).join(", ")}"
315
+ elsif next_hints[new_stage]
316
+ "Next: #{next_hints[new_stage]}"
317
+ end
318
+
319
+ nxt ? "#{head} #{nxt}" : head
320
+ end
321
+
275
322
  # --- Cycle-step savepoint ledger (intent 34) ------------------------------
276
323
  #
277
324
  # savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
@@ -304,8 +351,31 @@ module Bridge
304
351
  end.compact
305
352
  end
306
353
 
307
- # Append a milestone line for file_path if (and only if) it is a milestone
308
- # not already recorded. Returns true when a line was written, false otherwise.
354
+ # (stage, milestone) pairs already recorded in the ledger. The pair (not the
355
+ # milestone text alone) is the dedup key, because state-from-ledger lines like
356
+ # `Why started` and `How started` share the milestone text "started" while
357
+ # being distinct events (intent 81).
358
+ def self.savepoint_recorded_pairs(intent_dir)
359
+ f = File.join(intent_dir, SAVEPOINT_FILE)
360
+ return [] unless File.exist?(f)
361
+ File.read(f).each_line.filter_map do |line|
362
+ parts = line.strip.split(/\s{2,}/)
363
+ parts.length >= 3 ? [parts[1], parts[2]] : nil
364
+ end
365
+ end
366
+
367
+ # Append one ledger line for (stage, milestone) unless that pair is already
368
+ # recorded. The single append primitive shared by every line class. Returns
369
+ # true when a line was written, false when it was a no-op.
370
+ def self.append_savepoint_line(intent_dir, stage, milestone, now)
371
+ return false if savepoint_recorded_pairs(intent_dir).include?([stage, milestone])
372
+ line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
373
+ File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
374
+ true
375
+ end
376
+
377
+ # Append the artifact-landing milestone for file_path if (and only if) it is a
378
+ # milestone not already recorded. Returns true when a line was written.
309
379
  def self.append_savepoint(intent_dir, file_path, now: Time.now)
310
380
  basename = File.basename(file_path)
311
381
  stage, milestone = savepoint_milestone(intent_dir, basename)
@@ -313,11 +383,62 @@ module Bridge
313
383
  # A sentinel-marked lifecycle file logs NO milestone (the stage is not real
314
384
  # yet). The intent file is never sentineled, so it still logs its What line.
315
385
  return false unless stage_file_present?(File.join(intent_dir, basename))
316
- return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
317
386
 
318
- line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
319
- File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
320
- true
387
+ append_savepoint_line(intent_dir, stage, milestone, now)
388
+ end
389
+
390
+ # --- State-from-ledger: pre-stage, exec-start, and terminal lines (81) ------
391
+ #
392
+ # On top of intent 34's artifact-landing milestones, the ledger gains:
393
+ # - `started` lines, one per cycle stage entry (pre-stage, written by the
394
+ # PreToolUse savepoint hook the moment a stage's artifact is first written);
395
+ # - an `Exec started` companion emitted when checklist.md lands;
396
+ # - a terminal `Done delivered|abandoned` line written by the completion path.
397
+ # None of these are derivable from files on disk, so they are deliberately NOT
398
+ # part of savepoint_milestone and are never regenerated by rebuild_savepoint:
399
+ # a rebuilt ledger is the file-landing skeleton, the live ledger is richer.
400
+
401
+ # Map a written filename to the [stage, "started"] pre-stage milestone, or nil.
402
+ # spec.md => entering Why, plan.md => entering How. checklist.md/outcome.md do
403
+ # not open a stage (checklist's Exec-start is the append_exec_started companion).
404
+ def self.savepoint_started_milestone(basename)
405
+ case basename
406
+ when "spec.md" then ["Why", "started"]
407
+ when "plan.md" then ["How", "started"]
408
+ end
409
+ end
410
+
411
+ # Append the pre-stage `started` line for file_path, iff: the basename opens a
412
+ # stage, the stage is genuinely starting (its artifact is not yet a REAL file,
413
+ # so a sentinel placeholder still counts as "starting"), and the pair is not
414
+ # already recorded. Returns true when a line was written.
415
+ def self.append_started_savepoint(intent_dir, file_path, now: Time.now)
416
+ basename = File.basename(file_path)
417
+ stage, milestone = savepoint_started_milestone(basename)
418
+ return false unless milestone
419
+ return false if stage_file_present?(File.join(intent_dir, basename))
420
+
421
+ append_savepoint_line(intent_dir, stage, milestone, now)
422
+ end
423
+
424
+ # Append the `Exec started` companion (emitted when checklist.md lands, in the
425
+ # same PostToolUse event as the `How checklist.md created` line). Idempotent.
426
+ def self.append_exec_started(intent_dir, now: Time.now)
427
+ append_savepoint_line(intent_dir, "Exec", "started", now)
428
+ end
429
+
430
+ TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
431
+
432
+ # Append the terminal bookend `Done delivered|abandoned`, written by the
433
+ # completion path when an intent transfers to INDEX's Completed/Abandoned
434
+ # section. Idempotent per disposition. Raises on an unknown disposition.
435
+ def self.append_terminal_savepoint(intent_dir, disposition, now: Time.now)
436
+ unless TERMINAL_DISPOSITIONS.include?(disposition)
437
+ raise ArgumentError,
438
+ "disposition must be one of #{TERMINAL_DISPOSITIONS.join(', ')}, got #{disposition.inspect}"
439
+ end
440
+
441
+ append_savepoint_line(intent_dir, "Done", disposition, now)
321
442
  end
322
443
 
323
444
  # Reconstruct the ledger from files on disk (timestamps from mtimes), in
@@ -368,6 +489,23 @@ module Bridge
368
489
  "context_pct" => 0,
369
490
  "warning_at" => 80,
370
491
  "critical_at" => 90
492
+ },
493
+ # Worktree isolation block (intent 73c). Born unprovisioned; arm_auto calls
494
+ # Worktree.provision to fill it. code/store are abs paths or null.
495
+ "worktree" => {
496
+ "code" => nil,
497
+ "code_branch" => nil,
498
+ "store" => nil,
499
+ "store_branch" => nil,
500
+ "provisioned" => false
501
+ },
502
+ # Delivery lock block (intent 73c). The bridge IS the lock; the owner is
503
+ # whoever armed it. Born unowned; arm_auto stamps owner_session/pid/etc.
504
+ "lock" => {
505
+ "owner_session" => nil,
506
+ "pid" => nil,
507
+ "acquired_at" => nil,
508
+ "host" => nil
371
509
  }
372
510
  }
373
511
 
@@ -439,6 +577,25 @@ module Bridge
439
577
  end
440
578
  data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
441
579
  data["build"]["auto"] = true
580
+
581
+ # Acquire the delivery lock: this armed bridge is now the single owner of the
582
+ # intent's delivery (intent 73c). Stamp owner + pid liveness fields.
583
+ data["lock"] = {
584
+ "owner_session" => key,
585
+ "pid" => Process.pid,
586
+ "acquired_at" => Time.now.utc.iso8601,
587
+ "host" => (Socket.gethostname rescue nil)
588
+ }
589
+
590
+ # Provision the per-intent worktrees (mandatory code worktree for project
591
+ # intents; fail-open for non-git / global-only). Never let a provision error
592
+ # break arming: the lock and auto flag still matter.
593
+ begin
594
+ Worktree.provision(data)
595
+ rescue => e
596
+ $stderr.puts "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
597
+ end
598
+
442
599
  write(key, data)
443
600
  purge_done_bridges(session: key)
444
601
  data
@@ -450,6 +607,16 @@ module Bridge
450
607
  return nil unless data
451
608
  data["build"] ||= {}
452
609
  data["build"]["auto"] = false
610
+
611
+ # Release the worktrees the matching arm provisioned (intent 73c). Non-fatal:
612
+ # a release error must not block disarming. CLEANUP (73c3) refines the
613
+ # merge-vs-remove policy on the completion/release path.
614
+ begin
615
+ Worktree.release(data)
616
+ rescue => e
617
+ $stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
618
+ end
619
+
453
620
  write(session, data)
454
621
  purge_done_bridges(session: session)
455
622
  data
@@ -489,6 +656,114 @@ module Bridge
489
656
  "(blocked edit: #{file_abs})"
490
657
  end
491
658
 
659
+ # --- Worktree isolation gate (intent 73c2) ---
660
+
661
+ # Returns a reason String to BLOCK, or nil to ALLOW. Two independent rules,
662
+ # both fail-open by construction:
663
+ #
664
+ # 1. When the bridge has a provisioned code worktree, a code edit (a target
665
+ # outside ~/.plastic and outside this intent's store dir) MUST land inside
666
+ # worktree["code"]; otherwise BLOCK and name the expected worktree path.
667
+ # 2. When the target lives inside ANOTHER intent's store dir whose bridge lock
668
+ # is held by a LIVE non-owner session, BLOCK (non-owner edit to an active
669
+ # intent).
670
+ #
671
+ # Fails open (returns nil) when provisioned is false (non-git / global-only) or
672
+ # the bridge carries no worktree/lock blocks. Logs nothing on the allow path.
673
+ def self.worktree_gate_decision(bridge_data, file_path, home: Dir.home, current_session: nil)
674
+ return nil unless bridge_data.is_a?(Hash)
675
+ return nil if blank?(file_path)
676
+
677
+ file_abs = File.expand_path(file_path.to_s)
678
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
679
+ under_plastic = file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
680
+
681
+ intent_info = bridge_data["intent"] || {}
682
+ store = intent_info["store"]
683
+ dir = intent_info["dir"]
684
+ intent_dir_abs = (store && dir) ? File.expand_path("#{store}/#{dir}") : nil
685
+ under_own_intent = intent_dir_abs &&
686
+ (file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/"))
687
+
688
+ # Rule 1: provisioned code worktree confines project-code edits.
689
+ worktree = bridge_data["worktree"] || {}
690
+ if worktree["provisioned"] == true
691
+ code = worktree["code"].to_s
692
+ # Project code = outside ~/.plastic and outside this intent's store dir.
693
+ is_project_code = !under_plastic && !under_own_intent
694
+ if is_project_code && !blank?(code)
695
+ code_abs = File.expand_path(code)
696
+ inside_code = file_abs == code_abs || file_abs.start_with?("#{code_abs}/")
697
+ unless inside_code
698
+ id = intent_info["id"]
699
+ return "intent #{id} is isolated to its worktree — edit project code " \
700
+ "inside #{code_abs}, not the shared checkout. (blocked edit: #{file_abs})"
701
+ end
702
+ end
703
+ end
704
+
705
+ # Rule 2: do not edit another intent's locked, live store dir.
706
+ if under_plastic
707
+ reason = non_owner_store_edit_reason(file_abs, plastic_home, intent_dir_abs,
708
+ home: home, current_session: current_session,
709
+ own_session: bridge_data["session"])
710
+ return reason if reason
711
+ end
712
+
713
+ nil
714
+ end
715
+
716
+ # Helper for rule 2. A store dir is `<plastic_home>/store/{id}--{slug}` (global)
717
+ # or `<plastic_home>/projects/{slug}/store/{id}--{slug}` (project). When the
718
+ # edit target sits inside such a dir that is NOT this intent's own dir, and a
719
+ # live non-owner session holds that intent's bridge lock, BLOCK.
720
+ def self.non_owner_store_edit_reason(file_abs, plastic_home, own_intent_dir_abs,
721
+ home:, current_session:, own_session:)
722
+ return nil if own_intent_dir_abs &&
723
+ (file_abs == own_intent_dir_abs || file_abs.start_with?("#{own_intent_dir_abs}/"))
724
+
725
+ parsed = parse_store_target(file_abs, plastic_home)
726
+ return nil unless parsed
727
+
728
+ session = blank?(current_session) ? own_session : current_session
729
+ held = Worktree.lock_held_by_other?(
730
+ intent_id: parsed[:id], store: parsed[:store],
731
+ current_session: session, home: home,
732
+ )
733
+ return nil unless held
734
+
735
+ "intent #{parsed[:id]} is owned by another live session — its delivery lock " \
736
+ "is held elsewhere. Back off; do not edit #{file_abs}."
737
+ end
738
+
739
+ # Resolve an edit target inside a store to {id:, store:} for the intent dir it
740
+ # belongs to, or nil if the path is not inside an `{id}--{slug}` intent dir.
741
+ def self.parse_store_target(file_abs, plastic_home)
742
+ rels = []
743
+ global_store = File.join(plastic_home, "store")
744
+ if file_abs.start_with?("#{global_store}/")
745
+ rels << [file_abs[(global_store.length + 1)..], global_store]
746
+ end
747
+ projects = File.join(plastic_home, "projects")
748
+ if file_abs.start_with?("#{projects}/")
749
+ tail = file_abs[(projects.length + 1)..].to_s
750
+ parts = tail.split(File::SEPARATOR)
751
+ if parts.length >= 2 && parts[1] == "store"
752
+ pstore = File.join(projects, parts[0], "store")
753
+ rels << [file_abs[(pstore.length + 1)..], pstore]
754
+ end
755
+ end
756
+
757
+ rels.each do |rel, store_dir|
758
+ next if blank?(rel)
759
+ first = rel.split(File::SEPARATOR).first.to_s
760
+ idx = first.index("--")
761
+ next unless idx && idx > 0
762
+ return { id: first[0...idx], store: store_dir }
763
+ end
764
+ nil
765
+ end
766
+
492
767
  # --- Bash-edit gate (intent 27a) ---
493
768
 
494
769
  # Extract the set of file paths a Bash command writes to. Conservative by
@@ -203,13 +203,17 @@ class InstallerCore
203
203
  "scripts/hook-continue" => "scripts/hook-continue",
204
204
  "scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
205
205
  "scripts/hook-gate-check" => "scripts/hook-gate-check",
206
+ "scripts/hook-savepoint-pre" => "scripts/hook-savepoint-pre",
206
207
  "scripts/hook-qmd-search" => "scripts/hook-qmd-search",
207
208
  "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
208
209
  "scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
209
210
  "scripts/hook-code-gate" => "scripts/hook-code-gate",
210
211
  "scripts/hook-bash-gate" => "scripts/hook-bash-gate",
212
+ "scripts/hook-retrieval-gate" => "scripts/hook-retrieval-gate",
213
+ "scripts/lib/retrieval_gate.rb" => "scripts/lib/retrieval_gate.rb",
211
214
  "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
212
215
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
216
+ "scripts/lib/worktree.rb" => "scripts/lib/worktree.rb",
213
217
  "scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
214
218
  "scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
215
219
  "scripts/qmd-sync" => "scripts/qmd-sync",
@@ -561,6 +565,15 @@ class InstallerCore
561
565
  { "type" => "command", "command" => "#{hook_dir}/plastic-create-gate", "statusMessage" => "Checking create gate..." },
562
566
  ],
563
567
  },
568
+ # Retrieval gate (intent 84, Lever 2): redirects store-markdown reads to
569
+ # QMD and code reads to Serena when those tools are present. Binds the
570
+ # main agent AND subagents (PreToolUse applies to subagent tool calls).
571
+ {
572
+ "matcher" => "Bash|Read|Grep|Glob",
573
+ "hooks" => [
574
+ { "type" => "command", "command" => "#{hook_dir}/plastic-retrieval-gate", "statusMessage" => "Checking retrieval gate..." },
575
+ ],
576
+ },
564
577
  ],
565
578
  "PostToolUse" => {
566
579
  "matcher" => "Write|Edit",
@@ -120,6 +120,21 @@ module QmdSync
120
120
  pid
121
121
  end
122
122
 
123
+ # True when the QMD index has no pending (unembedded) documents. Binary
124
+ # freshness signal for the retrieval gate (intent 84, Lever 2). `qmd status` is
125
+ # plain text (no --json); it prints a line like "Pending: N need embedding".
126
+ # No pending line found -> treat as fresh (conservative: a parse miss must not
127
+ # block reads). Runner failure -> false (cannot confirm freshness). The caller
128
+ # gates on `detect` first, so absence is handled upstream; this only answers
129
+ # "is the present index fresh?". Pure via the injected runner.
130
+ def self.fresh?(runner: default_runner)
131
+ out, ok = runner.call(["status"])
132
+ return false unless ok && out
133
+ m = out[/^\s*Pending:\s*(\d+)\b/i, 1]
134
+ pending = m ? m.to_i : 0
135
+ pending.zero?
136
+ end
137
+
123
138
  # Read-only status used by doctor and the session-start report line.
124
139
  # Returns a structured hash; never mutates the index.
125
140
  def status(plastic_home:, runner: default_runner, detector: method(:detect))
@@ -0,0 +1,238 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "bridge"
5
+
6
+ # RetrievalGate — the single, pure decision for Lever 2 of intent 84.
7
+ #
8
+ # Given an agent tool call (Bash/Read/Grep/Glob) and injected capability signals,
9
+ # it decides whether to BLOCK the call (returning a redirect-to-QMD/Serena reason
10
+ # String) or ALLOW it (returning nil). All capability and freshness signals are
11
+ # injected by the caller (the hook); this module shells out to nothing, reads no
12
+ # globals, and runs no binaries. Mirrors bridge.rb's decision-fn convention
13
+ # (reason String to block, nil to allow).
14
+ #
15
+ # Classification (per target path):
16
+ # - store `*.md` (under <plastic_home>/store or .../projects/<slug>/store) -> QMD
17
+ # - Serena-supported code/data file (NOT a store markdown) -> SERENA
18
+ # - images / binary / other -> ALLOWED
19
+ #
20
+ # Capability enforcement is BINARY (no advisory tier):
21
+ # - QMD class: detected+fresh -> BLOCK; detected+stale -> fire reindex, ALLOW
22
+ # this turn; absent/down -> ALLOW (no warning).
23
+ # - SERENA class: detected -> BLOCK; absent -> ALLOW.
24
+ #
25
+ # Bypass: a TRAILING `# qmd-ok` shell comment on a Bash command (not a substring;
26
+ # a quoted/echoed occurrence does not bypass).
27
+ #
28
+ # Scope: only the agent's own tool calls. Ruby `File.read` inside scripts is
29
+ # invisible to a PreToolUse hook and is explicitly out of scope (no exemptions).
30
+ module RetrievalGate
31
+ module_function
32
+
33
+ # Serena LSP covers many languages incl. JSON/YAML/TOML/Markdown/Ruby. Keep a
34
+ # small, conservative allowlist of code/data extensions. Markdown is listed but
35
+ # store markdown is reclassified to QMD before Serena ever sees it.
36
+ SERENA_EXTENSIONS = %w[
37
+ rb js jsx ts tsx mjs cjs py go rs java kt scala c h cpp hpp cc
38
+ cs php rb swift sh bash zsh lua ex exs erl clj sql
39
+ json yaml yml toml md markdown
40
+ ].freeze
41
+
42
+ # Image / binary extensions that are always allowed (plain read is fine).
43
+ BINARY_EXTENSIONS = %w[
44
+ png jpg jpeg gif webp svg ico bmp tiff pdf
45
+ zip gz tar tgz bz2 xz 7z
46
+ mp3 mp4 mov avi wav flac ogg
47
+ woff woff2 ttf otf eot
48
+ bin exe dll so dylib o a class jar wasm
49
+ ].freeze
50
+
51
+ # A `# qmd-ok` token that is a real TRAILING shell comment, after stripping a
52
+ # trailing newline. The token must be preceded by whitespace (or start the
53
+ # command) and run to end-of-string. `echo "# qmd-ok"` does NOT match: the
54
+ # token there is followed by a closing quote, not end-of-string.
55
+ BYPASS_RE = /(?:\A|\s)#\s*qmd-ok\s*\z/.freeze
56
+
57
+ # Decide. Returns nil to ALLOW, or a reason String to BLOCK.
58
+ # capabilities: { qmd:, qmd_fresh:, serena: } (booleans).
59
+ # reindex: no-arg callable fired once when a QMD-class target is STALE.
60
+ # When bypassed, returns nil and (if given) yields :bypass to the optional
61
+ # block so the caller can log it.
62
+ def decision(tool_name:, tool_input:, plastic_home:, cwd:,
63
+ capabilities:, reindex: -> {})
64
+ targets = extract_targets(tool_name, tool_input, cwd: cwd)
65
+ return nil if targets.empty?
66
+
67
+ if bypass?(tool_name, tool_input)
68
+ yield(:bypass) if block_given?
69
+ return nil
70
+ end
71
+
72
+ stale_seen = false
73
+ targets.each do |path|
74
+ case classify(path, plastic_home: plastic_home)
75
+ when :qmd
76
+ if capabilities[:qmd] && capabilities[:qmd_fresh]
77
+ return qmd_reason(path)
78
+ elsif capabilities[:qmd] # present but stale
79
+ stale_seen = true
80
+ end
81
+ # absent/down -> allow this target
82
+ when :serena
83
+ return serena_reason(path) if capabilities[:serena]
84
+ end
85
+ end
86
+
87
+ reindex.call if stale_seen
88
+ nil
89
+ end
90
+
91
+ # --- classification ---
92
+
93
+ def classify(path, plastic_home:)
94
+ return :allow if path.nil? || path.empty?
95
+ ext = extension(path)
96
+
97
+ if store_markdown?(path, plastic_home: plastic_home)
98
+ return :qmd
99
+ end
100
+ return :allow if BINARY_EXTENSIONS.include?(ext)
101
+ return :serena if SERENA_EXTENSIONS.include?(ext)
102
+
103
+ :allow
104
+ end
105
+
106
+ # A markdown file under the global store or a project store. QMD owns store
107
+ # markdown even though Serena could also read markdown (QMD wins for the store).
108
+ def store_markdown?(path, plastic_home:)
109
+ return false unless %w[md markdown].include?(extension(path))
110
+ abs = absolutize(path)
111
+ home = File.expand_path(plastic_home)
112
+ global = File.join(home, "store")
113
+ return true if abs.start_with?("#{global}/")
114
+
115
+ projects = File.join(home, "projects")
116
+ return false unless abs.start_with?("#{projects}/")
117
+ tail = abs[(projects.length + 1)..].to_s.split(File::SEPARATOR)
118
+ tail.length >= 2 && tail[1] == "store"
119
+ end
120
+
121
+ def extension(path)
122
+ File.extname(path.to_s).sub(/\A\./, "").downcase
123
+ end
124
+
125
+ def absolutize(path)
126
+ File.absolute_path?(path) ? path : File.expand_path(path)
127
+ end
128
+
129
+ # --- bypass ---
130
+
131
+ # Only Bash commands carry a trailing `# qmd-ok` comment. The token must be a
132
+ # real trailing comment (BYPASS_RE), so a quoted/echoed occurrence does not
133
+ # bypass.
134
+ def bypass?(tool_name, tool_input)
135
+ return false unless tool_name.to_s == "Bash"
136
+ cmd = tool_input.is_a?(Hash) ? tool_input["command"].to_s : ""
137
+ BYPASS_RE.match?(cmd.chomp)
138
+ end
139
+
140
+ # --- target extraction ---
141
+
142
+ # Paths the call reads/scans. Conservative: missing an exotic form is fine;
143
+ # never flag /dev/null or pure pipes. Read vectors only (this is a READ gate),
144
+ # not the write vectors bridge.rb already covers.
145
+ def extract_targets(tool_name, tool_input, cwd:)
146
+ input = tool_input.is_a?(Hash) ? tool_input : {}
147
+ case tool_name.to_s
148
+ when "Read"
149
+ [input["file_path"]].compact.reject(&:empty?)
150
+ when "Glob"
151
+ [input["path"], input["pattern"]].compact.reject { |s| s.to_s.empty? }
152
+ when "Grep"
153
+ # The search root is the target; the query text is not a path.
154
+ [input["path"]].compact.reject { |s| s.to_s.empty? }
155
+ when "Bash"
156
+ bash_read_targets(input["command"].to_s)
157
+ else
158
+ []
159
+ end
160
+ end
161
+
162
+ # READ utilities that take file/dir path arguments. Conservative parse: split
163
+ # on shell separators, identify the utility, collect its non-flag path args.
164
+ READ_UTILS = %w[grep rg ag find cat head tail less more bat ls wc nl sort uniq].freeze
165
+
166
+ def bash_read_targets(command)
167
+ return [] unless command.is_a?(String) && !command.empty?
168
+ targets = []
169
+ command.split(/[;\n]|&&|\|\||\|/).each do |segment|
170
+ targets.concat(segment_read_targets(segment))
171
+ end
172
+ targets.reject { |t| t.nil? || t.empty? || dev_path?(t) }.uniq
173
+ end
174
+
175
+ def segment_read_targets(segment)
176
+ tokens = tokenize(segment)
177
+ return [] if tokens.empty?
178
+
179
+ # Skip leading env-style assignments (FOO=bar cmd ...).
180
+ idx = 0
181
+ idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /\A-/
182
+ util = File.basename(tokens[idx].to_s)
183
+ return [] unless READ_UTILS.include?(util)
184
+
185
+ args = tokens[(idx + 1)..] || []
186
+ path_args_for(util, args)
187
+ end
188
+
189
+ # Collect path-shaped arguments for a read utility. Flags and flag-values are
190
+ # skipped; for grep/rg the first non-flag bareword is the PATTERN, not a path.
191
+ def path_args_for(util, args)
192
+ skip_pattern = %w[grep rg ag].include?(util)
193
+ paths = []
194
+ pattern_consumed = false
195
+ args.each do |a|
196
+ next if a.start_with?("-")
197
+ if skip_pattern && !pattern_consumed
198
+ pattern_consumed = true
199
+ next
200
+ end
201
+ paths << a
202
+ end
203
+ paths
204
+ end
205
+
206
+ # Minimal tokenizer: split on whitespace, strip surrounding matching quotes off
207
+ # each token. Good enough for the conservative read-vector parse.
208
+ def tokenize(segment)
209
+ segment.to_s.strip.split(/\s+/).map { |t| strip_quotes(t) }
210
+ end
211
+
212
+ def strip_quotes(token)
213
+ if (token.start_with?('"') && token.end_with?('"')) ||
214
+ (token.start_with?("'") && token.end_with?("'"))
215
+ token[1..-2].to_s
216
+ else
217
+ token
218
+ end
219
+ end
220
+
221
+ def dev_path?(path)
222
+ path == "/dev/null" || path.start_with?("/dev/")
223
+ end
224
+
225
+ # --- reasons ---
226
+
227
+ def qmd_reason(path)
228
+ "retrieval gate: search the store via QMD, not raw grep/Read. " \
229
+ "Use `qmd search`/`qmd query` over the `plastic-*` collections (or " \
230
+ "`scripts/qmd-sync search`) instead of reading #{path}. " \
231
+ "If you genuinely need the raw read, append a trailing `# qmd-ok` to a Bash command."
232
+ end
233
+
234
+ def serena_reason(path)
235
+ "retrieval gate: navigate code via Serena's symbolic tools (find_symbol / " \
236
+ "get_symbols_overview / find_referencing_symbols), not raw grep/Read of #{path}."
237
+ end
238
+ end