@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 +51 -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-gate-check +13 -22
- package/scripts/hook-retrieval-gate +122 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/lib/bridge.rb +125 -6
- package/scripts/lib/installer_core.rb +14 -0
- package/scripts/lib/qmd_sync.rb +15 -0
- package/scripts/lib/retrieval_gate.rb +238 -0
- package/scripts/new-intent +9 -1
- package/scripts/spawn-preamble +4 -2
- package/skills/auto/SKILL.md +20 -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/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
package/scripts/hook-gate-check
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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" =>
|
|
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
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -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
|
-
#
|
|
310
|
-
#
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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",
|
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
|
|
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
|
package/scripts/new-intent
CHANGED
|
@@ -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.
|
|
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:"
|
package/scripts/spawn-preamble
CHANGED
|
@@ -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).
|
|
47
|
-
"
|
|
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
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 —
|
|
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
|