@zalom/plastic 1.0.0-beta.12 → 1.0.0-beta.14

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
@@ -183,6 +183,10 @@ Sections: `## Active`, `## Future`, `## Clusters`, `## Abandoned`, `## Completed
183
183
 
184
184
  For index maintenance, use `plastic-managing-index`.
185
185
 
186
+ One-line entry convention. Each index entry is ONE line: `- [<id> <terse title>](<dir>) <tags>`.
187
+ The title is the title, not a summary: aim for about 80 characters, no multi-sentence
188
+ descriptions. This is a self-check, not a gate.
189
+
186
190
  ## Rules for Skills
187
191
 
188
192
  ALL work flows through intents.
@@ -194,6 +198,53 @@ ALL work flows through intents.
194
198
  5. Researches are intents. No separate folder.
195
199
  6. Intents are created only via `plastic-creating-intent`. Never hand-author an intent file. The skill self-verifies the written intent with `scripts/validate-intent` before announcing or committing, so every intent is born complete.
196
200
 
201
+ ## House Style (self-check)
202
+
203
+ The agent is the heaviest contributor to the transcript, so terseness pays every turn. These
204
+ are pre-send self-checks the agent applies to its own output. They are not gated.
205
+
206
+ - Answer or decision first. Lead with the result, then support it.
207
+ - Bullets over paragraphs.
208
+ - No preamble, no end-recap. Do not restate the question or summarize what you just said.
209
+ - One question-cluster at a time when asking the human.
210
+ - Reasoning goes in the thinking channel, not duplicated into the visible reply. This keeps
211
+ the human's visibility into your reasoning without paying for it twice in the transcript.
212
+
213
+ Active-intent cache rule. For the intent under active development you already hold its
214
+ delivered artifacts in your own context: prefer revisiting that in-context memory (hit the
215
+ cache) over re-reading them from disk, which only widens context. QMD is for OTHER or indexed
216
+ intents, not for re-reading what you just wrote. Pairs with `/clear` plus savepoint-resume
217
+ hygiene after each intent. Advisory self-check, not hard-verifiable.
218
+
219
+ ## Retrieval Gate
220
+
221
+ A single capability-aware PreToolUse gate enforces retrieval-first routing on the agent's own
222
+ Bash/Read/Grep/Glob calls (and on subagents, since PreToolUse binds them). Detection is binary:
223
+ present means enforce, absent or down means off, with no warning and no advisory tier.
224
+
225
+ - Store markdown (under a Plastic store) routes to QMD when QMD is present and the index is
226
+ fresh: the raw grep/find/Read is blocked and you use `qmd search`/`qmd query` (or
227
+ `scripts/qmd-sync search`) instead. When QMD is present but stale, the read is allowed this
228
+ turn and a background reindex is fired so the next turn enforces against a fresh index;
229
+ reindex is never synchronous. When QMD is absent or down, raw reads are allowed.
230
+ - Serena-supported code and data files route to Serena symbolic tools when Serena is present;
231
+ absent means allowed.
232
+ - Images, binaries, and everything else are allowed.
233
+ - Bypass: append a trailing `# qmd-ok` shell comment to a Bash command for the rare case where
234
+ QMD is healthy but you genuinely need the raw read. A quoted or echoed occurrence does not
235
+ bypass. Bypasses are logged.
236
+ - Scope: only the agent's tool calls. Ruby `File.read` inside a script is invisible to the gate
237
+ and is out of scope by design.
238
+
239
+ ## Context-economy measurement buckets (84a)
240
+
241
+ Intent 84 defines three buckets for sibling 84a to audit against; 84 does not run the audit.
242
+
243
+ - (a) gate-hook prose tokens: the per-transition narration emitted by the gate hook.
244
+ - (b) main-loop store-read tokens: tokens the main agent spends reading or grepping the store
245
+ in the transcript.
246
+ - (c) authored-section sizes: sizes of authored artifacts (INDEX entries and the like).
247
+
197
248
  ## Transition Gates
198
249
 
199
250
  | Transition | Trigger | Gate |
package/hooks/hooks.json CHANGED
@@ -40,6 +40,16 @@
40
40
  }
41
41
  ]
42
42
  },
43
+ {
44
+ "matcher": "Write|Edit",
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" savepoint-pre",
49
+ "statusMessage": "Recording stage start..."
50
+ }
51
+ ]
52
+ },
43
53
  {
44
54
  "matcher": "Write",
45
55
  "hooks": [
@@ -59,6 +69,16 @@
59
69
  "statusMessage": "Checking lifecycle gate..."
60
70
  }
61
71
  ]
