@zalom/plastic 1.0.0-beta.23 → 1.0.0-beta.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PLASTIC.md CHANGED
@@ -283,32 +283,15 @@ hygiene after each intent. Advisory self-check, not hard-verifiable.
283
283
 
284
284
  ## Retrieval Gate
285
285
 
286
- A single capability-aware PreToolUse gate enforces retrieval-first routing on the agent's own
287
- Bash/Read/Grep/Glob calls (and on subagents, since PreToolUse binds them). The gate is
288
- OPERATION-based: it separates searching from reading, and it never stands between you and
289
- reading something you have already located.
290
-
291
- - Only CONTENT SEARCH over a Plastic store is gated. The Grep tool and bash `grep`/`rg`/`ag`
292
- whose target is at or under a store route to QMD when QMD is present and the index is fresh:
293
- the raw scan is blocked and you use `qmd search`/`qmd query` (or `scripts/qmd-sync search`)
294
- instead. When QMD is present but stale, the search is allowed this turn and a background
295
- reindex is fired so the next turn enforces against a fresh index; reindex is never
296
- synchronous. When QMD is absent, the search is allowed.
297
- - Reading a known target (the Read tool, bash `cat`/`head`/`tail`) and structural discovery
298
- (the Glob tool, bash `find`/`ls`) are always allowed, including over the store. QMD cannot
299
- list directories or hand back one specific file, so these are never gated.
300
- - Code is never hard-gated here. Symbolic code navigation via Serena is a soft prompt mandate
301
- (the UserPromptSubmit power-tools hook), not a block: content grep over code is allowed,
302
- because Serena navigates symbols and cannot grep arbitrary strings.
303
- - QMD failure model. Absent or stale degrades to allow (stale also fires the background
304
- reindex). A broken QMD, where the freshness probe errors or times out, also fails open, and
305
- the hook emits a one-line warning so a degraded QMD is visible rather than silent.
306
- - Bypass: append a trailing `# qmd-ok` shell comment to a Bash command when you attempted
307
- discovery and it did not serve you (no hits, or results that do not answer your need by your
308
- reading of the snippets, not their score). A quoted or echoed occurrence does not bypass.
309
- Bypasses are logged. The gate enforces that discovery was attempted, never that it succeeded.
310
- - Scope: only the agent's tool calls. Ruby `File.read` inside a script is invisible to the gate
311
- and is out of scope by design.
286
+ Advisory. Hard gates guard writes, locks, and structure, never reads or searches. Read,
287
+ Grep, Glob, and bash search are always allowed, including over the stores. When QMD is
288
+ present and fresh, a content search over store markdown receives an advisory hint pointing
289
+ at `qmd search` alongside its result; when QMD is present but stale, a background reindex
290
+ fires so the next turn's hint runs against a fresh index (never synchronous). QMD and
291
+ Serena are recommendations, not obligations: the UserPromptSubmit power-tools hook appends
292
+ one recommendation line per present tool. The legacy trailing `# qmd-ok` token is still
293
+ accepted on Bash commands and simply silences the hint. Scope stays the agent's own tool
294
+ calls; Ruby `File.read` inside a script is invisible to the hook by design.
312
295
 
313
296
  ## Context-economy measurement buckets (84a)
314
297
 
@@ -332,11 +315,28 @@ Hard blocking — hooks exit code 2 on gate failure.
332
315
 
333
316
  ## Delivery Isolation and the Single-Owner Lock
334
317
 
