@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.
- package/PLASTIC.md +71 -0
- package/hooks/hooks.json +20 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/package.json +1 -1
- package/scripts/hook-code-gate +9 -4
- package/scripts/hook-gate-check +13 -22
- package/scripts/hook-retrieval-gate +122 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/lib/bridge.rb +281 -6
- package/scripts/lib/installer_core.rb +13 -0
- package/scripts/lib/qmd_sync.rb +15 -0
- package/scripts/lib/retrieval_gate.rb +238 -0
- package/scripts/lib/worktree.rb +409 -0
- package/scripts/new-intent +9 -1
- package/scripts/spawn-preamble +4 -2
- package/skills/auto/SKILL.md +34 -2
- package/skills/auto/references/agent-report-contract.md +8 -0
- package/skills/continuing/SKILL.md +21 -6
- package/skills/intent-curator/SKILL.md +4 -1
- package/skills/managing-index/SKILL.md +2 -0
- package/skills/releasing/SKILL.md +32 -0
package/scripts/lib/bridge.rb
CHANGED
|
@@ -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
|
-
#
|
|
308
|
-
#
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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",
|
package/scripts/lib/qmd_sync.rb
CHANGED
|
@@ -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
|