72
+ },
73
+ {
74
+ "matcher": "Bash|Read|Grep|Glob",
75
+ "hooks": [
76
+ {
77
+ "type": "command",
78
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" retrieval-gate",
79
+ "statusMessage": "Checking retrieval gate..."
80
+ }
81
+ ]
62
82
  }
63
83
  ],
64
84
  "PostToolUse": [
@@ -0,0 +1,10 @@
1
+ #!/bin/bash
2
+ # Retrieval-gate hook launcher (intent 84, Lever 2). No-op when there is no
3
+ # global store. Passes the PreToolUse JSON through on stdin (exec inherits it)
4
+ # and the plastic_home as ARGV[0], mirroring the qmd-search launcher.
5
+ GLOBAL_INDEX="$HOME/.plastic/INDEX.md"
6
+ if [ ! -f "$GLOBAL_INDEX" ]; then
7
+ exit 0
8
+ fi
9
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ exec ruby "$SCRIPT_DIR/../scripts/hook-retrieval-gate" "$HOME/.plastic"
@@ -0,0 +1,10 @@
1
+ #!/bin/bash
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)
4
+
5
+ if [ -z "$FILE_PATH" ]; then
6
+ exit 0
7
+ fi
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ ruby "$SCRIPT_DIR/../scripts/hook-savepoint-pre" "$FILE_PATH"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-beta.12",
3
+ "version": "1.0.0-beta.14",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,122 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # PreToolUse retrieval gate (intent 84, Lever 2). Reads the tool call (JSON on
6
+ # stdin: tool_name + tool_input), computes capabilities (QMD detect+freshness,
7
+ # Serena detect), delegates the decision to RetrievalGate, and enforces:
8
+ # ALLOW = exit 0 ; BLOCK = exit 2 with reason on stderr (shown to the agent).
9
+ # Fail-open: any parse error, timeout, or unexpected exception exits 0. On the
10
+ # STALE QMD path RetrievalGate fires QmdSync.reindex_async (NEVER synchronous).
11
+ # Binds subagents (PreToolUse hooks apply to subagent tool calls too).
12
+ #
13
+ # Scope: only the agent's own Bash/Read/Grep/Glob calls. Ruby `File.read` inside
14
+ # scripts is invisible to a PreToolUse hook and is out of scope (no exemptions).
15
+ #
16
+ # ARGV[0] is plastic_home (passed by the launcher), like hook-qmd-search.
17
+
18
+ require "json"
19
+ require "timeout"
20
+ require_relative "lib/retrieval_gate"
21
+ require_relative "lib/qmd_sync"
22
+ require_relative "lib/power_tools"
23
+
24
+ module RetrievalGateHook
25
+ module_function
26
+
27
+ # Pure-ish core: capabilities and reindex are injected so this is unit-testable
28
+ # with no real qmd/serena. Returns [exit_code, stderr_string].
29
+ # stdin: raw PreToolUse JSON
30
+ # capabilities: { qmd:, qmd_fresh:, serena: }
31
+ # reindex: callable fired on the STALE path
32
+ def run(stdin:, plastic_home:, cwd:, capabilities:, reindex: -> {})
33
+ payload = parse(stdin)
34
+ return [0, nil] unless payload
35
+
36
+ tool_name = payload["tool_name"].to_s
37
+ tool_input = payload["tool_input"]
38
+ tool_input = {} unless tool_input.is_a?(Hash)
39
+
40
+ bypassed = false
41
+ reason = RetrievalGate.decision(
42
+ tool_name: tool_name, tool_input: tool_input,
43
+ plastic_home: plastic_home, cwd: cwd,
44
+ capabilities: capabilities, reindex: reindex
45
+ ) { |_sig| bypassed = true }
46
+
47
+ if reason
48
+ [2, "PLASTIC GATE — #{reason}"]
49
+ elsif bypassed
50
+ [0, "PLASTIC GATE — bypassed via # qmd-ok"]
51
+ else
52
+ [0, nil]
53
+ end
54
+ rescue StandardError
55
+ [0, nil] # fail-open
56
+ end
57
+
58
+ def parse(raw)
59
+ data = JSON.parse(raw.to_s)
60
+ data.is_a?(Hash) ? data : nil
61
+ rescue StandardError
62
+ nil
63
+ end
64
+
65
+ # Detect real capabilities for the live executable. A slow `qmd status` cannot
66
+ # stall a tool call: a Timeout around the freshness probe degrades to "absent
67
+ # for this turn" (allow, no reindex), staying fail-open and non-blocking.
68
+ def detect_capabilities(cwd:)
69
+ qmd = QmdSync.detect
70
+ qmd_fresh = false
71
+ if qmd
72
+ qmd_fresh = begin
73
+ Timeout.timeout(2) { QmdSync.fresh? }
74
+ rescue StandardError
75
+ # Probe stalled/failed: treat as absent this turn (allow, no reindex).
76
+ qmd = false
77
+ false
78
+ end
79
+ end
80
+ serena = PowerTools.serena?(cwd: cwd)
81
+ { qmd: qmd, qmd_fresh: qmd_fresh, serena: serena }
82
+ end
83
+
84
+ # Best-effort reindex callable for the STALE path. Resolves the collection from
85
+ # cwd (project + global) and fires the async reindexer for each; never raises.
86
+ def reindex_for(cwd:, plastic_home:)
87
+ lambda do
88
+ begin
89
+ cols = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
90
+ cols.each { |c| QmdSync.reindex_async(collection: c) }
91
+ rescue StandardError
92
+ # non-fatal; the read is already allowed this turn
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ # --- executable entrypoint ---
99
+ if $PROGRAM_NAME == __FILE__
100
+ raw = begin
101
+ $stdin.read
102
+ rescue StandardError
103
+ ""
104
+ end
105
+
106
+ plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
107
+ cwd = Dir.pwd
108
+
109
+ code, err = begin
110
+ caps = RetrievalGateHook.detect_capabilities(cwd: cwd)
111
+ RetrievalGateHook.run(
112
+ stdin: raw, plastic_home: plastic_home, cwd: cwd,
113
+ capabilities: caps,
114
+ reindex: RetrievalGateHook.reindex_for(cwd: cwd, plastic_home: plastic_home)
115
+ )
116
+ rescue StandardError
117
+ [0, nil] # fail-open at the outermost boundary too
118
+ end
119
+
120
+ $stderr.puts(err) if err && !err.empty?
121
+ exit code
122
+ 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
@@ -274,6 +274,51 @@ module Bridge
274
274
  end