335
- Exactly one session or agent develops an intent's delivery at a time. Ownership is the armed
336
- session bridge, which doubles as the delivery lock: arming records the owning session, the
337
- owner pid, an acquired-at timestamp, and the host. Another session that finds an armed bridge
338
- for the same intent with a live owner backs off; if the owner pid is dead the lock is
339
- reclaimable. This is mandatory, not a convention.
318
+ Exactly one session or agent develops an intent's delivery at a time. Ownership is
319
+ session-keyed and durable: arming acquires `delivery.lock` inside the intent directory
320
+ (atomically, O_EXCL), recording the owner session, the host, the acquired-at time, a
321
+ delegates list, and the lock type. Liveness is a lease: the owner's hooks refresh the lock
322
+ file's mtime on tool activity, and the lock counts as stale only when that heartbeat is
323
+ older than the TTL. No process id is consulted anywhere. The /tmp session bridge is a cache
324
+ of this state; on any disagreement, or when the bridge is missing, the lock file wins.
325
+ Another session that finds a fresh lock backs off; a stale lock is reclaimed only by
326
+ explicit takeover, which replaces the lock and appends an audit line to the intent's
327
+ savepoint.md. Subagents spawned by the owner write under the owner's lock once registered
328
+ as delegates. Disarm clears the lock; the End tail is ordered: verify, merge and remove
329
+ worktrees, clear the lock, and only then is the bridge purge-eligible. Repair is one
330
+ idempotent function with two entry points: the `plastic-lock` command (status, fix,
331
+ release, reclaim, delegate) and `/plastic-intent-starting`, so boarding self-heals. This is
332
+ mandatory, not a convention.
333
+
334
+ Two locks share this schema (the two-lock doctrine): `delivery.lock` (exclusive, one owner
335
+ plus delegates) and the future `maintenance.lock` (short TTL, structural move-and-record
336
+ only). They are mutually exclusive in either direction; maintenance is allowed at any
337
+ lifecycle stage provided no delivery lock is held. Intent 108 ships the delivery lock and
338
+ the mutual-exclusion seam; the maintenance lock implementation follows intent 93 in a
339
+ chained intent.
340
340
 
341
341
  Every code-touching intent gets its own git worktree named `{id}--{slug}`, and all code edits
342
342
  for that intent happen only inside it. Plastic provisions the worktree deterministically: it
@@ -350,6 +350,21 @@ Provisioning fails open for intents that touch no project code (pure research or
350
350
  intents in the global store, or a non-git repo): those get the lock only, and the worktree
351
351
  block stays unprovisioned. The fail-open path is always logged, never silent.
352
352
 
353
+ ### Intent delivery, station by station
354
+
355
+ How one intent travels from boarding to Done, and what the lock, bridge, and gates do at
356
+ each station.
357
+
358
+ | Station | Delivered artifact | Lock and bridge steps | Pre-stage gate | Post-stage record |
359
+ |---|---|---|---|---|
360
+ | Start (board) | none (a procedure, not a stage) | `plastic-lock fix` self-heals stale, corrupt, or legacy state; arm acquires `delivery.lock` (O_EXCL, session-keyed), provisions the code worktree, writes the bridge cache | lock-gate denies any write into an active intent dir without this intent's lock; every deny names the resolving command | savepoint confirms the boarding station |
361
+ | What (create) | `<id>--<slug>.md`, born complete | no lock yet; no bridge | create-gate validates the proposed intent content (Write, Edit, and MCP edits) | savepoint `What` line; intent listed in INDEX `## Active` |
362
+ | Why | `spec.md` | owner writes refresh the lease (lock file mtime heartbeat) | gate-check requires the intent file with `## Intent` before spec.md; lock-gate admits only the owner or a delegate | savepoint `Why started`, `Why spec.md created` |
363
+ | How | `plan.md`, `actions/`, `checklist.md` | heartbeat on writes; the code gate stays closed until plan.md plus checklist.md exist | gate-check requires spec.md before plan.md, and plan.md plus actions/ before checklist.md | savepoint `How started`, `How plan.md created`, `How checklist.md created`, `Exec started` |
364
+ | Exec | code on the intent branch, checklist checked off | heartbeat; code edits confined to the provisioned worktree; delegates write under the owner's lock; bash, interpreter, and MCP writes gated the same way | code-gate, worktree-gate, bash-gate, lock-gate | checklist boxes; savepoint milestones |
365
+ | End (done) | `outcome.md`, INDEX moves to Completed | ordered End tail: verify, merge and remove worktrees, disarm clears `delivery.lock`, only then is the bridge purge-eligible | gate-check blocks outcome.md while checklist items are unchecked | savepoint `Done delivered` (or `abandoned`); takeover audits, if any, remain in savepoint.md |
366
+ | Maintenance (any stage) | `revisions.md` move-and-record entries | future `maintenance.lock` (short TTL), mutually exclusive with `delivery.lock` in either direction; 108 ships the schema seam only, the implementation follows intent 93 in a chained intent | acquisition refuses while the other lock type is fresh; a terminal intent with no lock held is read-only | dated, rule-tagged `revisions.md` entry; savepoint untouched |
367
+
353
368
  ## Deprecation Process
354
369
 
355
370
  Deprecations live in `deprecations.yml` and are shown at SessionStart. While Plastic is
package/hooks/code-gate CHANGED
@@ -1,6 +1,15 @@
1
1
  #!/bin/bash
2
2
  INPUT=$(cat)
