@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.
Files changed (67) hide show
  1. package/PLASTIC.md +75 -552
  2. package/README.md +3 -2
  3. package/agents/plastic-intent-curator.md +2 -2
  4. package/deprecations.yml +10 -2
  5. package/hooks/{links-gate → edit-gates} +1 -1
  6. package/hooks/hooks.json +4 -44
  7. package/hooks/power-tools +8 -0
  8. package/package.json +1 -2
  9. package/scripts/codex-hook +17 -22
  10. package/scripts/doctor.rb +85 -3
  11. package/scripts/hook-code-gate +13 -28
  12. package/scripts/hook-create-gate +10 -57
  13. package/scripts/hook-edit-gates +58 -0
  14. package/scripts/hook-links-gate +8 -31
  15. package/scripts/hook-lock-gate +13 -58
  16. package/scripts/hook-power-tools +38 -0
  17. package/scripts/hook-savepoint-pre +8 -11
  18. package/scripts/lib/bridge.rb +2 -2
  19. package/scripts/lib/edit_gates.rb +342 -0
  20. package/scripts/lib/hook_registry.rb +61 -29
  21. package/scripts/lib/installer_core.rb +99 -17
  22. package/scripts/lib/qmd_hook.rb +21 -41
  23. package/scripts/lib/qmd_sync.rb +0 -15
  24. package/scripts/lib/revisions_writer.rb +1 -1
  25. package/scripts/restore-intent-v1 +16 -11
  26. package/skills/auto/SKILL.md +10 -0
  27. package/skills/auto/evals/evals.json +2 -2
  28. package/skills/conventions/SKILL.md +31 -0
  29. package/skills/conventions/references/completion-and-done.md +40 -0
  30. package/skills/conventions/references/gates-and-enforcement.md +23 -0
  31. package/skills/conventions/references/knowledge-graph.md +47 -0
  32. package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
  33. package/skills/conventions/references/locks-and-worktrees.md +113 -0
  34. package/skills/conventions/references/maintenance-and-revisions.md +170 -0
  35. package/skills/conventions/references/roadmaps.md +44 -0
  36. package/skills/conventions/references/tiers-and-dispatch.md +134 -0
  37. package/skills/doctor/SKILL.md +9 -1
  38. package/skills/intent-brainstorming/SKILL.md +4 -0
  39. package/skills/intent-continuing/SKILL.md +4 -0
  40. package/skills/intent-creating/SKILL.md +7 -2
  41. package/skills/intent-ending/SKILL.md +4 -0
  42. package/skills/intent-executing/SKILL.md +10 -0
  43. package/skills/intent-linking/SKILL.md +4 -0
  44. package/skills/intent-locking/SKILL.md +4 -0
  45. package/skills/intent-planning/SKILL.md +7 -0
  46. package/skills/intent-savepoint/SKILL.md +4 -0
  47. package/skills/intent-speccing/SKILL.md +4 -0
  48. package/skills/intent-starting/SKILL.md +10 -0
  49. package/skills/project-creating/references/project-scaffolding.md +2 -2
  50. package/skills/roadmap/SKILL.md +4 -0
  51. package/skills/roadmap-continuing/SKILL.md +4 -0
  52. package/skills/skill-creating/SKILL.md +3 -0
  53. package/skills/skill-creating/references/defaults-first.md +23 -0
  54. package/skills/skill-creating/references/hooks.md +4 -1
  55. package/skills/store-curating/SKILL.md +8 -0
  56. package/skills/store-indexing/SKILL.md +8 -0
  57. package/skills/tutorial/references/track-2-auto.md +2 -3
  58. package/PLASTIC-reference.md +0 -138
  59. package/hooks/code-gate +0 -27
  60. package/hooks/create-gate +0 -3
  61. package/hooks/lock-gate +0 -21
  62. package/hooks/qmd-search +0 -8
  63. package/hooks/retrieval-gate +0 -10
  64. package/hooks/savepoint-pre +0 -10
  65. package/scripts/hook-qmd-search +0 -44
  66. package/scripts/hook-retrieval-gate +0 -148
  67. 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
- Plastic conventions live in `PLASTIC.md`, distributed to `~/.plastic/PLASTIC.md`
187
- and overwritten on every update. Project-specific rules live in `AGENTS.md`.
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 PLASTIC.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.md > WORK vs MAINTENANCE).
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 (PLASTIC.md > WORK vs MAINTENANCE; there is exactly one lock in Plastic, the delivery lock).
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 PLASTIC.md "Deprecation Process" for the full convention.
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"
@@ -1,3 +1,3 @@
1
1
  #!/bin/bash
2
2
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
3
- exec ruby "$SCRIPT_DIR/../scripts/hook-links-gate"
3
+ exec ruby "$SCRIPT_DIR/../scripts/hook-edit-gates"
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\" code-gate",
39
- "statusMessage": "Checking lifecycle gate..."
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\" qmd-search",
143
- "statusMessage": "Searching QMD..."
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.8.0",
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
  }
@@ -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 | qmd-search | savepoint
9
- # shell-tool gates (intent 203): bash-gate | retrieval-gate
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 gates (bash-gate, retrieval-gate) report
24
- # tool_name: "Bash" with the command in tool_input.command, but MUST NOT be
25
- # routed through ApplyPatchEnvelope.parse below: a plain shell command has no
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 these two exec
29
- # the SAME scripts/hook-bash-gate / scripts/hook-retrieval-gate files Claude
30
- # already runs, unmodified, the identical "drive the body, relay its output"
31
- # pattern used for the live-state hooks. The one adaptation shared by both the
32
- # live-state and shell-tool paths is threading the payload's session_id into
33
- # CLAUDE_CODE_SESSION_ID, since Codex's own process env never carries it, plus
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 qmd-search savepoint].freeze
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 retrieval-gate].freeze
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 / retrieval-gate (intent 203): a shell command has no apply_patch
82
- # diff envelope, so this branch execs the SAME scripts/hook-bash-gate /
83
- # scripts/hook-retrieval-gate files Claude already runs, unmodified, and
84
- # relays their exit code and stderr. It must never fall through to
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 PLASTIC.md > Structural maintenance and revisions.md"
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
- "(PLASTIC.md > WORK vs MAINTENANCE)."
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
- "PLASTIC.md > Structural maintenance and revisions.md), or restore the missing intent"
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)
@@ -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/bridge"
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
- # Auditable escape (intent 150, mirrors the Bash gate's intent 108 D7): a trailing
24
- # `# plastic-ok` in the new Write/Edit content allows the edit and logs it, so a
25
- # deliberately sanctioned code edit no longer needs the Bash-funneling workaround.
26
- new_content = ARGV[2]
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
- $stderr.puts "PLASTIC GATE — #{reason}"
32
+ outcome = EditGates.code_gate(ctx)
33
+ exit 0 unless outcome
34
+ outcome.lines.each { |line| $stderr.puts line }
50
35
  exit 2
@@ -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/intent_validator"
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
- path = payload.dig("tool_input", "file_path") || payload.dig("tool_params", "file_path")
40
- exit 0 if path.nil? || path.to_s.strip.empty?
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
@@ -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/links_gate"
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
- path = payload.dig("tool_input", "file_path") || payload.dig("tool_params", "file_path")
36
- exit 0 if path.to_s.strip.empty?
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