275
275
  end
276
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
+
277
322
  # --- Cycle-step savepoint ledger (intent 34) ------------------------------
278
323
  #
279
324
  # savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
@@ -306,8 +351,31 @@ module Bridge
306
351
  end.compact
307
352
  end
308
353
 
309
- # Append a milestone line for file_path if (and only if) it is a milestone
310
- # 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.
311
379
  def self.append_savepoint(intent_dir, file_path, now: Time.now)
312
380
  basename = File.basename(file_path)
313
381
  stage, milestone = savepoint_milestone(intent_dir, basename)
@@ -315,11 +383,62 @@ module Bridge
315
383
  # A sentinel-marked lifecycle file logs NO milestone (the stage is not real
316
384
  # yet). The intent file is never sentineled, so it still logs its What line.
317
385
  return false unless stage_file_present?(File.join(intent_dir, basename))
318
- return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
319
386
 
320
- line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
321
- File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
322
- 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)
323
442
  end
324
443
 
325
444
  # Reconstruct the ledger from files on disk (timestamps from mtimes), in
@@ -203,11 +203,14 @@ 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",
213
216
  "scripts/lib/worktree.rb" => "scripts/lib/worktree.rb",
@@ -219,6 +222,8 @@ class InstallerCore
219
222
  "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
220
223
  "scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
221
224
  "scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
225
+ "scripts/project-links" => "scripts/project-links",
226
+ "scripts/rebuild-graph" => "scripts/rebuild-graph",
222
227
  "scripts/validate-intent" => "scripts/validate-intent",
223
228
  "scripts/new-intent" => "scripts/new-intent",
224
229
  "scripts/hook-create-gate" => "scripts/hook-create-gate",
@@ -562,6 +567,15 @@ class InstallerCore
562
567
  { "type" => "command", "command" => "#{hook_dir}/plastic-create-gate", "statusMessage" => "Checking create gate..." },
563
568
  ],
564
569
  },
570
+ # Retrieval gate (intent 84, Lever 2): redirects store-markdown reads to
571
+ # QMD and code reads to Serena when those tools are present. Binds the
572
+ # main agent AND subagents (PreToolUse applies to subagent tool calls).
573
+ {
574
+ "matcher" => "Bash|Read|Grep|Glob",
575
+ "hooks" => [
576
+ { "type" => "command", "command" => "#{hook_dir}/plastic-retrieval-gate", "statusMessage" => "Checking retrieval gate..." },
577
+ ],
578
+ },
565
579
  ],