3
- FILE_PATH=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("tool_params", "file_path") || data.dig("tool_input", "file_path") || ""' 2>/dev/null)
3
+ FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
4
+ data = JSON.parse(STDIN.read)
5
+ ti = data["tool_input"] || data["tool_params"] || {}
6
+ path = ti["file_path"] || ti["notebook_path"] || ti["relative_path"] || ""
7
+ if !path.empty? && !path.start_with?("/")
8
+ root = ti["project_root"] || data["cwd"] || ""
9
+ path = File.join(root, path) unless root.empty?
10
+ end
11
+ puts path
12
+ ' 2>/dev/null)
4
13
 
5
14
  if [ -z "$FILE_PATH" ]; then
6
15
  exit 0
package/hooks/hooks.json CHANGED
@@ -19,7 +19,7 @@
19
19
  ],
20
20
  "PreCompact": [
21
21
  {
22
- "matcher": "manual|auto",
22
+ "matcher": "",
23
23
  "hooks": [
24
24
  {
25
25
  "type": "command",
@@ -31,7 +31,7 @@
31
31
  ],
32
32
  "PreToolUse": [
33
33
  {
34
- "matcher": "Write|Edit|NotebookEdit",
34
+ "matcher": "Write|Edit|NotebookEdit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
35
35
  "hooks": [
36
36
  {
37
37
  "type": "command",
@@ -56,7 +56,7 @@
56
56
  ]
57
57
  },
58
58
  {
59
- "matcher": "Write",
59
+ "matcher": "Write|Edit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
60
60
  "hooks": [
61
61
  {
62
62
  "type": "command",
package/hooks/lock-gate CHANGED
@@ -1,6 +1,15 @@
1
1
  #!/bin/bash
2
2
  INPUT=$(cat)
3
- FILE_PATH=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("tool_params", "file_path") || data.dig("tool_input", "file_path") || ""' 2>/dev/null)
3
+ FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
4
+ data = JSON.parse(STDIN.read)
5
+ ti = data["tool_input"] || data["tool_params"] || {}
6
+ path = ti["file_path"] || ti["notebook_path"] || ti["relative_path"] || ""
7
+ if !path.empty? && !path.start_with?("/")
8
+ root = ti["project_root"] || data["cwd"] || ""
9
+ path = File.join(root, path) unless root.empty?
10
+ end
11
+ puts path
12
+ ' 2>/dev/null)
4
13
 
5
14
  if [ -z "$FILE_PATH" ]; then
6
15
  exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-beta.23",
3
+ "version": "1.0.0-beta.25",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.rb CHANGED
@@ -20,6 +20,7 @@ require_relative "lib/intent_validator"
20
20
  require_relative "lib/graph_rebuild"
21
21
  require_relative "lib/links_projection"
22
22
  require_relative "lib/links_section"
23
+ require_relative "lib/hook_registry"
23
24
 
24
25
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
25
26
  # run it hermetically (no eval, no global-constant rewriting).
@@ -472,7 +473,10 @@ class Doctor
472
473
  category: "conventions", name: "section_structure", status: "warn",
473
474
  message: "#{bad_sections.size} intent file(s) have non-sanctioned ## sections",
474
475
  details: bad_sections.map { |b| "#{b[:dir]}: #{b[:issues].join(", ")}" },
475
- fixable: false
476
+ fixable: true,
477
+ fix_hint: "Dispatch plastic-intent-curator to relocate each unsanctioned section into the " \
478
+ "intent's revisions.md via move-and-record (a missing required section is restored " \
479
+ "or reprojected instead); see PLASTIC.md > Structural maintenance and revisions.md"
476
480
  )
477
481
  end
478
482
 
@@ -698,7 +702,9 @@ class Doctor
698
702
  checks << graph_finding_check(
699
703
  "graph_i4_danglers", i4,
700
704
  "Every sources/chain id resolves to a real intent (I4)",
701
- "Fix or remove the dangling id reference"
705
+ "Dispatch plastic-intent-curator to record the dangling sources/chain edge as a " \
706
+ "broken-source/broken-chain move-and-record entry in the intent's revisions.md (see " \
707
+ "PLASTIC.md > Structural maintenance and revisions.md), or restore the missing intent"
702
708
  )
703
709
  checks
704
710
  end
@@ -822,6 +828,45 @@ class Doctor
822
828
  fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
823
829
  )
824
830
  end
831
+
832
+ # hooks_match_registry (intent 108, D7): the live settings must carry
833
+ # EXACTLY the registrations HookRegistry defines; any drift (a missing
834
+ # gate, a stray plastic hook, a stale matcher) is how bash-gate shipped
835
+ # dead once already.
836
+ expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
837
+ diffs = []
838
+ expected.each do |event, group|
839
+ groups = group.is_a?(Array) ? group : [group]
840
+ live = settings.dig("hooks", event) || []
841
+ groups.each do |g|
842
+ match = live.find { |h| h.is_a?(Hash) && h["matcher"] == g["matcher"] }
843
+ wanted = g["hooks"].map { |h| h["command"] }
844
+ got = match ? Array(match["hooks"]).map { |h| h["command"] } : []
845
+ missing = wanted - got
846
+ diffs << "#{event}[#{g['matcher']}] missing: #{missing.join(', ')}" unless missing.empty?
847
+ end
848
+ end
849
+ live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
850
+ Array(groups).flat_map do |g|
851
+ next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
852
+ g["hooks"].map { |h| h["command"].to_s }.select { |c| c.include?("plastic-") }
853
+ .map { |c| "#{event}: #{c}" }
854
+ end
855
+ end
856
+ expected_cmds = expected.values.flat_map { |g| g.is_a?(Array) ? g : [g] }
857
+ .flat_map { |g| g["hooks"].map { |h| h["command"] } }
858
+ strays = live_plastic.reject { |lp| expected_cmds.any? { |c| lp.end_with?(c) } }
859
+ diffs.concat(strays.map { |s| "stray: #{s}" })
860
+
861
+ checks << if diffs.empty?
862
+ check(category: "agent_registration", name: "hooks_match_registry",
863
+ status: "pass", message: "settings.json hooks match HookRegistry")
864
+ else
865
+ check(category: "agent_registration", name: "hooks_match_registry",
866
+ status: "fail", message: "#{diffs.size} hook registration(s) diverge from HookRegistry",
867
+ details: diffs, fixable: true,
868
+ fix_hint: "Re-run the installer merge: npx @zalom/plastic update (or ruby ~/.plastic/scripts/install.rb)")
869
+ end
825
870
  end
826
871
 
827
872
  # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
@@ -28,13 +28,31 @@ exit 0 if command.nil? || command.to_s.strip.empty?
28
28
  cwd = payload["cwd"]
29
29
  cwd = Dir.pwd if cwd.nil? || cwd.to_s.empty?
30
30
 
31
- # --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
32
31
  session = payload["session_id"]
33
32
  session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.to_s.empty?
33
+
34
+ # Auditable escape (intent 108, D7): a trailing `# plastic-ok` allows the
35
+ # command and logs it, so sanctioned writes are visible, not silent.
36
+ if Bridge.bash_escape?(command)
37
+ begin
38
+ require "fileutils"
39
+ log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
40
+ FileUtils.mkdir_p(File.dirname(log))
41
+ File.open(log, "a") do |io|
42
+ io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{command.gsub(/\s+/, ' ').strip}")
43
+ end
44
+ rescue StandardError
45
+ # the escape still applies; logging is best-effort
46
+ end
47
+ exit 0
48
+ end
49
+
50
+ # --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
51
+ # A nil bridge does NOT short-circuit (intent 108): the lock gate decides from
52
+ # the durable delivery.lock file, so it must run even without a bridge cache.
34
53
  bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
35
- exit 0 unless bridge_data
36
54
 
37
- reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd)
55
+ reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd, session: session)
38
56
  exit 0 unless reason
39
57
 
40
58
  $stderr.puts "PLASTIC GATE — #{reason}"
@@ -19,8 +19,13 @@
19
19
  # Reads the Claude Code PreToolUse payload as JSON on STDIN:
20
20
  # { "tool_input": { "file_path": "...", "content": "..." } }
21
21
  # Empty / unparseable / non-matching path => exit 0 (cannot judge, allow).
22
- # Matching path but missing content => exit 2 (fail-safe: refuse to allow an
23
- # unvalidated intent write).
22
+ # Three payload shapes on a matching path (intent 108, D7):
23
+ # Write (content): validate the proposed content.
24
+ # Edit (old_string): simulate the replacement on the on-disk file and
25
+ # validate the RESULT; a missing file blocks.
26
+ # Pathless MCP mutation: validate the CURRENT on-disk file (the PostToolUse
27
+ # gate-check backstop validates the result); a
28
+ # missing file blocks (fail-safe).
24
29
 
25
30
  require "json"
26
31
  require_relative "lib/intent_validator"
@@ -43,14 +48,45 @@ is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
43
48
  File.basename(abs) == "#{File.basename(dir)}.md"
44
49
  exit 0 unless is_intent_file
45
50
 
46
- content = payload.dig("tool_input", "content") || payload.dig("tool_params", "content")
47
- if content.nil?
48
- $stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed content; " \
49
- "refusing to allow an unvalidated intent write."
51
+ input = payload["tool_input"] || payload["tool_params"] || {}
52
+ content = input["content"]
53
+ old_string = input["old_string"]
54
+
55
+ def block!(lines)
56
+ Array(lines).each { |l| $stderr.puts l }
50
57
  exit 2
51
58
  end
52
59
 
53
- result = IntentValidator.validate_content(content)
60
+ result =
61
+ if !content.nil?
62
+ # Write: validate the proposed content (intent 60b, unchanged).
63
+ IntentValidator.validate_content(content)
64
+ elsif !old_string.nil?
65
+ # Edit (intent 108, D7): simulate the replacement on the on-disk file and
66
+ # validate the RESULT, so an Edit can no longer sneak an intent file past
67
+ # the validator.
68
+ unless File.exist?(abs)
69
+ block!("PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
70
+ "create intents via new-intent / plastic-creating-intent.")
71
+ end
72
+ current = File.read(abs)
73
+ unless current.include?(old_string)
74
+ exit 0 # the Edit tool itself will fail; nothing lands, nothing to judge
75
+ end
76
+ simulated = input["replace_all"] ? current.gsub(old_string, input["new_string"].to_s)
77
+ : current.sub(old_string, input["new_string"].to_s)
78
+ IntentValidator.validate_content(simulated)
79
+ else
80
+ # Pathless mutation (MCP symbolic edits): the proposal is not visible at
81
+ # PreToolUse. Judge the CURRENT file: valid -> allow (the PostToolUse
82
+ # gate-check backstop validates the result), invalid/missing -> block.
83
+ unless File.exist?(abs)
84
+ block!("PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed " \
85
+ "content and no file exists; refusing to allow an unvalidated intent write.")
86
+ end
87
+ IntentValidator.validate_content(File.read(abs))
88
+ end
89
+
54
90
  exit 0 if result[:ok]
55
91
 
56
92
  $stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
@@ -9,6 +9,7 @@
9
9
  require "json"
10
10
  require_relative "lib/bridge"
11
11
  require_relative "lib/intent_validator"
12
+ require_relative "lib/lock"
12
13
 
13
14
  file_path = ARGV[0]
14
15
  exit 0 unless file_path && !file_path.empty?
@@ -72,6 +73,15 @@ intent_dir = "#{store}/#{dir}"
72
73
  # Normalize both paths for comparison
73
74
  bridge_intent_dir_abs = File.expand_path(intent_dir)
74
75
 
76
+ # Lease heartbeat (intent 108, D1): every write by the session that owns (or
77
+ # delegates under) the lock refreshes the delivery.lock mtime. Best-effort:
78
+ # a heartbeat failure must never break the gate hook.
79
+ begin
80
+ Lock.heartbeat(bridge_intent_dir_abs, session: session)
81
+ rescue StandardError
82
+ # ignore
83
+ end
84
+
75
85
  # Not inside intent dir = not our business
76
86
  exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
77
87
 
@@ -10,15 +10,26 @@
10
10
  # reads never reach here.
11
11
  require "json"
12
12
  require_relative "lib/bridge"
13
+ require_relative "lib/lock"
13
14
 
14
15
  begin
15
16
  file_path = ARGV[0]
16
17
  exit 0 unless file_path && !file_path.empty?
17
18
  session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
18
19
 
19
- bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no lock held
20
- reason = Bridge.lock_gate_decision(bridge_data, file_path)
21
- exit 0 unless reason
20
+ bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no bridge cache
21
+ reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session)
22
+ unless reason
23
+ # Allow path: refresh the lease for the session that holds this target's
24
+ # lock (owner or delegate). Best-effort, never blocks.
25
+ begin
26
+ dir = Bridge.intent_dir_for(file_path)
27
+ Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
28
+ rescue StandardError
29
+ # ignore
30
+ end
31
+ exit 0
32
+ end
22
33
 
23
34
  print JSON.generate(
24
35
  "hookSpecificOutput" => {
@@ -2,18 +2,20 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
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).
5
+ # PreToolUse retrieval gate (intent 84, Lever 2; operation-based redesign 89a;
6
+ # ADVISORY since intent 108, D8). Reads the tool call (JSON on stdin: tool_name +
7
+ # tool_input), computes capabilities (QMD detect + freshness), and delegates the
8
+ # decision to RetrievalGate. It NEVER blocks: reads and searches always run
9
+ # (exit 0). A store content search that QMD could serve better gets a QMD hint
10
+ # via PreToolUse additionalContext on stdout. Hard gates guard writes, locks,
11
+ # and structure; never reads.
10
12
  # Fail-open: any parse error, timeout, or unexpected exception exits 0. On the
11
13
  # STALE QMD path RetrievalGate fires QmdSync.reindex_async (NEVER synchronous).
12
14
  # Binds subagents (PreToolUse hooks apply to subagent tool calls too).
13
15
  #
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.
16
+ # Only CONTENT SEARCH over store markdown gets the hint; reads and structural
17
+ # ops stay silent. Code navigation is a soft prompt recommendation
18
+ # (UserPromptSubmit power-tools), not handled here.
17
19
  #
18
20
  # Scope: only the agent's own Bash/Read/Grep/Glob calls. Ruby `File.read` inside
19
21
  # scripts is invisible to a PreToolUse hook and is out of scope (no exemptions).
@@ -29,13 +31,13 @@ module RetrievalGateHook
29
31
  module_function
30
32
 
31
33
  # Pure-ish core: capabilities and reindex are injected so this is unit-testable
32
- # with no real qmd. Returns [exit_code, stderr_string].
34
+ # with no real qmd. Returns [exit_code, stderr_string, stdout_json].
33
35
  # stdin: raw PreToolUse JSON
34
36
  # capabilities: { qmd:, qmd_fresh: }
35
37
  # reindex: callable fired on the STALE path
36
38
  def run(stdin:, plastic_home:, cwd:, capabilities:, reindex: -> {})
37
39
  payload = parse(stdin)
38
- return [0, nil] unless payload
40
+ return [0, nil, nil] unless payload
39
41
 
40
42
  tool_name = payload["tool_name"].to_s
41
43
  tool_input = payload["tool_input"]
@@ -49,14 +51,23 @@ module RetrievalGateHook
49
51
  ) { |_sig| bypassed = true }
50
52
 
51
53
  if reason
52
- [2, "PLASTIC GATE #{reason}"]
53
- elsif bypassed
54
- [0, "PLASTIC GATE — bypassed via # qmd-ok"]
54
+ # Advisory (intent 108, D8): reads and searches are never denied. The
55
+ # would-block reason becomes a hint the agent sees alongside the result.
56
+ hint = JSON.generate(
57
+ "hookSpecificOutput" => {
58
+ "hookEventName" => "PreToolUse",
59
+ "additionalContext" =>
60
+ "PLASTIC advisory: #{reason} (this search ran; the hint is not a block)"
61
+ }
62
+ )
63
+ [0, nil, hint]
55
64
  else
56
- [0, nil]
65
+ # The `# qmd-ok` bypass token is still accepted (bypassed is set), but
66
+ # with nothing to bypass it no longer announces itself.
67
+ [0, nil, nil]
57
68
  end
58
69
  rescue StandardError
59
- [0, nil] # fail-open
70
+ [0, nil, nil] # fail-open
60
71
  end
61
72
 
62
73
  def parse(raw)
@@ -120,7 +131,7 @@ if $PROGRAM_NAME == __FILE__
120
131
  plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
121
132
  cwd = Dir.pwd
122
133
 
123
- code, err = begin
134
+ code, err, out = begin
124
135
  caps = RetrievalGateHook.detect_capabilities(cwd: cwd)
125
136
  RetrievalGateHook.run(
126
137
  stdin: raw, plastic_home: plastic_home, cwd: cwd,
@@ -128,9 +139,10 @@ if $PROGRAM_NAME == __FILE__
128
139
  reindex: RetrievalGateHook.reindex_for(cwd: cwd, plastic_home: plastic_home)
129
140
  )
130
141
  rescue StandardError
131
- [0, nil] # fail-open at the outermost boundary too
142
+ [0, nil, nil] # fail-open at the outermost boundary too
132
143
  end
133
144
 
134
145
  $stderr.puts(err) if err && !err.empty?
146
+ print out if out && !out.empty?
135
147
  exit code
136
148
  end