@zalom/plastic 1.8.0 → 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 -552
- 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 +85 -3
- 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 +99 -17
- 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/restore-intent-v1 +16 -11
- package/skills/auto/SKILL.md +10 -0
- 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 +9 -1
- 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 +4 -0
- 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/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/retrieval_gate.rb +0 -211
package/README.md
CHANGED
|
@@ -183,8 +183,9 @@ behind releases are part of the repository, not a hidden process.
|
|
|
183
183
|
|
|
184
184
|
## Conventions
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
and overwritten on every update.
|
|
186
|
+
The always-on core conventions live in `PLASTIC.md`, distributed to `~/.plastic/PLASTIC.md`
|
|
187
|
+
and overwritten on every update. Deeper doctrine lives in the `plastic-conventions` skill,
|
|
188
|
+
loaded on demand by the skills that need it. Project-specific rules live in `AGENTS.md`.
|
|
188
189
|
Run `plastic-doctor` to check installation health; it compares files against
|
|
189
190
|
the manifests, checks store state, and runs automatically after every update.
|
|
190
191
|
|
|
@@ -27,9 +27,9 @@ You are the Plastic Intent Curator. Your role is to maintain the health and navi
|
|
|
27
27
|
5. On a terminal-state transition (Completed OR Abandoned), do these things:
|
|
28
28
|
a. Author a real `outcome.md` in the intent directory from `~/.plastic/templates/outcome.md`, with the frontmatter `disposition: delivered` for a completed intent or `disposition: abandoned` for an abandoned one. `outcome.md` is MANDATORY at every terminal, delivered and abandoned alike: on abandon it records the abandonment reason and replaces the scaffolded placeholder sentinel (never leave `outcome.md` a placeholder at a terminal).
|
|
29
29
|
b. Call `plastic-intent-ending` for the terminal-transition close (INDEX move, savepoint `Done` bookend, store commit, disarm, and the QMD reindex last): `ruby ~/.plastic/scripts/end-intent --store <store> --id <id> --disposition delivered|abandoned`, then follow that skill's own disarm and reindex steps. Never restate the INDEX/savepoint/reindex one-liners here.
|
|
30
|
-
6. Structural maintenance is move-and-record, and it is NEVER done without its receipt: remove the misplaced section, file, or ref from its artifact, then create or append `revisions.md` in that intent directory (copy the FORM from `~/.plastic/templates/revisions.md`) IN THE SAME PASS as the edit. If you cannot write `revisions.md` for any reason (permissions, a read-only path), you MUST NOT make the structural edit either - report the blocker instead of leaving an unrecorded change (this mirrors the tool-side rule: project-links, rebuild-graph, and restore-intent-v1 refuse rather than write a change with no receipt; you hold yourself to the same rule by hand). One entry per relocated item, newest at the bottom: a `## Revision vN - YYYY-MM-DD-HH:MM` header, a one-sentence `Why` ending with `[rule: <tag>]`, `Prior location`, and either `Content held` (verbatim) or a one-line `Change` for a frontmatter edit. For a stray file, embed its full content and delete the original. The violation-tag catalog is canonical in
|
|
30
|
+
6. Structural maintenance is move-and-record, and it is NEVER done without its receipt: remove the misplaced section, file, or ref from its artifact, then create or append `revisions.md` in that intent directory (copy the FORM from `~/.plastic/templates/revisions.md`) IN THE SAME PASS as the edit. If you cannot write `revisions.md` for any reason (permissions, a read-only path), you MUST NOT make the structural edit either - report the blocker instead of leaving an unrecorded change (this mirrors the tool-side rule: project-links, rebuild-graph, and restore-intent-v1 refuse rather than write a change with no receipt; you hold yourself to the same rule by hand). One entry per relocated item, newest at the bottom: a `## Revision vN - YYYY-MM-DD-HH:MM` header, a one-sentence `Why` ending with `[rule: <tag>]`, `Prior location`, and either `Content held` (verbatim) or a one-line `Change` for a frontmatter edit. For a stray file, embed its full content and delete the original. The violation-tag catalog is canonical in `plastic-conventions > references/maintenance-and-revisions.md`. A graph edit must move TOWARD ground truth (drop a dangling/false edge, add a reciprocity-forced or documented-real one) and must NEVER invent a relationship - "might be related" is never a valid `[rule:]` reason (`plastic-conventions > references/maintenance-and-revisions.md`, WORK vs MAINTENANCE).
|
|
31
31
|
7. Before performing structural maintenance on ANY intent that is NOT the one your own session is currently delivering under its own held delivery lock, you must:
|
|
32
|
-
a. Check the target's lock freshness: `ruby ~/.plastic/scripts/plastic-lock status --intent-dir <target-intent-dir>` and read the `lock_fresh` field of its JSON output. If `true`, DEFER: make no edit to that intent, and report it as skipped (an active delivery is in progress). This is DETECT-ONLY - you never acquire, create, or hold any lock of your own for maintenance (
|
|
32
|
+
a. Check the target's lock freshness: `ruby ~/.plastic/scripts/plastic-lock status --intent-dir <target-intent-dir>` and read the `lock_fresh` field of its JSON output. If `true`, DEFER: make no edit to that intent, and report it as skipped (an active delivery is in progress). This is DETECT-ONLY - you never acquire, create, or hold any lock of your own for maintenance (`plastic-conventions > references/maintenance-and-revisions.md`, WORK vs MAINTENANCE; there is exactly one lock in Plastic, the delivery lock).
|
|
33
33
|
b. Require a clean store working tree before starting: `git -C ~/.plastic status --porcelain` (or the project store's own root, if not global) must be empty. If it is not, STOP and report the dirty paths rather than risk sweeping an unrelated concurrent change into your own commit; do not proceed until the tree is clean.
|
|
34
34
|
c. Create a fresh branch from the current tip of that repo's main: `git -C <repo-root> checkout -b maintenance/curator-<UTC-timestamp> main`.
|
|
35
35
|
d. Make the scoped edit plus its `revisions.md` receipt (step 6 above), touching nothing else.
|
package/deprecations.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Plastic deprecation notices
|
|
2
2
|
# Each entry is shown to the user during SessionStart.
|
|
3
|
-
# See
|
|
3
|
+
# See the plastic-releasing skill for the full convention.
|
|
4
4
|
#
|
|
5
5
|
# Schema:
|
|
6
6
|
# id: unique-slug (used for dismissal tracking)
|
|
@@ -11,4 +11,12 @@
|
|
|
11
11
|
# removal: semver where feature is removed
|
|
12
12
|
# link: optional URL to migration guide
|
|
13
13
|
|
|
14
|
-
deprecations:
|
|
14
|
+
deprecations:
|
|
15
|
+
- id: plastic-reference-md-absorbed
|
|
16
|
+
severity: info
|
|
17
|
+
summary: "PLASTIC-reference.md has been absorbed into the plastic-conventions skill and removed; its content now lives in that skill's reference chapters."
|
|
18
|
+
migration_steps:
|
|
19
|
+
- "No action is required. Running plastic update removes the leftover ~/.plastic/PLASTIC-reference.md automatically."
|
|
20
|
+
- "If you hand-copied or hand-edited that file, delete your own copy; it is no longer read by Plastic."
|
|
21
|
+
introduced: "1.9.0"
|
|
22
|
+
removal: "1.11.0"
|
package/hooks/hooks.json
CHANGED
|
@@ -35,38 +35,8 @@
|
|
|
35
35
|
"hooks": [
|
|
36
36
|
{
|
|
37
37
|
"type": "command",
|
|
38
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\"
|
|
39
|
-
"statusMessage": "Checking
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
"type": "command",
|
|
43
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" lock-gate",
|
|
44
|
-
"statusMessage": "Checking lock gate..."
|
|
45
|
-
}
|
|
46
|
-
]
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
"matcher": "Write|Edit",
|
|
50
|
-
"hooks": [
|
|
51
|
-
{
|
|
52
|
-
"type": "command",
|
|
53
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" savepoint-pre",
|
|
54
|
-
"statusMessage": "Recording stage start..."
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
"type": "command",
|
|
58
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" links-gate",
|
|
59
|
-
"statusMessage": "Checking Links gate..."
|
|
60
|
-
}
|
|
61
|
-
]
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
"matcher": "Write|Edit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
|
|
65
|
-
"hooks": [
|
|
66
|
-
{
|
|
67
|
-
"type": "command",
|
|
68
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" create-gate",
|
|
69
|
-
"statusMessage": "Checking create gate..."
|
|
38
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" edit-gates",
|
|
39
|
+
"statusMessage": "Checking Plastic gates..."
|
|
70
40
|
}
|
|
71
41
|
]
|
|
72
42
|
},
|
|
@@ -79,16 +49,6 @@
|
|
|
79
49
|
"statusMessage": "Checking lifecycle gate..."
|
|
80
50
|
}
|
|
81
51
|
]
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
"matcher": "Bash|Read|Grep|Glob",
|
|
85
|
-
"hooks": [
|
|
86
|
-
{
|
|
87
|
-
"type": "command",
|
|
88
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" retrieval-gate",
|
|
89
|
-
"statusMessage": "Checking retrieval gate..."
|
|
90
|
-
}
|
|
91
|
-
]
|
|
92
52
|
}
|
|
93
53
|
],
|
|
94
54
|
"PostToolUse": [
|
|
@@ -139,8 +99,8 @@
|
|
|
139
99
|
"hooks": [
|
|
140
100
|
{
|
|
141
101
|
"type": "command",
|
|
142
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\"
|
|
143
|
-
"statusMessage": "
|
|
102
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" power-tools",
|
|
103
|
+
"statusMessage": "Checking power tools..."
|
|
144
104
|
}
|
|
145
105
|
]
|
|
146
106
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# power-tools mandate hook launcher (intents 66, 66b, 187, 246). 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-power-tools"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zalom/plastic",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Intent-driven idea development system for AI coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
"agents/",
|
|
39
39
|
"templates/",
|
|
40
40
|
"PLASTIC.md",
|
|
41
|
-
"PLASTIC-reference.md",
|
|
42
41
|
"deprecations.yml"
|
|
43
42
|
]
|
|
44
43
|
}
|
package/scripts/codex-hook
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
# Usage: codex-hook <gate>
|
|
6
6
|
# file-mutation gates: code-gate | lock-gate | savepoint-pre | gate-check | create-gate | links-gate
|
|
7
7
|
# live-state hooks (intent 199): session-start | check-update | continue |
|
|
8
|
-
# future-intent-check | auto-arm |
|
|
9
|
-
# shell-tool
|
|
8
|
+
# future-intent-check | auto-arm | power-tools | savepoint
|
|
9
|
+
# shell-tool gate (intent 203): bash-gate
|
|
10
10
|
#
|
|
11
11
|
# The Codex input adapter (intent 102, extended by 199 and 203). Reads a Codex
|
|
12
12
|
# hook stdin payload once. Three shapes exist and the gate name alone selects
|
|
@@ -20,17 +20,17 @@
|
|
|
20
20
|
# that hook (hooks/<name>), which is already harness-agnostic (resolves
|
|
21
21
|
# ~/.plastic off $HOME, reads only the common stdin fields the guide confirms
|
|
22
22
|
# Codex shares with Claude for these three events), so the body is reused, not
|
|
23
|
-
# rewritten (D3). The shell-tool
|
|
24
|
-
#
|
|
25
|
-
#
|
|
23
|
+
# rewritten (D3). The shell-tool gate (bash-gate) reports tool_name: "Bash" with
|
|
24
|
+
# the command in tool_input.command, but MUST NOT be routed through
|
|
25
|
+
# ApplyPatchEnvelope.parse below: a plain shell command has no
|
|
26
26
|
# *** Begin Patch/*** End Patch envelope, so it would parse to an empty op list
|
|
27
27
|
# and hit this file's own `exit 0 if ops.empty?` line, silently allowing every
|
|
28
|
-
# Bash call and reopening the exact hole intent 203 closes. So
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
28
|
+
# Bash call and reopening the exact hole intent 203 closes. So it execs the SAME
|
|
29
|
+
# scripts/hook-bash-gate file Claude already runs, unmodified, the identical
|
|
30
|
+
# "drive the body, relay its output" pattern used for the live-state hooks. The
|
|
31
|
+
# one adaptation shared by both the live-state and shell-tool paths is threading
|
|
32
|
+
# the payload's session_id into CLAUDE_CODE_SESSION_ID, since Codex's own
|
|
33
|
+
# process env never carries it, plus
|
|
34
34
|
# a bounded timeout: hooks/check-update backgrounds a real npm network call
|
|
35
35
|
# without redirecting its output away from the inherited stdout/stderr pipes,
|
|
36
36
|
# and Codex invokes hooks synchronously, so without a bound a slow network call
|
|
@@ -50,9 +50,9 @@ require_relative "lib/links_gate"
|
|
|
50
50
|
# caller that merges the dispatcher's own stdout and stderr.
|
|
51
51
|
$stdout.sync = true
|
|
52
52
|
|
|
53
|
-
STATE_HOOKS = %w[session-start check-update continue future-intent-check auto-arm
|
|
53
|
+
STATE_HOOKS = %w[session-start check-update continue future-intent-check auto-arm power-tools savepoint].freeze
|
|
54
54
|
STATE_TIMEOUT = 5
|
|
55
|
-
SHELL_HOOKS = %w[bash-gate
|
|
55
|
+
SHELL_HOOKS = %w[bash-gate].freeze
|
|
56
56
|
|
|
57
57
|
gate = ARGV[0].to_s
|
|
58
58
|
raw = ($stdin.read rescue nil)
|
|
@@ -78,20 +78,15 @@ if STATE_HOOKS.include?(gate)
|
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
if SHELL_HOOKS.include?(gate)
|
|
81
|
-
# bash-gate
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
# ApplyPatchEnvelope.parse below (see header comment).
|
|
81
|
+
# bash-gate (intent 203): a shell command has no apply_patch diff envelope, so
|
|
82
|
+
# this branch execs the SAME scripts/hook-bash-gate file Claude already runs,
|
|
83
|
+
# unmodified, and relays its exit code and stderr. It must never fall through
|
|
84
|
+
# to ApplyPatchEnvelope.parse below (see header comment).
|
|
86
85
|
cwd = payload["cwd"].to_s
|
|
87
86
|
cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
|
|
88
87
|
env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
|
|
89
88
|
script = File.join(__dir__, "hook-#{gate}")
|
|
90
89
|
argv = [script]
|
|
91
|
-
# hook-retrieval-gate reads plastic_home off ARGV[0] (mirrors hooks/retrieval-gate's
|
|
92
|
-
# own launcher, which passes "$HOME/.plastic"); hook-bash-gate takes no args, it
|
|
93
|
-
# resolves Dir.home directly.
|
|
94
|
-
argv << ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") } if gate == "retrieval-gate"
|
|
95
90
|
out, err, status = begin
|
|
96
91
|
Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env, RbConfig.ruby, *argv, stdin_data: raw, chdir: cwd) }
|
|
97
92
|
rescue StandardError
|
package/scripts/doctor.rb
CHANGED
|
@@ -560,7 +560,7 @@ class Doctor
|
|
|
560
560
|
fixable: true,
|
|
561
561
|
fix_hint: "Dispatch plastic-store-curating to relocate each unsanctioned section into the " \
|
|
562
562
|
"intent's revisions.md via move-and-record (a missing required section is restored " \
|
|
563
|
-
"or reprojected instead); see
|
|
563
|
+
"or reprojected instead); see plastic-conventions > references/maintenance-and-revisions.md"
|
|
564
564
|
)
|
|
565
565
|
end
|
|
566
566
|
|
|
@@ -929,7 +929,7 @@ end
|
|
|
929
929
|
"project-links --intent <id> --apply` over running project-links directly: it detects " \
|
|
930
930
|
"(never acquires) the target's delivery lock, requires a clean store working tree, and " \
|
|
931
931
|
"commits the scoped change plus its revisions.md receipt as one merged operation " \
|
|
932
|
-
"(
|
|
932
|
+
"(plastic-conventions > references/maintenance-and-revisions.md)."
|
|
933
933
|
)
|
|
934
934
|
end
|
|
935
935
|
|
|
@@ -1229,7 +1229,7 @@ end
|
|
|
1229
1229
|
"Every sources/chain id resolves to a real intent (I4)",
|
|
1230
1230
|
"Dispatch plastic-store-curating to record the dangling sources/chain edge as a " \
|
|
1231
1231
|
"broken-source/broken-chain move-and-record entry in the intent's revisions.md (see " \
|
|
1232
|
-
"
|
|
1232
|
+
"plastic-conventions > references/maintenance-and-revisions.md), or restore the missing intent"
|
|
1233
1233
|
)
|
|
1234
1234
|
checks
|
|
1235
1235
|
end
|
|
@@ -1347,6 +1347,12 @@ end
|
|
|
1347
1347
|
)
|
|
1348
1348
|
end
|
|
1349
1349
|
|
|
1350
|
+
# claude_hooks_implemented (intent 244): the edit-path gates now live as
|
|
1351
|
+
# branches inside one dispatcher (scripts/hook-edit-gates), which is
|
|
1352
|
+
# exactly the shape that let links-gate ship registered and dead on
|
|
1353
|
+
# Codex (intent 200's failure class, one level up).
|
|
1354
|
+
checks << claude_hooks_implemented_check
|
|
1355
|
+
|
|
1350
1356
|
# hooks_registered — settings.json has Plastic hooks for required events
|
|
1351
1357
|
settings_path = File.join(agent_dir, "settings.json")
|
|
1352
1358
|
settings = read_json_safe(settings_path)
|
|
@@ -1437,6 +1443,82 @@ end
|
|
|
1437
1443
|
checks
|
|
1438
1444
|
end
|
|
1439
1445
|
|
|
1446
|
+
# claude_hooks_implemented (intent 244, the Claude twin of intent 200's Codex
|
|
1447
|
+
# check): the edit-path gates now live as branches inside one dispatcher,
|
|
1448
|
+
# which is exactly the shape that let links-gate ship registered and dead on
|
|
1449
|
+
# Codex. HookRegistry::GATE_TOOLS is the registry side; scripts/hook-edit-gates
|
|
1450
|
+
# is the implementation side. Checked in BOTH directions, so a registered gate
|
|
1451
|
+
# with no branch (always allows, silently) and a branch nobody registers
|
|
1452
|
+
# (dead code) are both reported.
|
|
1453
|
+
#
|
|
1454
|
+
# Read as plain text, never required: the dispatcher has top-level side effects
|
|
1455
|
+
# (it reads $stdin and exits), the same reason codex_dispatcher_gate_names reads
|
|
1456
|
+
# scripts/codex-hook as text.
|
|
1457
|
+
def claude_dispatcher_gate_names(source)
|
|
1458
|
+
case_start = source.index(/^\s*case gate\b/)
|
|
1459
|
+
return nil unless case_start
|
|
1460
|
+
|
|
1461
|
+
case_body = source[case_start..-1]
|
|
1462
|
+
end_idx = case_body.index(/^\s*end\b/)
|
|
1463
|
+
scanned = end_idx ? case_body[0...end_idx] : case_body
|
|
1464
|
+
names = scanned.scan(/^\s*when\s+"([^"]+)"/).flatten.uniq
|
|
1465
|
+
names.empty? ? nil : names
|
|
1466
|
+
end
|
|
1467
|
+
|
|
1468
|
+
def claude_hooks_implemented_check
|
|
1469
|
+
dispatcher_path = File.join(plastic_home, "scripts", "hook-edit-gates")
|
|
1470
|
+
|
|
1471
|
+
unless File.exist?(dispatcher_path)
|
|
1472
|
+
return check(
|
|
1473
|
+
category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
|
|
1474
|
+
message: "scripts/hook-edit-gates not found at #{tilde(dispatcher_path)}; cannot verify " \
|
|
1475
|
+
"the edit-path gate registry and dispatcher agree",
|
|
1476
|
+
fixable: true, fix_hint: "Re-run the Plastic installer"
|
|
1477
|
+
)
|
|
1478
|
+
end
|
|
1479
|
+
|
|
1480
|
+
dispatcher_names = claude_dispatcher_gate_names(File.read(dispatcher_path))
|
|
1481
|
+
|
|
1482
|
+
if dispatcher_names.nil?
|
|
1483
|
+
return check(
|
|
1484
|
+
category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
|
|
1485
|
+
message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the `case gate` " \
|
|
1486
|
+
"statement no longer matches the shape this check expects, so the registry was " \
|
|
1487
|
+
"never actually checked against the real dispatcher. Update " \
|
|
1488
|
+
"claude_dispatcher_gate_names in doctor.rb to the file's new shape.",
|
|
1489
|
+
fixable: false
|
|
1490
|
+
)
|
|
1491
|
+
end
|
|
1492
|
+
|
|
1493
|
+
registry_names = HookRegistry::GATE_TOOLS.keys
|
|
1494
|
+
missing_branch = registry_names - dispatcher_names
|
|
1495
|
+
dead_branch = dispatcher_names - registry_names
|
|
1496
|
+
|
|
1497
|
+
if missing_branch.empty? && dead_branch.empty?
|
|
1498
|
+
return check(
|
|
1499
|
+
category: "agent_registration", name: "claude_hooks_implemented", status: "pass",
|
|
1500
|
+
message: "Every registered edit-path gate has a scripts/hook-edit-gates branch, and every " \
|
|
1501
|
+
"dispatcher branch is registered"
|
|
1502
|
+
)
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
details = missing_branch.map do |name|
|
|
1506
|
+
"#{name} is in HookRegistry::GATE_TOOLS but scripts/hook-edit-gates has no branch for it, " \
|
|
1507
|
+
"so the dispatcher skips it on every edit and it never blocks anything"
|
|
1508
|
+
end
|
|
1509
|
+
details += dead_branch.map do |name|
|
|
1510
|
+
"#{name} has a branch in scripts/hook-edit-gates but is not in HookRegistry::GATE_TOOLS, so " \
|
|
1511
|
+
"the dispatcher never reaches it and it is dead code"
|
|
1512
|
+
end
|
|
1513
|
+
|
|
1514
|
+
check(
|
|
1515
|
+
category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
|
|
1516
|
+
message: "The edit-path gate registry and scripts/hook-edit-gates disagree on " \
|
|
1517
|
+
"#{details.size} gate(s)",
|
|
1518
|
+
details: details, fixable: false
|
|
1519
|
+
)
|
|
1520
|
+
end
|
|
1521
|
+
|
|
1440
1522
|
# Plastic skills install as ~/.claude/skills/plastic-<name>/SKILL.md. Pass if at
|
|
1441
1523
|
# least one such skill is present.
|
|
1442
1524
|
def flat_skills_check(agent_dir, installer_flag)
|
package/scripts/hook-code-gate
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
# Usage: hook-code-gate <file_path>
|
|
5
|
+
# Usage: hook-code-gate <file_path> [session_id] [content]
|
|
6
6
|
# PreToolUse gate. Composes two independent block rules; EITHER blocks the edit:
|
|
7
7
|
# - Stage rule (intent 27): when auto mode is armed and the active intent has not
|
|
8
8
|
# reached How (plan.md + checklist.md), block edits to project code outside the store.
|
|
@@ -12,39 +12,24 @@
|
|
|
12
12
|
#
|
|
13
13
|
# Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
|
|
14
14
|
# No bridge = allow. Each rule fails open on its own conditions (see bridge.rb).
|
|
15
|
+
#
|
|
16
|
+
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
17
|
+
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
18
|
+
# (scripts/hook-edit-gates) so the two harnesses cannot drift.
|
|
15
19
|
|
|
16
|
-
require_relative "lib/
|
|
20
|
+
require_relative "lib/edit_gates"
|
|
17
21
|
|
|
18
22
|
file_path = ARGV[0]
|
|
19
23
|
exit 0 unless file_path && !file_path.empty?
|
|
20
24
|
|
|
21
25
|
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
|
|
28
|
-
begin
|
|
29
|
-
require "fileutils"
|
|
30
|
-
log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
|
|
31
|
-
FileUtils.mkdir_p(File.dirname(log))
|
|
32
|
-
File.open(log, "a") do |io|
|
|
33
|
-
io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{file_path}")
|
|
34
|
-
end
|
|
35
|
-
rescue StandardError
|
|
36
|
-
# the escape still applies; logging is best-effort
|
|
37
|
-
end
|
|
38
|
-
exit 0
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
|
|
42
|
-
bridge_data = Bridge.discover_bridge(session: session, cwd: file_path, edited_path: file_path)
|
|
43
|
-
exit 0 unless bridge_data
|
|
44
|
-
|
|
45
|
-
reason = Bridge.code_gate_decision(bridge_data, file_path) ||
|
|
46
|
-
Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
|
|
47
|
-
exit 0 unless reason
|
|
27
|
+
ctx = EditGates::Context.new(
|
|
28
|
+
tool_name: "Write", session: session, gate_path: file_path, file_path: file_path,
|
|
29
|
+
content: ARGV[2], harness: "claude",
|
|
30
|
+
)
|
|
48
31
|
|
|
49
|
-
|
|
32
|
+
outcome = EditGates.code_gate(ctx)
|
|
33
|
+
exit 0 unless outcome
|
|
34
|
+
outcome.lines.each { |line| $stderr.puts line }
|
|
50
35
|
exit 2
|
package/scripts/hook-create-gate
CHANGED
|
@@ -26,9 +26,15 @@
|
|
|
26
26
|
# Pathless MCP mutation: validate the CURRENT on-disk file (the PostToolUse
|
|
27
27
|
# gate-check backstop validates the result); a
|
|
28
28
|
# missing file blocks (fail-safe).
|
|
29
|
+
#
|
|
30
|
+
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
31
|
+
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
32
|
+
# (scripts/hook-edit-gates) so the two harnesses cannot drift. Deliberately
|
|
33
|
+
# WITHOUT a top-level rescue, matching this script's behavior before the
|
|
34
|
+
# extraction: an internal exception here has never been caught.
|
|
29
35
|
|
|
30
36
|
require "json"
|
|
31
|
-
require_relative "lib/
|
|
37
|
+
require_relative "lib/edit_gates"
|
|
32
38
|
|
|
33
39
|
raw = $stdin.read rescue nil
|
|
34
40
|
exit 0 if raw.nil? || raw.strip.empty?
|
|
@@ -36,60 +42,7 @@ exit 0 if raw.nil? || raw.strip.empty?
|
|
|
36
42
|
payload = JSON.parse(raw) rescue nil
|
|
37
43
|
exit 0 unless payload.is_a?(Hash)
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
exit 0
|
|
41
|
-
|
|
42
|
-
# Precise path matcher: the target must be an intent file inside its own
|
|
43
|
-
# equally-named dir. Never matches sibling lifecycle files (spec.md, etc.) or
|
|
44
|
-
# unrelated store files.
|
|
45
|
-
abs = File.expand_path(path)
|
|
46
|
-
dir = File.dirname(abs)
|
|
47
|
-
is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
|
|
48
|
-
File.basename(abs) == "#{File.basename(dir)}.md"
|
|
49
|
-
exit 0 unless is_intent_file
|
|
50
|
-
|
|
51
|
-
input = payload["tool_input"] || payload["tool_params"] || {}
|
|
52
|
-
content = input["content"]
|
|
53
|
-
old_string = input["old_string"]
|
|
54
|
-
|
|
55
|
-
def block!(lines)
|
|
56
|
-
Array(lines).each { |l| $stderr.puts l }
|
|
57
|
-
exit 2
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
result =
|
|
61
|
-
if !content.nil?
|
|
62
|
-
# Write: validate the proposed content (intent 60b, unchanged).
|
|
63
|
-
IntentValidator.validate_content(content)
|
|
64
|
-
elsif !old_string.nil?
|
|
65
|
-
# Edit (intent 108, D7): simulate the replacement on the on-disk file and
|
|
66
|
-
# validate the RESULT, so an Edit can no longer sneak an intent file past
|
|
67
|
-
# the validator.
|
|
68
|
-
unless File.exist?(abs)
|
|
69
|
-
block!("PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
|
|
70
|
-
"create intents via new-intent / plastic-intent-creating.")
|
|
71
|
-
end
|
|
72
|
-
current = File.read(abs)
|
|
73
|
-
unless current.include?(old_string)
|
|
74
|
-
exit 0 # the Edit tool itself will fail; nothing lands, nothing to judge
|
|
75
|
-
end
|
|
76
|
-
simulated = input["replace_all"] ? current.gsub(old_string, input["new_string"].to_s)
|
|
77
|
-
: current.sub(old_string, input["new_string"].to_s)
|
|
78
|
-
IntentValidator.validate_content(simulated)
|
|
79
|
-
else
|
|
80
|
-
# Pathless mutation (MCP symbolic edits): the proposal is not visible at
|
|
81
|
-
# PreToolUse. Judge the CURRENT file: valid -> allow (the PostToolUse
|
|
82
|
-
# gate-check backstop validates the result), invalid/missing -> block.
|
|
83
|
-
unless File.exist?(abs)
|
|
84
|
-
block!("PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed " \
|
|
85
|
-
"content and no file exists; refusing to allow an unvalidated intent write.")
|
|
86
|
-
end
|
|
87
|
-
IntentValidator.validate_content(File.read(abs))
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
exit 0 if result[:ok]
|
|
91
|
-
|
|
92
|
-
$stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
|
|
93
|
-
result[:errors].each { |e| $stderr.puts " #{e}" }
|
|
94
|
-
$stderr.puts "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
|
|
45
|
+
outcome = EditGates.create_gate(EditGates.context_from(payload))
|
|
46
|
+
exit 0 unless outcome
|
|
47
|
+
outcome.lines.each { |line| $stderr.puts line }
|
|
95
48
|
exit 2
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# The merged Claude PreToolUse edit-path dispatcher (intent 244). One registered
|
|
6
|
+
# hook, one Ruby process, one stdin parse, five gates in-process, in the fixed
|
|
7
|
+
# order savepoint-pre, lock-gate, code-gate, links-gate, create-gate (spec D-a).
|
|
8
|
+
# The first deny ends evaluation and its own shape is reproduced exactly: stderr
|
|
9
|
+
# plus exit 2 for code-gate/links-gate/create-gate, stdout permissionDecision
|
|
10
|
+
# JSON plus exit 0 for lock-gate (spec D-b). savepoint-pre runs first so its
|
|
11
|
+
# `started` ledger append stays unconditional, including on a call another gate
|
|
12
|
+
# then blocks (spec D-c).
|
|
13
|
+
#
|
|
14
|
+
# Fail-open everywhere (spec D-i): each gate runs inside its own rescue in
|
|
15
|
+
# EditGates.dispatch, and this file's top level rescues to exit 0, so a gate bug
|
|
16
|
+
# never denies and never silences a sibling gate.
|
|
17
|
+
#
|
|
18
|
+
# LOAD-BEARING SHAPE: doctor's claude_hooks_implemented_check reads THIS FILE as
|
|
19
|
+
# plain text and extracts the `when "<gate>"` labels of the `case gate` statement
|
|
20
|
+
# below, because a script with top-level side effects can never be required to
|
|
21
|
+
# introspect it (the same technique intent 200 used for scripts/codex-hook). If
|
|
22
|
+
# the case statement is reshaped, update that check in scripts/doctor.rb in the
|
|
23
|
+
# same change, or it will fail loudly by design.
|
|
24
|
+
|
|
25
|
+
require "json"
|
|
26
|
+
require_relative "lib/edit_gates"
|
|
27
|
+
|
|
28
|
+
def route(gate, ctx)
|
|
29
|
+
case gate
|
|
30
|
+
when "savepoint-pre" then EditGates.savepoint_pre(ctx)
|
|
31
|
+
when "lock-gate" then EditGates.lock_gate(ctx)
|
|
32
|
+
when "code-gate" then EditGates.code_gate(ctx)
|
|
33
|
+
when "links-gate" then EditGates.links_gate(ctx)
|
|
34
|
+
when "create-gate" then EditGates.create_gate(ctx)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
raw = begin
|
|
40
|
+
$stdin.read
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
exit 0 if raw.nil? || raw.strip.empty?
|
|
45
|
+
|
|
46
|
+
payload = begin
|
|
47
|
+
JSON.parse(raw)
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
exit 0 unless payload.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
exit EditGates.dispatch(ctx: EditGates.context_from(payload), route: method(:route))
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
# Dispatcher-internal failure: never impersonate a deny (spec D-i).
|
|
56
|
+
$stderr.puts "plastic edit-gates error: #{e.message}"
|
|
57
|
+
exit 0
|
|
58
|
+
end
|
package/scripts/hook-links-gate
CHANGED
|
@@ -13,9 +13,13 @@
|
|
|
13
13
|
# { "tool_input": { "file_path":, "content": | "old_string"/"new_string"/"replace_all": } }
|
|
14
14
|
# Exit 0 = allow (including every "cannot judge" case). Exit 2 = deny (message
|
|
15
15
|
# on stderr).
|
|
16
|
+
#
|
|
17
|
+
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
18
|
+
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
19
|
+
# (scripts/hook-edit-gates) so the two harnesses cannot drift.
|
|
16
20
|
|
|
17
21
|
require "json"
|
|
18
|
-
require_relative "lib/
|
|
22
|
+
require_relative "lib/edit_gates"
|
|
19
23
|
|
|
20
24
|
begin
|
|
21
25
|
raw = begin
|
|
@@ -32,36 +36,9 @@ begin
|
|
|
32
36
|
end
|
|
33
37
|
exit 0 unless payload.is_a?(Hash)
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
exit 0
|
|
37
|
-
|
|
38
|
-
abs = File.expand_path(path)
|
|
39
|
-
exit 0 unless LinksGate.intent_file?(abs)
|
|
40
|
-
|
|
41
|
-
input = payload["tool_input"] || payload["tool_params"] || {}
|
|
42
|
-
content = input["content"]
|
|
43
|
-
old_string = input["old_string"]
|
|
44
|
-
|
|
45
|
-
before_content = File.exist?(abs) ? File.read(abs) : ""
|
|
46
|
-
|
|
47
|
-
after_content =
|
|
48
|
-
if !content.nil?
|
|
49
|
-
content
|
|
50
|
-
elsif !old_string.nil?
|
|
51
|
-
exit 0 unless before_content.include?(old_string) # the Edit tool itself will fail; nothing to judge
|
|
52
|
-
input["replace_all"] ? before_content.gsub(old_string, input["new_string"].to_s)
|
|
53
|
-
: before_content.sub(old_string, input["new_string"].to_s)
|
|
54
|
-
else
|
|
55
|
-
exit 0 # pathless mutation (no visible proposal); cannot judge, allow
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
plastic_home = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
|
|
59
|
-
|
|
60
|
-
reason = LinksGate.decision(file_path: abs, before_content: before_content,
|
|
61
|
-
after_content: after_content, plastic_home: plastic_home)
|
|
62
|
-
exit 0 unless reason
|
|
63
|
-
|
|
64
|
-
$stderr.puts reason
|
|
39
|
+
outcome = EditGates.links_gate(EditGates.context_from(payload))
|
|
40
|
+
exit 0 unless outcome
|
|
41
|
+
outcome.lines.each { |line| $stderr.puts line }
|
|
65
42
|
exit 2
|
|
66
43
|
rescue => e
|
|
67
44
|
# Hook-internal failure only: do NOT impersonate a user-deny. Log and fail open
|