566
580
  "PostToolUse" => {
567
581
  "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
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
@@ -311,7 +311,15 @@ def main(argv)
311
311
  File.write(File.join(intent_dir, name), "#{Bridge::PLACEHOLDER_SENTINEL}\n#{body}")
312
312
  end
313
313
 
314
- # 6. Self-validate (frontmatter + sanctioned sections).
314
+ # 6. Stamp the born savepoint line (intent 81). The first ledger line is the
315
+ # `What` bookend, written deterministically at creation rather than relying on
316
+ # a PostToolUse gate firing on the intent-file write (which is missed in some
317
+ # sessions / harnesses). The intent file is never a sentinel placeholder, so
318
+ # append_savepoint records `What {id}--{slug}.md`. Idempotent: a later gate
319
+ # fire adds nothing.
320
+ Bridge.append_savepoint(intent_dir, intent_file)
321
+
322
+ # 7. Self-validate (frontmatter + sanctioned sections).
315
323
  result = IntentValidator.validate(intent_dir)
316
324
  unless result[:ok]
317
325
  warn "new-intent: scaffolded intent is NOT born complete:"
@@ -43,8 +43,10 @@ REPORT_CONTRACT =
43
43
  "status (delivered or blocked), artifacts written, verification or tests run, " \
44
44
  "checklist deltas, deviations from spec, and blockers or handoff notes; plus a " \
45
45
  "role-specific payload that fulfils your place in the What, Why, How, Exec cycle " \
46
- "(for example the planner explains the plan back to the orchestrator). See " \
47
- "skills/auto/references/agent-report-contract.md for the per-role format."
46
+ "(for example the planner explains the plan back to the orchestrator). Keep the " \
47
+ "report prose-stripped: the envelope and payload only, no greeting, no preamble, " \
48
+ "no end-recap, no restating of the task; reasoning stays in the thinking channel. " \
49
+ "See skills/auto/references/agent-report-contract.md for the per-role format."
48
50
 
49
51
  def parse_args(argv)
50
52
  role = nil
@@ -90,7 +90,19 @@ Solo fallback: if the harness has no subagent dispatch, fall back to a single ag
90
90
 
91
91
  ## Stage-Aware Entry
92
92
 
93
- Read the active intent's directory. Determine current lifecycle stage from filesystem state:
93
+ Read the active intent's `savepoint.md` FIRST (intent 81): the last line classifies the stage,
94
+ and you then verify only that line's artifact before entering. Fall back to the filesystem probe
95
+ below only when the ledger is missing (then rebuild it with `Bridge.rebuild_savepoint`).
96
+
97
+ | Ledger last line | Enter |
98
+ |---|---|
99
+ | `What {id}--{slug}.md` (born) or no spec | Start / complete Why (write spec.md) |
100
+ | `Why spec.md created` | Enter How |
101
+ | `How plan.md created` / `How checklist.md created` / `Exec started` | Enter Exec (verify plan + checklist) |
102
+ | `Exec outcome.md created` | Exec done; complete the intent |
103
+ | `Done delivered|abandoned` | Terminal; do not resume |
104
+
105
+ Filesystem fallback (ledger missing only):
94
106
 
95
107
  | Check (in order) | Stage |
96
108
  |---|---|
@@ -196,7 +208,13 @@ During initial project creation, all decisions are non-destructive by definition
196
208
  5. Review `## Insights` for observations that should spawn future intents. If any:
197
209
  - Create them (using `plastic-creating-intent` conventions)
198
210
  - Update `chain` in the current intent's frontmatter
199
- 6. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
211
+ 6. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date). As the
212
+ closing act of the transfer, stamp the terminal ledger bookend (intent 81) so the savepoint's
213
+ last line records delivery:
214
+ ```bash
215
+ ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.append_terminal_savepoint("<intent_dir>", "delivered")'
216
+ ```
217
+ (Use `"abandoned"` instead when the intent is being moved to `## Abandoned`.) Idempotent.
200
218
  7. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: deliver intent <ID> — <name>"`
201
219
  8. On completion, ALWAYS refresh the QMD search index for this store (no-op when QMD is absent).
202
220
  It runs in the background so it never blocks the turn:
@@ -17,6 +17,14 @@ only. In-flight observations still go in `## Insights`; the report does not add
17
17
  completed its handoff: the agent that did the work is the cheapest, most accurate source of the
18
18
  account.
19
19
 
