@zalom/plastic 1.7.1 → 1.9.0
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 +75 -535
- package/README.md +3 -2
- package/agents/plastic-intent-curator.md +2 -2
- package/deprecations.yml +10 -2
- package/hooks/{links-gate → edit-gates} +1 -1
- package/hooks/hooks.json +4 -44
- package/hooks/power-tools +8 -0
- package/package.json +1 -2
- package/scripts/codex-hook +17 -22
- package/scripts/doctor.rb +688 -186
- package/scripts/end-intent +65 -22
- package/scripts/hook-code-gate +13 -28
- package/scripts/hook-create-gate +10 -57
- package/scripts/hook-edit-gates +58 -0
- package/scripts/hook-links-gate +8 -31
- package/scripts/hook-lock-gate +13 -58
- package/scripts/hook-power-tools +38 -0
- package/scripts/hook-savepoint-pre +8 -11
- package/scripts/lib/bridge.rb +2 -2
- package/scripts/lib/edit_gates.rb +342 -0
- package/scripts/lib/hook_registry.rb +61 -29
- package/scripts/lib/installer_core.rb +100 -18
- package/scripts/lib/outcome_guard.rb +38 -0
- package/scripts/lib/qmd_hook.rb +21 -41
- package/scripts/lib/qmd_sync.rb +0 -15
- package/scripts/lib/revisions_writer.rb +1 -1
- package/scripts/maintenance-run +56 -2
- package/scripts/restore-intent-v1 +16 -11
- package/skills/auto/SKILL.md +15 -4
- package/skills/auto/evals/evals.json +2 -2
- package/skills/conventions/SKILL.md +31 -0
- package/skills/conventions/references/completion-and-done.md +40 -0
- package/skills/conventions/references/gates-and-enforcement.md +23 -0
- package/skills/conventions/references/knowledge-graph.md +47 -0
- package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
- package/skills/conventions/references/locks-and-worktrees.md +113 -0
- package/skills/conventions/references/maintenance-and-revisions.md +170 -0
- package/skills/conventions/references/roadmaps.md +44 -0
- package/skills/conventions/references/tiers-and-dispatch.md +134 -0
- package/skills/doctor/SKILL.md +59 -19
- package/skills/intent-brainstorming/SKILL.md +4 -0
- package/skills/intent-continuing/SKILL.md +4 -0
- package/skills/intent-creating/SKILL.md +7 -2
- package/skills/intent-ending/SKILL.md +20 -6
- package/skills/intent-executing/SKILL.md +10 -0
- package/skills/intent-linking/SKILL.md +4 -0
- package/skills/intent-locking/SKILL.md +4 -0
- package/skills/intent-planning/SKILL.md +7 -0
- package/skills/intent-savepoint/SKILL.md +4 -0
- package/skills/intent-speccing/SKILL.md +4 -0
- package/skills/intent-starting/SKILL.md +10 -0
- package/skills/project-creating/references/project-scaffolding.md +2 -2
- package/skills/roadmap/SKILL.md +4 -0
- package/skills/roadmap-continuing/SKILL.md +4 -0
- package/skills/skill-creating/SKILL.md +3 -0
- package/skills/skill-creating/references/defaults-first.md +23 -0
- package/skills/skill-creating/references/hooks.md +4 -1
- package/skills/store-curating/SKILL.md +8 -0
- package/skills/store-indexing/SKILL.md +8 -0
- package/skills/tutorial/references/track-2-auto.md +2 -3
- package/templates/agents.md +8 -0
- package/PLASTIC-reference.md +0 -138
- package/hooks/code-gate +0 -27
- package/hooks/create-gate +0 -3
- package/hooks/lock-gate +0 -21
- package/hooks/qmd-search +0 -8
- package/hooks/retrieval-gate +0 -10
- package/hooks/savepoint-pre +0 -10
- package/scripts/hook-qmd-search +0 -44
- package/scripts/hook-retrieval-gate +0 -148
- package/scripts/lib/legacy_bookend_amnesty.rb +0 -35
- package/scripts/lib/retrieval_gate.rb +0 -211
package/PLASTIC-reference.md
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
# Plastic - Reference
|
|
2
|
-
|
|
3
|
-
> **This file is maintained by Plastic.** It will be overwritten when the
|
|
4
|
-
> plugin is updated. It holds reference material: read it on demand, it is
|
|
5
|
-
> not injected at session start.
|
|
6
|
-
|
|
7
|
-
### Structural maintenance and revisions.md
|
|
8
|
-
|
|
9
|
-
When a delivered intent accumulates structural junk (an unsanctioned section, a stray file, a
|
|
10
|
-
frontmatter edge to an intent that no longer exists), the intent-curator relocates it into
|
|
11
|
-
`revisions.md` instead of reopening the work. Each entry is a versioned, dated header
|
|
12
|
-
(`## Revision vN - YYYY-MM-DD-HH:MM`) plus `Why` (one sentence naming the broken rule, ending
|
|
13
|
-
with `[rule: <tag>]`), `Prior location`, and either `Content held` (the verbatim removed
|
|
14
|
-
content) or, for a frontmatter edit, a one-line `Change` (before and after). A stray file has
|
|
15
|
-
its full content embedded and the original is deleted.
|
|
16
|
-
|
|
17
|
-
Violation tags (starter set, free-text tags allowed):
|
|
18
|
-
- `unsanctioned-section`: a top-level section the sanctioned-section rule now rejects
|
|
19
|
-
- `phantom-section`: a section referenced but not present or not sanctioned
|
|
20
|
-
- `stray-file`: a file that does not belong in the intent directory
|
|
21
|
-
- `dangling-ref`: a link or reference to something that no longer exists
|
|
22
|
-
- `broken-chain`: a chain frontmatter edge to an intent that no longer exists
|
|
23
|
-
- `broken-source`: a sources frontmatter edge to an intent that no longer exists
|
|
24
|
-
- `misplaced-content`: content that belongs in a different artifact or section
|
|
25
|
-
- `links-projection`: a tool-authored `## Links` regeneration (project-links; intent 197)
|
|
26
|
-
- `graph-rebuild`: a tool-authored sources/chain frontmatter rebuild (rebuild-graph; intent 197)
|
|
27
|
-
|
|
28
|
-
## Two Processes
|
|
29
|
-
|
|
30
|
-
| Process | Scope | Type | Actor |
|
|
31
|
-
|---|---|---|---|
|
|
32
|
-
| **Build → Observe → Repeat** | The system | Continuous loop | Coordinator |
|
|
33
|
-
| **What → Why → How → Exec** | One intent | Finite lifecycle | Agent |
|
|
34
|
-
|
|
35
|
-
B→O→R is the Coordinator's heartbeat. W→W→H→E is what happens inside each intent.
|
|
36
|
-
The connection: an intent's `## Insights` feeds the Coordinator's Observe phase.
|
|
37
|
-
|
|
38
|
-
## Defaults-First
|
|
39
|
-
|
|
40
|
-
Plastic stands on its own. Skills and agents use Plastic's own defaults; an
|
|
41
|
-
external skill (for example `superpowers:*`) is opt-in, never load-bearing.
|
|
42
|
-
|
|
43
|
-
- **Default to Plastic, delegate by exception.** Name the Plastic-native path as
|
|
44
|
-
the default. Delegate to an external skill only when (a) it is available in the
|
|
45
|
-
harness, or (b) the user explicitly asks for it. A user without that plugin must
|
|
46
|
-
still get the core behavior.
|
|
47
|
-
- **Phrase external skills as enhancements.** Write "use Plastic's native X by
|
|
48
|
-
default; if `superpowers:<skill>` is available, or the user prefers it, delegate
|
|
49
|
-
to it" never "delegate to `superpowers:<skill>`" as the only path.
|
|
50
|
-
- **Optional dependencies detect then degrade.** `qmd` is the reference shape:
|
|
51
|
-
`scripts/lib/qmd_sync.rb` detects the binary first and every verb no-ops cleanly
|
|
52
|
-
when it is absent (see `scripts/qmd-sync`). Optional CLIs and MCP servers follow
|
|
53
|
-
the same detect-then-skip pattern, so a missing tool never crashes a session.
|
|
54
|
-
- **Legitimate hard dependencies are exempt.** Ruby, Node, git, and POSIX tools are
|
|
55
|
-
the cost of running Plastic, not silent coupling. The principle targets accidental
|
|
56
|
-
dependence on external skills doing work Plastic should do itself.
|
|
57
|
-
|
|
58
|
-
## Roadmaps
|
|
59
|
-
|
|
60
|
-
Roadmaps exist for planned parallel delivery of intents in a coherent and organized way. A roadmap
|
|
61
|
-
is a named, ordered, delivery-side collection of intents: the delivery-side counterpart to a
|
|
62
|
-
release (completion-side, tracked in `CHANGELOG.md`). Use `plastic-roadmap` to create, order,
|
|
63
|
-
close, and consume one.
|
|
64
|
-
|
|
65
|
-
File location: `roadmaps/{slug}.md`, a sibling of `INDEX.md`, wherever `INDEX.md` lives, never
|
|
66
|
-
inside `store/` (store holds intent directories, not project artifacts). For a project that is its
|
|
67
|
-
root, `~/.plastic/projects/{slug}/roadmaps/`, beside `project.yml`; for the global tier it is
|
|
68
|
-
`~/.plastic/roadmaps/`, beside `~/.plastic/INDEX.md`. `roadmaps/` lists only live (open or
|
|
69
|
-
in-flight) roadmaps: once a roadmap's goal is reached, it moves to `roadmaps/archived/{slug}.md`,
|
|
70
|
-
a sibling subdirectory scaffolded once with a `.gitkeep`.
|
|
71
|
-
|
|
72
|
-
A roadmap file has four sections, in order: a title/meta header, `## Goal`, `## Batches`, and an
|
|
73
|
-
append-only dated `## Log`. `## Goal` is a checkable prose condition read by a human or agent, not
|
|
74
|
-
an executable checker. `## Batches` holds ordered batches; entries inside a batch are
|
|
75
|
-
parallel-safe, batches run sequentially, top to bottom. A roadmap written before owner ruling 145
|
|
76
|
-
may instead use the legacy `## Waves` heading; the tooling accepts both, but never renames an
|
|
77
|
-
existing roadmap file to migrate it.
|
|
78
|
-
|
|
79
|
-
Each batch entry carries a status token (`queued`/`delivering`/`delivered`/`abandoned`/`blocked`)
|
|
80
|
-
that mirrors that intent's status in `INDEX.md`. `INDEX.md` is the single writer of intent status;
|
|
81
|
-
on any conflict INDEX wins and the roadmap entry is corrected to match.
|
|
82
|
-
|
|
83
|
-
**Human-comprehension surface.** A roadmap is also written to be read cold. Batch entries render as
|
|
84
|
-
checkboxes (checked once delivered, unchecked otherwise) next to the status token, and each `## Log`
|
|
85
|
-
line is one plain-language sentence, starting `YYYY-MM-DD HH:MM UTC`, written the way an
|
|
86
|
-
engineering manager would brief a non-expert executive: what shipped and why it matters, no jargon
|
|
87
|
-
or codenames, ending with a link
|
|
88
|
-
to that intent's `outcome.md`. The log points at the detail instead of repeating it, so a person
|
|
89
|
-
opening the file with no other context can tell what shipped, what is running now, and what is
|
|
90
|
-
next in under a minute.
|
|
91
|
-
|
|
92
|
-
**Relationship to loop engineering (intent 69).** A roadmap is the planning half of the work; the
|
|
93
|
-
loop is its runtime. Batches lay out the parallelism plan: what can run together, and in what order.
|
|
94
|
-
Loop engineering (intent 69, not yet delivered) is expected to consume that plan and supply the
|
|
95
|
-
running parts, the heartbeat, how many dispatches run at once, checking the goal, and resuming
|
|
96
|
-
after a stop. This section only states the relationship and points to intent 69 as the future
|
|
97
|
-
consumer; it does not change intent 69's own design.
|
|
98
|
-
|
|
99
|
-
## Context-economy measurement buckets (84a)
|
|
100
|
-
|
|
101
|
-
Intent 84 defines three buckets for sibling 84a to audit against; 84 does not run the audit.
|
|
102
|
-
|
|
103
|
-
- (a) gate-hook prose tokens: the per-transition narration emitted by the gate hook.
|
|
104
|
-
- (b) main-loop store-read tokens: tokens the main agent spends reading or grepping the store
|
|
105
|
-
in the transcript.
|
|
106
|
-
- (c) authored-section sizes: sizes of authored artifacts (INDEX entries and the like).
|
|
107
|
-
|
|
108
|
-
## Deprecation Process
|
|
109
|
-
|
|
110
|
-
Deprecations live in `deprecations.yml` and are shown at SessionStart. While Plastic is
|
|
111
|
-
pre-1.0, a satisfied deprecation (its migration is already done on installed machines) may be
|
|
112
|
-
removed immediately instead of waiting for its declared `removal` version. From `1.0.0` on,
|
|
113
|
-
the steady-state grace rule applies (removal at least two minors ahead). For the full process,
|
|
114
|
-
severity levels, and the pre-1.0 exception, see the `plastic-releasing` skill.
|
|
115
|
-
|
|
116
|
-
## Skills Reference
|
|
117
|
-
|
|
118
|
-
Detailed conventions live inside the skills that use them, not in this file.
|
|
119
|
-
|
|
120
|
-
| Topic | Skill | References in skill |
|
|
121
|
-
|-------|-------|-------------------|
|
|
122
|
-
| Creating intents, lifecycle | `plastic-intent-creating` | lifecycle, wikilinks |
|
|
123
|
-
| Brainstorming, spec writing | `plastic-intent-brainstorming` | — |
|
|
124
|
-
| Planning, actions | `plastic-intent-planning` | — |
|
|
125
|
-
| Execution, delivery | `plastic-intent-executing` | — |
|
|
126
|
-
| Autonomous delivery | `plastic-auto` | agent architecture |
|
|
127
|
-
| Save/restore state | `plastic-intent-savepoint`, `plastic-intent-continuing` | context management |
|
|
128
|
-
| Knowledge graph, linking | `plastic-intent-linking` | zettelkasten, wikilinks |
|
|
129
|
-
| Projects, hubs | `plastic-project-creating` | hubs, project stores |
|
|
130
|
-
| Provision a project store | `plastic-store-provisioning` | project stores |
|
|
131
|
-
| Index maintenance | `plastic-store-indexing` | — |
|
|
132
|
-
| Releases, deprecations | `plastic-releasing` | deprecation process |
|
|
133
|
-
| Health diagnostics | `plastic-doctor` | three scopes: `--core` (binary install-integrity check, runs on SessionStart), `--store [global\|<slug>]` (per-store check, runs on dashboard load), no flag = full check (runs after every update); gate enforcement, stuck detection |
|
|
134
|
-
| Report a Plastic quirk, bug, or feature idea | `plastic-feedback` | transport and privacy (redaction checklist, why a prefilled URL) |
|
|
135
|
-
| Authoring skills, agents, hooks | `plastic-skill-creating` | progressive disclosure, agentskills.io spec |
|
|
136
|
-
| Evaluating skills, evals | `plastic-skill-evaluating` | eval methodology, convention checks |
|
|
137
|
-
| Create, order, and consume a roadmap of intents | `plastic-roadmap` | file format, operations |
|
|
138
|
-
|
package/hooks/code-gate
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
INPUT=$(cat)
|
|
3
|
-
FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
|
|
4
|
-
data = JSON.parse(STDIN.read)
|
|
5
|
-
ti = data["tool_input"] || data["tool_params"] || {}
|
|
6
|
-
path = ti["file_path"] || ti["notebook_path"] || ti["relative_path"] || ""
|
|
7
|
-
if !path.empty? && !path.start_with?("/")
|
|
8
|
-
root = ti["project_root"] || data["cwd"] || ""
|
|
9
|
-
path = File.join(root, path) unless root.empty?
|
|
10
|
-
end
|
|
11
|
-
puts path
|
|
12
|
-
' 2>/dev/null)
|
|
13
|
-
|
|
14
|
-
if [ -z "$FILE_PATH" ]; then
|
|
15
|
-
exit 0
|
|
16
|
-
fi
|
|
17
|
-
|
|
18
|
-
SESSION_ID=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("session_id") || ""' 2>/dev/null)
|
|
19
|
-
|
|
20
|
-
NEW_CONTENT=$(echo "$INPUT" | ruby -rjson -e '
|
|
21
|
-
data = JSON.parse(STDIN.read)
|
|
22
|
-
ti = data["tool_input"] || data["tool_params"] || {}
|
|
23
|
-
print ti["content"] || ti["new_string"] || ""
|
|
24
|
-
' 2>/dev/null)
|
|
25
|
-
|
|
26
|
-
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
27
|
-
ruby "$SCRIPT_DIR/../scripts/hook-code-gate" "$FILE_PATH" "$SESSION_ID" "$NEW_CONTENT"
|
package/hooks/create-gate
DELETED
package/hooks/lock-gate
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
INPUT=$(cat)
|
|
3
|
-
FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
|
|
4
|
-
data = JSON.parse(STDIN.read)
|
|
5
|
-
ti = data["tool_input"] || data["tool_params"] || {}
|
|
6
|
-
path = ti["file_path"] || ti["notebook_path"] || ti["relative_path"] || ""
|
|
7
|
-
if !path.empty? && !path.start_with?("/")
|
|
8
|
-
root = ti["project_root"] || data["cwd"] || ""
|
|
9
|
-
path = File.join(root, path) unless root.empty?
|
|
10
|
-
end
|
|
11
|
-
puts path
|
|
12
|
-
' 2>/dev/null)
|
|
13
|
-
|
|
14
|
-
if [ -z "$FILE_PATH" ]; then
|
|
15
|
-
exit 0
|
|
16
|
-
fi
|
|
17
|
-
|
|
18
|
-
SESSION_ID=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("session_id") || ""' 2>/dev/null)
|
|
19
|
-
|
|
20
|
-
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
21
|
-
ruby "$SCRIPT_DIR/../scripts/hook-lock-gate" "$FILE_PATH" "$SESSION_ID"
|
package/hooks/qmd-search
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# qmd-first search hook launcher (intent 66). No-op when there is no global store.
|
|
3
|
-
GLOBAL_INDEX="$HOME/.plastic/INDEX.md"
|
|
4
|
-
if [ ! -f "$GLOBAL_INDEX" ]; then
|
|
5
|
-
exit 0
|
|
6
|
-
fi
|
|
7
|
-
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
-
exec ruby "$SCRIPT_DIR/../scripts/hook-qmd-search" "$HOME/.plastic"
|
package/hooks/retrieval-gate
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
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"
|
package/hooks/savepoint-pre
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
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/scripts/hook-qmd-search
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# encoding: UTF-8
|
|
3
|
-
# frozen_string_literal: true
|
|
4
|
-
|
|
5
|
-
# qmd-first UserPromptSubmit hook (intent 66). Reads the prompt from stdin JSON,
|
|
6
|
-
# delegates the decision to QmdHook.run under a hard timeout, and prints
|
|
7
|
-
# hookSpecificOutput when there is something to inject/remind. Silent no-op (exit
|
|
8
|
-
# 0) when qmd is absent, the prompt is trivial, or anything goes wrong. ARGV[0]
|
|
9
|
-
# is the plastic_home (passed by the launcher); defaults to ~/.plastic.
|
|
10
|
-
require "json"
|
|
11
|
-
require "timeout"
|
|
12
|
-
require_relative "lib/qmd_hook"
|
|
13
|
-
|
|
14
|
-
raw = begin
|
|
15
|
-
STDIN.read
|
|
16
|
-
rescue StandardError
|
|
17
|
-
""
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
prompt = begin
|
|
21
|
-
parsed = JSON.parse(raw)
|
|
22
|
-
parsed.is_a?(Hash) ? parsed["user_prompt"].to_s : ""
|
|
23
|
-
rescue StandardError
|
|
24
|
-
""
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
|
|
28
|
-
|
|
29
|
-
context = begin
|
|
30
|
-
Timeout.timeout(2) do
|
|
31
|
-
QmdHook.run(prompt: prompt, cwd: Dir.pwd, plastic_home: plastic_home)
|
|
32
|
-
end
|
|
33
|
-
rescue Exception
|
|
34
|
-
nil
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
exit 0 if context.nil? || context.strip.empty?
|
|
38
|
-
|
|
39
|
-
puts JSON.generate(
|
|
40
|
-
"hookSpecificOutput" => {
|
|
41
|
-
"hookEventName" => "UserPromptSubmit",
|
|
42
|
-
"additionalContext" => context,
|
|
43
|
-
}
|
|
44
|
-
)
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# encoding: UTF-8
|
|
3
|
-
# frozen_string_literal: true
|
|
4
|
-
|
|
5
|
-
# PreToolUse retrieval gate (intent 84, Lever 2; operation-based redesign 89a;
|
|
6
|
-
# ADVISORY since intent 108, D8). Reads the tool call (JSON on stdin: tool_name +
|
|
7
|
-
# tool_input), computes capabilities (QMD detect + freshness), and delegates the
|
|
8
|
-
# decision to RetrievalGate. It NEVER blocks: reads and searches always run
|
|
9
|
-
# (exit 0). A store content search that QMD could serve better gets a QMD hint
|
|
10
|
-
# via PreToolUse additionalContext on stdout. Hard gates guard writes, locks,
|
|
11
|
-
# and structure; never reads.
|
|
12
|
-
# Fail-open: any parse error, timeout, or unexpected exception exits 0. On the
|
|
13
|
-
# STALE QMD path RetrievalGate fires QmdSync.reindex_async (NEVER synchronous).
|
|
14
|
-
# Binds subagents (PreToolUse hooks apply to subagent tool calls too).
|
|
15
|
-
#
|
|
16
|
-
# Only CONTENT SEARCH over store markdown gets the hint; reads and structural
|
|
17
|
-
# ops stay silent. Code navigation is a soft prompt recommendation
|
|
18
|
-
# (UserPromptSubmit power-tools), not handled here.
|
|
19
|
-
#
|
|
20
|
-
# Scope: only the agent's own Bash/Read/Grep/Glob calls. Ruby `File.read` inside
|
|
21
|
-
# scripts is invisible to a PreToolUse hook and is out of scope (no exemptions).
|
|
22
|
-
#
|
|
23
|
-
# ARGV[0] is plastic_home (passed by the launcher), like hook-qmd-search.
|
|
24
|
-
|
|
25
|
-
require "json"
|
|
26
|
-
require "timeout"
|
|
27
|
-
require_relative "lib/retrieval_gate"
|
|
28
|
-
require_relative "lib/qmd_sync"
|
|
29
|
-
|
|
30
|
-
module RetrievalGateHook
|
|
31
|
-
module_function
|
|
32
|
-
|
|
33
|
-
# Pure-ish core: capabilities and reindex are injected so this is unit-testable
|
|
34
|
-
# with no real qmd. Returns [exit_code, stderr_string, stdout_json].
|
|
35
|
-
# stdin: raw PreToolUse JSON
|
|
36
|
-
# capabilities: { qmd:, qmd_fresh: }
|
|
37
|
-
# reindex: callable fired on the STALE path
|
|
38
|
-
def run(stdin:, plastic_home:, cwd:, capabilities:, reindex: -> {})
|
|
39
|
-
payload = parse(stdin)
|
|
40
|
-
return [0, nil, nil] unless payload
|
|
41
|
-
|
|
42
|
-
tool_name = payload["tool_name"].to_s
|
|
43
|
-
tool_input = payload["tool_input"]
|
|
44
|
-
tool_input = {} unless tool_input.is_a?(Hash)
|
|
45
|
-
|
|
46
|
-
bypassed = false
|
|
47
|
-
reason = RetrievalGate.decision(
|
|
48
|
-
tool_name: tool_name, tool_input: tool_input,
|
|
49
|
-
plastic_home: plastic_home, cwd: cwd,
|
|
50
|
-
capabilities: capabilities, reindex: reindex
|
|
51
|
-
) { |_sig| bypassed = true }
|
|
52
|
-
|
|
53
|
-
if reason
|
|
54
|
-
# Advisory (intent 108, D8): reads and searches are never denied. The
|
|
55
|
-
# would-block reason becomes a hint the agent sees alongside the result.
|
|
56
|
-
hint = JSON.generate(
|
|
57
|
-
"hookSpecificOutput" => {
|
|
58
|
-
"hookEventName" => "PreToolUse",
|
|
59
|
-
"additionalContext" =>
|
|
60
|
-
"PLASTIC advisory: #{reason} (this search ran; the hint is not a block)"
|
|
61
|
-
}
|
|
62
|
-
)
|
|
63
|
-
[0, nil, hint]
|
|
64
|
-
else
|
|
65
|
-
# The `# qmd-ok` bypass token is still accepted (bypassed is set), but
|
|
66
|
-
# with nothing to bypass it no longer announces itself.
|
|
67
|
-
[0, nil, nil]
|
|
68
|
-
end
|
|
69
|
-
rescue StandardError
|
|
70
|
-
[0, nil, nil] # fail-open
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def parse(raw)
|
|
74
|
-
data = JSON.parse(raw.to_s)
|
|
75
|
-
data.is_a?(Hash) ? data : nil
|
|
76
|
-
rescue StandardError
|
|
77
|
-
nil
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Detect real capabilities for the live executable. Probes are injected so the
|
|
81
|
-
# three-tier QMD failure model is unit-testable:
|
|
82
|
-
# - absent : QMD not on PATH -> allow, no warn.
|
|
83
|
-
# - broken : QMD present but freshness probe -> tier-b: WARN once, then treat
|
|
84
|
-
# raises/times out as absent this turn (allow).
|
|
85
|
-
# - fresh : probe returns truthy -> gate is live.
|
|
86
|
-
# A slow `qmd status` cannot stall a tool call: a 2s Timeout bounds the probe.
|
|
87
|
-
def detect_capabilities(cwd:,
|
|
88
|
-
detect: -> { QmdSync.detect },
|
|
89
|
-
fresh: -> { QmdSync.fresh? },
|
|
90
|
-
warn: ->(m) { $stderr.puts(m) })
|
|
91
|
-
qmd = detect.call
|
|
92
|
-
qmd_fresh = false
|
|
93
|
-
if qmd
|
|
94
|
-
begin
|
|
95
|
-
qmd_fresh = Timeout.timeout(2) { fresh.call }
|
|
96
|
-
rescue StandardError
|
|
97
|
-
# Tier-b: QMD is present but its freshness probe broke/stalled. Distinct
|
|
98
|
-
# from QMD being absent — warn so a degraded QMD is visible, then fail open
|
|
99
|
-
# (allow this turn, no reindex).
|
|
100
|
-
warn.call("PLASTIC GATE — QMD is present but its freshness probe failed; " \
|
|
101
|
-
"allowing this turn without routing search to QMD (check qmd).")
|
|
102
|
-
qmd = false
|
|
103
|
-
qmd_fresh = false
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
{ qmd: qmd, qmd_fresh: qmd_fresh }
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Best-effort reindex callable for the STALE path. Resolves the collection from
|
|
110
|
-
# cwd (project + global) and fires the async reindexer for each; never raises.
|
|
111
|
-
def reindex_for(cwd:, plastic_home:)
|
|
112
|
-
lambda do
|
|
113
|
-
begin
|
|
114
|
-
cols = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
|
|
115
|
-
cols.each { |c| QmdSync.reindex_async(collection: c) }
|
|
116
|
-
rescue StandardError
|
|
117
|
-
# non-fatal; the read is already allowed this turn
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# --- executable entrypoint ---
|
|
124
|
-
if $PROGRAM_NAME == __FILE__
|
|
125
|
-
raw = begin
|
|
126
|
-
$stdin.read
|
|
127
|
-
rescue StandardError
|
|
128
|
-
""
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
|
|
132
|
-
cwd = Dir.pwd
|
|
133
|
-
|
|
134
|
-
code, err, out = begin
|
|
135
|
-
caps = RetrievalGateHook.detect_capabilities(cwd: cwd)
|
|
136
|
-
RetrievalGateHook.run(
|
|
137
|
-
stdin: raw, plastic_home: plastic_home, cwd: cwd,
|
|
138
|
-
capabilities: caps,
|
|
139
|
-
reindex: RetrievalGateHook.reindex_for(cwd: cwd, plastic_home: plastic_home)
|
|
140
|
-
)
|
|
141
|
-
rescue StandardError
|
|
142
|
-
[0, nil, nil] # fail-open at the outermost boundary too
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
$stderr.puts(err) if err && !err.empty?
|
|
146
|
-
print out if out && !out.empty?
|
|
147
|
-
exit code
|
|
148
|
-
end
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
# encoding: UTF-8
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
# Intent 170a - A2 cutoff amnesty for the legacy savepoint Done-bookend gap.
|
|
5
|
-
#
|
|
6
|
-
# Frozen 2026-07-10. Pre-161 terminal intents predate
|
|
7
|
-
# Bridge.append_terminal_savepoint, so their savepoint.md never got a
|
|
8
|
-
# `Done delivered|abandoned` line. This list grandfathers exactly those,
|
|
9
|
-
# keyed by store scope plus intent id, so doctor's signals_complete check
|
|
10
|
-
# stops counting them as gaps. Every intent NOT on this list still warns if
|
|
11
|
-
# its savepoint lacks the Done bookend, including any new terminal intent
|
|
12
|
-
# going forward. This is a frozen historical snapshot: it must never grow.
|
|
13
|
-
# Regenerating it requires re-running the exact predicate below against the
|
|
14
|
-
# real store and reviewing the diff, never appending ad hoc.
|
|
15
|
-
#
|
|
16
|
-
# Predicate used to build this list (2026-07-10): for each store in
|
|
17
|
-
# Doctor#done_signal_stores(nil), each dir in index_sections_by_dir(index),
|
|
18
|
-
# terminal = the dir's INDEX section is Completed or Abandoned, gap =
|
|
19
|
-
# savepoint.md exists AND does not match
|
|
20
|
-
# /\bDone\b.*\b(delivered|abandoned)\b/.
|
|
21
|
-
#
|
|
22
|
-
# This does NOT grandfather the separate outcome.md completeness gap
|
|
23
|
-
# (checked independently at scripts/doctor.rb:603-607); some of these ids
|
|
24
|
-
# may still warn on that axis.
|
|
25
|
-
module LegacyBookendAmnesty
|
|
26
|
-
LIST = {
|
|
27
|
-
"global" => %w[1a 1a2 23].freeze,
|
|
28
|
-
"project:plastic" => %w[
|
|
29
|
-
1 11 121a 124 128 13 13b 15 158 158a 159 160 163 1a 1b1a3 22 30a1a 34
|
|
30
|
-
36a 36a1 37 38 39 45 45a 49 4a 4a1 4a1c1 50 52 54 55 56 58 59 60b 65
|
|
31
|
-
66 66a 66b 66c 66c1 67 68 71 72 73b 73c 73c1 73c2 73c3 74 77 79 80 83
|
|
32
|
-
84 85a 9
|
|
33
|
-
].freeze,
|
|
34
|
-
}.freeze
|
|
35
|
-
end
|
|
@@ -1,211 +0,0 @@
|
|
|
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, redesigned
|
|
7
|
-
# operation-based in intent 89a.
|
|
8
|
-
#
|
|
9
|
-
# Given an agent tool call (Bash/Read/Grep/Glob) and injected capability signals,
|
|
10
|
-
# it decides whether to BLOCK the call (returning a redirect-to-QMD reason String)
|
|
11
|
-
# or ALLOW it (returning nil). All capability/freshness signals are injected by the
|
|
12
|
-
# caller (the hook); this module shells out to nothing, reads no globals, and runs
|
|
13
|
-
# no binaries. Mirrors bridge.rb's decision-fn convention (reason String to block,
|
|
14
|
-
# nil to allow).
|
|
15
|
-
#
|
|
16
|
-
# Operation-based policy (intent 89, ## Redesign):
|
|
17
|
-
# - The gate distinguishes DISCOVERY (content search) from READING a known target.
|
|
18
|
-
# - Only CONTENT SEARCH over store markdown is hard-gated -> QMD.
|
|
19
|
-
# - Reading a known target (Read, cat/head/tail) and structural discovery (Glob,
|
|
20
|
-
# find, ls) are ALWAYS allowed, including over the store.
|
|
21
|
-
# - Code navigation is a soft prompt MANDATE (PowerTools / UserPromptSubmit), not a
|
|
22
|
-
# hard gate here. Content grep over code is allowed (Serena cannot grep strings).
|
|
23
|
-
#
|
|
24
|
-
# Content-search vectors (the only ones that can be gated):
|
|
25
|
-
# - the Grep tool (its `path` search root)
|
|
26
|
-
# - bash `grep`/`rg`/`ag` (their path args; the first bareword is the PATTERN)
|
|
27
|
-
#
|
|
28
|
-
# QMD enforcement is BINARY (no advisory tier):
|
|
29
|
-
# - store-md content search: QMD detected+fresh -> BLOCK; detected+stale -> fire
|
|
30
|
-
# reindex, ALLOW this turn; absent/broken -> ALLOW (the hook warns on broken).
|
|
31
|
-
#
|
|
32
|
-
# Bypass: a TRAILING `# qmd-ok` shell comment on a Bash command (not a substring; a
|
|
33
|
-
# quoted/echoed occurrence does not bypass). It is the auditable seam for "I tried
|
|
34
|
-
# discovery and it did not serve me" (empty, low, or wrongly-scored results).
|
|
35
|
-
#
|
|
36
|
-
# Scope: only the agent's own tool calls. Ruby `File.read` inside scripts is invisible
|
|
37
|
-
# to a PreToolUse hook and is explicitly out of scope (no exemptions).
|
|
38
|
-
module RetrievalGate
|
|
39
|
-
module_function
|
|
40
|
-
|
|
41
|
-
# A `# qmd-ok` token that is a real TRAILING shell comment, after stripping a
|
|
42
|
-
# trailing newline. The token must be preceded by whitespace (or start the
|
|
43
|
-
# command) and run to end-of-string. `echo "# qmd-ok"` does NOT match: the token
|
|
44
|
-
# there is followed by a closing quote, not end-of-string.
|
|
45
|
-
BYPASS_RE = /(?:\A|\s)#\s*qmd-ok\s*\z/.freeze
|
|
46
|
-
|
|
47
|
-
# Bash utilities that perform CONTENT SEARCH (scan file CONTENT for a pattern).
|
|
48
|
-
# These are the only bash read-vectors that can be gated; readers (cat/head/tail)
|
|
49
|
-
# and structural tools (find/ls) are never gated.
|
|
50
|
-
CONTENT_SEARCH_UTILS = %w[grep rg ag].freeze
|
|
51
|
-
|
|
52
|
-
# Decide. Returns nil to ALLOW, or a reason String to BLOCK.
|
|
53
|
-
# capabilities: { qmd:, qmd_fresh: } (booleans).
|
|
54
|
-
# reindex: no-arg callable fired once when a QMD-class target is STALE.
|
|
55
|
-
# When bypassed, returns nil and (if given) yields :bypass to the optional block
|
|
56
|
-
# so the caller can log it.
|
|
57
|
-
def decision(tool_name:, tool_input:, plastic_home:, cwd:,
|
|
58
|
-
capabilities:, reindex: -> {})
|
|
59
|
-
targets = extract_targets(tool_name, tool_input, cwd: cwd)
|
|
60
|
-
return nil if targets.empty?
|
|
61
|
-
|
|
62
|
-
if bypass?(tool_name, tool_input)
|
|
63
|
-
yield(:bypass) if block_given?
|
|
64
|
-
return nil
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
stale_seen = false
|
|
68
|
-
targets.each do |path|
|
|
69
|
-
next unless classify(path, plastic_home: plastic_home) == :qmd
|
|
70
|
-
|
|
71
|
-
if capabilities[:qmd] && capabilities[:qmd_fresh]
|
|
72
|
-
return qmd_reason(path)
|
|
73
|
-
elsif capabilities[:qmd] # present but stale
|
|
74
|
-
stale_seen = true
|
|
75
|
-
end
|
|
76
|
-
# absent/broken -> allow this target
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
reindex.call if stale_seen
|
|
80
|
-
nil
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# --- classification ---
|
|
84
|
-
|
|
85
|
-
# Operation-based: the store tree is the only gated class (content search whose
|
|
86
|
-
# target is at/under a store routes to QMD). Everything else is allowed.
|
|
87
|
-
def classify(path, plastic_home:)
|
|
88
|
-
return :allow if path.nil? || path.empty?
|
|
89
|
-
store_path?(path, plastic_home: plastic_home) ? :qmd : :allow
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# A path AT or UNDER the global store or a project store. We gate the whole store
|
|
93
|
-
# tree (not just `*.md`) because a content search root is usually a directory:
|
|
94
|
-
# grepping the store scans its markdown, which is exactly what QMD should serve.
|
|
95
|
-
def store_path?(path, plastic_home:)
|
|
96
|
-
abs = absolutize(path)
|
|
97
|
-
home = File.expand_path(plastic_home)
|
|
98
|
-
global = File.join(home, "store")
|
|
99
|
-
return true if abs == global || abs.start_with?("#{global}/")
|
|
100
|
-
|
|
101
|
-
projects = File.join(home, "projects")
|
|
102
|
-
return false unless abs.start_with?("#{projects}/")
|
|
103
|
-
tail = abs[(projects.length + 1)..].to_s.split(File::SEPARATOR)
|
|
104
|
-
tail.length >= 2 && tail[1] == "store"
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def absolutize(path)
|
|
108
|
-
File.absolute_path?(path) ? path : File.expand_path(path)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# --- bypass ---
|
|
112
|
-
|
|
113
|
-
# Only Bash commands carry a trailing `# qmd-ok` comment. The token must be a real
|
|
114
|
-
# trailing comment (BYPASS_RE), so a quoted/echoed occurrence does not bypass.
|
|
115
|
-
def bypass?(tool_name, tool_input)
|
|
116
|
-
return false unless tool_name.to_s == "Bash"
|
|
117
|
-
cmd = tool_input.is_a?(Hash) ? tool_input["command"].to_s : ""
|
|
118
|
-
BYPASS_RE.match?(cmd.chomp)
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# --- target extraction ---
|
|
122
|
-
|
|
123
|
-
# Paths a CONTENT-SEARCH operation scans. Reads (Read, cat/head/tail) and
|
|
124
|
-
# structural discovery (Glob, find, ls) are NOT content search -> no targets ->
|
|
125
|
-
# always allowed. Only the Grep tool and bash grep/rg/ag can be gated. Read
|
|
126
|
-
# vectors only (this is a READ gate); write vectors are bridge.rb's job.
|
|
127
|
-
def extract_targets(tool_name, tool_input, cwd:)
|
|
128
|
-
input = tool_input.is_a?(Hash) ? tool_input : {}
|
|
129
|
-
case tool_name.to_s
|
|
130
|
-
when "Grep"
|
|
131
|
-
# The search root is the target; the query text is not a path.
|
|
132
|
-
[input["path"]].compact.reject { |s| s.to_s.empty? }
|
|
133
|
-
when "Bash"
|
|
134
|
-
bash_search_targets(input["command"].to_s)
|
|
135
|
-
else
|
|
136
|
-
# Read, Glob, and every other tool: read / structural op -> never gated.
|
|
137
|
-
[]
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
# CONTENT-SEARCH path args across a compound command. Conservative: missing an
|
|
142
|
-
# exotic form is fine; never flag /dev/null or pure pipes.
|
|
143
|
-
def bash_search_targets(command)
|
|
144
|
-
return [] unless command.is_a?(String) && !command.empty?
|
|
145
|
-
targets = []
|
|
146
|
-
command.split(/[;\n]|&&|\|\||\|/).each do |segment|
|
|
147
|
-
targets.concat(segment_search_targets(segment))
|
|
148
|
-
end
|
|
149
|
-
targets.reject { |t| t.nil? || t.empty? || dev_path?(t) }.uniq
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def segment_search_targets(segment)
|
|
153
|
-
tokens = tokenize(segment)
|
|
154
|
-
return [] if tokens.empty?
|
|
155
|
-
|
|
156
|
-
# Skip leading env-style assignments (FOO=bar cmd ...).
|
|
157
|
-
idx = 0
|
|
158
|
-
idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /\A-/
|
|
159
|
-
util = File.basename(tokens[idx].to_s)
|
|
160
|
-
return [] unless CONTENT_SEARCH_UTILS.include?(util)
|
|
161
|
-
|
|
162
|
-
args = tokens[(idx + 1)..] || []
|
|
163
|
-
path_args_for(args)
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
# Collect path-shaped arguments for a content-search util. Flags are skipped; the
|
|
167
|
-
# first non-flag bareword is the PATTERN, not a path.
|
|
168
|
-
def path_args_for(args)
|
|
169
|
-
paths = []
|
|
170
|
-
pattern_consumed = false
|
|
171
|
-
args.each do |a|
|
|
172
|
-
next if a.start_with?("-")
|
|
173
|
-
unless pattern_consumed
|
|
174
|
-
pattern_consumed = true
|
|
175
|
-
next
|
|
176
|
-
end
|
|
177
|
-
paths << a
|
|
178
|
-
end
|
|
179
|
-
paths
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
# Minimal tokenizer: split on whitespace, strip surrounding matching quotes off
|
|
183
|
-
# each token. Good enough for the conservative read-vector parse.
|
|
184
|
-
def tokenize(segment)
|
|
185
|
-
segment.to_s.strip.split(/\s+/).map { |t| strip_quotes(t) }
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def strip_quotes(token)
|
|
189
|
-
if (token.start_with?('"') && token.end_with?('"')) ||
|
|
190
|
-
(token.start_with?("'") && token.end_with?("'"))
|
|
191
|
-
token[1..-2].to_s
|
|
192
|
-
else
|
|
193
|
-
token
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
def dev_path?(path)
|
|
198
|
-
path == "/dev/null" || path.start_with?("/dev/")
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
# --- reasons ---
|
|
202
|
-
|
|
203
|
-
def qmd_reason(path)
|
|
204
|
-
"retrieval gate: search the store via QMD, not a raw content scan. Reading a " \
|
|
205
|
-
"known file and listing/globbing the store are fine; only CONTENT SEARCH over " \
|
|
206
|
-
"store markdown routes through QMD. Use `qmd search`/`qmd query` over the " \
|
|
207
|
-
"`plastic-*` collections (or `scripts/qmd-sync search`) instead of scanning " \
|
|
208
|
-
"#{path}. If QMD's results do not answer your need (your reading of the " \
|
|
209
|
-
"snippets, not their score), append a trailing `# qmd-ok` to a Bash command."
|
|
210
|
-
end
|
|
211
|
-
end
|