20
+ ## Prose-stripped (intent 84)
21
+
22
+ The report is the envelope and the per-role payload, nothing else. Dispatched and background
23
+ subagents report and do their job; they do not narrate. Strip conversational prose: no
24
+ greeting, no preamble, no "Here is what I did" framing, no end-recap, no restating of the task.
25
+ Reasoning belongs in the thinking channel, not the report body. This tightens the FORM (the
26
+ fields stay exactly as below); it does not remove any required field.
27
+
20
28
  ## Common envelope
21
29
 
22
30
  Every role report, whatever the stage, carries these fields:
@@ -63,13 +63,28 @@ command is a no-op when QMD is absent, so fall back to the existing INDEX.md / f
63
63
 
64
64
  For that intent's directory:
65
65
 
66
- 1. **Read `savepoint.md`.** It is a deterministic, append-only stage ledger (one line per
67
- milestone, newest at the bottom): `{utc-iso8601} {Stage} {milestone}`. The **last line =
68
- current stage**.
69
- 2. **Verify the stage file.** Confirm the file the ledger names exists and is non-empty
70
- (ledger `How plan.md created` → `plan.md` must be present and non-empty).
66
+ 1. **Read `savepoint.md` FIRST (intent 81).** It is a deterministic, append-only ledger
67
+ (one line per event, newest at the bottom): `{utc-iso8601} {Stage} {milestone}`. Classify
68
+ the state from the **last line** alone, then verify ONLY that line's artifact. The bookends
69
+ are fixed: first line `What created`, last line either a cycle position or
70
+ `Done delivered|abandoned`.
71
+
72
+ | Last line | State | Verify only |
73
+ |---|---|---|
74
+ | `What {id}--{slug}.md` | born / parked | intent file exists |
75
+ | `Why started` | Why entered, no spec yet | spec.md not yet real; continue Why |
76
+ | `Why spec.md created` | Why done | spec.md present; continue to How |
77
+ | `How started` / `How plan.md created` | How in progress | plan.md; continue How |
78
+ | `How checklist.md created` / `Exec started` | ready for / in Exec | plan.md + checklist.md present; continue Exec |
79
+ | `Exec outcome.md created` | Exec done | outcome.md present; ready to complete |
80
+ | `Done delivered` / `Done abandoned` | terminal | do NOT cycle-resume; INDEX is authoritative |
81
+
82
+ 2. **Verify the stage file.** Confirm only the last line's artifact exists and is non-empty
83
+ (ledger `How plan.md created` → `plan.md` must be present and non-empty). Do not re-probe
84
+ every lifecycle file.
71
85
  3. **Drift handling.** If the ledger's last line disagrees with files-on-disk, rebuild the
72
- ledger from filesystem state and note the correction:
86
+ ledger from filesystem state and note the correction. A rebuilt ledger is the file-landing
87
+ skeleton (no `started`/`Done` lines), which still pins cycle position:
73
88
  ```bash
74
89
  ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.rebuild_savepoint("<intent_dir>")'
75
90
  ```
@@ -38,6 +38,9 @@ The agent handles:
38
38
  - Cluster management (create, merge, rename)
39
39
  - Orphan detection
40
40
 
41
- When an intent reaches a terminal state — moved to Completed OR Abandoned — refresh the QMD index for the affected store (no-op when QMD absent), running in the background so it never blocks: `ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root> --async`.
41
+ When an intent reaches a terminal state — moved to Completed OR Abandoned — do two things as the closing act of the transfer:
42
+
43
+ 1. Stamp the terminal savepoint bookend (intent 81), so the ledger's last line records the disposition: `ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.append_terminal_savepoint("<intent_dir>", "delivered")'` (use `"abandoned"` for an abandoned intent). Idempotent.
44
+ 2. Refresh the QMD index for the affected store (no-op when QMD absent), running in the background so it never blocks: `ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root> --async`.
42
45
 
43
46
  After the agent completes, report what changed.
@@ -41,6 +41,8 @@ Topic-based groupings. Manually curated. Create a new cluster when 3+ intents sh
41
41
  ### Completed
42
42
  All completed intents with dates. Links preserved, never deleted.
43
43
 
44
+ When you move an intent INTO Completed or Abandoned, stamp the terminal savepoint bookend as the closing act of the transfer (intent 81), so the ledger's last line records the disposition: `ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.append_terminal_savepoint("<intent_dir>", "delivered")'` (use `"abandoned"` for an abandoned intent). Idempotent.
45
+
44
46
  ## Workflow
45
47
 
46
48
  QMD-first (when available): when you need to locate a specific intent (to reclassify, flag, or