@zalom/plastic 1.9.0 → 1.11.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 (96) hide show
  1. package/PLASTIC.md +23 -8
  2. package/README.md +16 -5
  3. package/agents/plastic-enforcer.md +3 -2
  4. package/agents/plastic-intent-discovery.md +7 -0
  5. package/bin/plastic.js +17 -8
  6. package/hooks/auto-arm +2 -2
  7. package/hooks/bash-gate +1 -1
  8. package/hooks/check-update +1 -1
  9. package/hooks/continue +2 -2
  10. package/hooks/edit-gates +1 -1
  11. package/hooks/future-intent-check +2 -2
  12. package/hooks/gate-check +3 -3
  13. package/hooks/power-tools +1 -1
  14. package/hooks/session-start +1 -1
  15. package/package.json +1 -1
  16. package/scripts/codex-hook +50 -106
  17. package/scripts/doctor.rb +132 -1080
  18. package/scripts/exec-worktree +103 -0
  19. package/scripts/hash-intent +1 -1
  20. package/scripts/hook-bash-gate +19 -0
  21. package/scripts/hook-code-gate +4 -1
  22. package/scripts/hook-continue +2 -2
  23. package/scripts/hook-create-gate +6 -3
  24. package/scripts/hook-gate-check +17 -0
  25. package/scripts/hook-links-gate +4 -1
  26. package/scripts/hook-lock-gate +7 -3
  27. package/scripts/hook-savepoint-pre +4 -1
  28. package/scripts/hook-session-start +21 -15
  29. package/scripts/lib/apply_patch_envelope.rb +46 -13
  30. package/scripts/lib/bridge.rb +83 -15
  31. package/scripts/lib/codex_edit_gates.rb +138 -0
  32. package/scripts/lib/doctor_core.rb +1087 -0
  33. package/scripts/lib/edit_gates.rb +61 -5
  34. package/scripts/lib/exec_worktree.rb +325 -0
  35. package/scripts/lib/harness_text.rb +57 -0
  36. package/scripts/lib/hook_registry.rb +32 -28
  37. package/scripts/lib/installer_core.rb +67 -7
  38. package/scripts/lib/lock.rb +196 -47
  39. package/scripts/lib/ruby_probe.rb +60 -0
  40. package/scripts/lib/scaffold_intent.rb +392 -0
  41. package/scripts/lib/spec_header.rb +83 -0
  42. package/scripts/lib/start_intent.rb +296 -0
  43. package/scripts/lib/verify_intent.rb +262 -0
  44. package/scripts/lib/worktree.rb +15 -1
  45. package/scripts/link-suggest +1 -1
  46. package/scripts/maintenance-run +5 -5
  47. package/scripts/migrate-to-global +2 -2
  48. package/scripts/restore-intent-v1 +1 -1
  49. package/scripts/scaffold-intent +120 -0
  50. package/scripts/start-intent +89 -0
  51. package/scripts/verify-intent +73 -0
  52. package/skills/agent-advisor/SKILL.md +5 -5
  53. package/skills/auto/SKILL.md +42 -32
  54. package/skills/auto/references/agent-architecture.md +1 -1
  55. package/skills/auto/references/agent-report-contract.md +1 -1
  56. package/skills/auto/references/human-report-contract.md +25 -8
  57. package/skills/auto/references/tiers.md +24 -2
  58. package/skills/conventions/references/completion-and-done.md +3 -0
  59. package/skills/conventions/references/gates-and-enforcement.md +28 -12
  60. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  61. package/skills/conventions/references/tiers-and-dispatch.md +10 -9
  62. package/skills/dashboard/SKILL.md +1 -1
  63. package/skills/doctor/SKILL.md +6 -5
  64. package/skills/doctor/references/gates-stuck-detection.md +13 -8
  65. package/skills/doctor/report.md +1 -1
  66. package/skills/install/SKILL.md +1 -1
  67. package/skills/intent-brainstorming/SKILL.md +0 -2
  68. package/skills/intent-creating/SKILL.md +6 -6
  69. package/skills/intent-creating/references/lifecycle.md +1 -1
  70. package/skills/intent-discovering/SKILL.md +10 -3
  71. package/skills/intent-ending/SKILL.md +8 -7
  72. package/skills/intent-executing/SKILL.md +27 -19
  73. package/skills/intent-grilling/SKILL.md +5 -3
  74. package/skills/intent-planning/SKILL.md +7 -3
  75. package/skills/intent-researching/SKILL.md +0 -2
  76. package/skills/intent-starting/SKILL.md +10 -2
  77. package/skills/project-creating/SKILL.md +0 -2
  78. package/skills/project-creating/references/project-scaffolding.md +1 -1
  79. package/skills/releasing/SKILL.md +1 -1
  80. package/skills/releasing/references/promotion-and-tagging.md +14 -8
  81. package/skills/releasing/references/release-lines.md +1 -1
  82. package/skills/skill-creating/SKILL.md +5 -2
  83. package/skills/store-indexing/SKILL.md +8 -5
  84. package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
  85. package/skills/tutorial/references/track-1-guided.md +2 -2
  86. package/skills/tutorial/references/track-2-auto.md +9 -6
  87. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
  88. package/skills/uninstall/SKILL.md +6 -9
  89. package/templates/agents.md +12 -12
  90. package/templates/config.yml +6 -7
  91. package/templates/index.md +6 -3
  92. package/templates/spec.md +1 -1
  93. package/skills/humanizer/SKILL.md +0 -40
  94. package/skills/humanizer/evals/evals.json +0 -70
  95. package/skills/humanizer/references/always-on-snippet.md +0 -9
  96. package/skills/humanizer/references/examples.md +0 -48
package/PLASTIC.md CHANGED
@@ -31,7 +31,7 @@ store/
31
31
  spec.md # optional - specification (Why deliverable)
32
32
  plan.md # optional - implementation plan (How deliverable)
33
33
  checklist.md # optional - execution registry (How deliverable)
34
- outcome.md # optional - detailed result (Exec deliverable)
34
+ outcome.md # detailed result (Exec deliverable) - mandatory at a terminal (Completed and Abandoned alike)
35
35
  actions/ # optional - real action files (How deliverable, at least one required)
36
36
  resources/ # optional - research, references, screenshots, diagrams
37
37
  savepoint.md # optional - deterministic cycle-step ledger (auto-written)
@@ -61,7 +61,7 @@ tags: [plastic, architecture]
61
61
 
62
62
  - Context contract: load `sources` strongly (they are what the intent was built from);
63
63
  traverse `chain` lightly for discovery. See
64
- docs/concepts/how-plastic-sources-and-chains-intents.md for the full model.
64
+ [`how-plastic-sources-and-chains-intents.md`](https://github.com/zalom/plastic/blob/main/docs/concepts/how-plastic-sources-and-chains-intents.md) for the full model.
65
65
  - IDs use Luhmann's alternating convention: `1` → `1a` → `1a1` → `1a1a`
66
66
  - Multiple branches increment: `1a`, `1b`, `1c`
67
67
 
@@ -73,7 +73,7 @@ of influence (sources/chain/tags) and the `## Links` projection rules.
73
73
  | Stage | Section | Deliverable | Detail |
74
74
  |-------|---------|-------------|--------|
75
75
  | **What** | `## Intent` | `{ID}--{slug}.md` | `plastic-intent-creating` |
76
- | **Why** | `## Context` + Decisions | `spec.md` | `plastic-intent-brainstorming` |
76
+ | **Why** | `## Context` + Decisions | `spec.md` | `plastic-intent-brainstorming` enriches Context/Decisions; `plastic-intent-speccing` writes `spec.md` |
77
77
  | **How** | Planning | `plan.md` + `actions/ACTION_N.md` (at least one) + `checklist.md` | `plastic-intent-planning` |
78
78
  | **Exec** | Execution | `outcome.md` | `plastic-intent-executing` |
79
79
 
@@ -113,6 +113,13 @@ M = one subsystem (about a day); L = cross-cutting or novel design.
113
113
  Tier is recorded as a `Tier: S|M|L` line at the top of spec.md. It is convention-only,
114
114
  read by the orchestrator, not enforced by any gate or by doctor.
115
115
 
116
+ Settledness is recorded separately, as a `Settled: yes (<reason>)` line directly beneath the
117
+ `Tier:` line, above the `# Spec:` heading. It records that a design has already done its
118
+ thinking, so a later stage can trust it without deriving it again. An absent line means not
119
+ settled; there is no `Settled: no` form. Settledness and scope are independent, so a large
120
+ intent can be settled and never becomes a smaller tier. `scripts/lib/spec_header.rb` is the
121
+ only parser of both lines; nothing else reads the grammar.
122
+
116
123
  See `plastic-conventions > references/tiers-and-dispatch.md` for topology by tier, what never
117
124
  gets cut, and guided mode.
118
125
 
@@ -194,7 +201,7 @@ Format: `ID--three-to-five-words` (all stores).
194
201
  - Root intents: sequential numbers (`1`, `2`, `3`)
195
202
  - Branches alternate: `1` → `1a` → `1a1` → `1a1a`
196
203
  - Intent file matches directory: `1a1--slug/1a1--slug.md`
197
- - Next ID: `"${CLAUDE_PLUGIN_ROOT}/scripts/folgezettel-id" <parent_id> <store_path>`
204
+ - Next ID: `ruby ~/.plastic/scripts/folgezettel-id <parent_id> <store_path>`
198
205
 
199
206
  See `plastic-conventions > references/knowledge-graph.md` for the branch-vs-root semantic
200
207
  decision: when to branch versus start a root.
@@ -286,12 +293,20 @@ One line each. On Claude the five edit-path gates (savepoint-pre, lock-gate, cod
286
293
  links-gate, create-gate) run inside one dispatcher process per Write or Edit, in that fixed
287
294
  order with the first deny winning; what each gate checks is unchanged.
288
295
 
289
- - **create-gate** validates the proposed intent file at What write-time.
290
- - **gate-check** enforces lifecycle stage order.
296
+ - **savepoint-pre** appends the savepoint ledger line before a write into an intent dir; it
297
+ records and never denies.
291
298
  - **lock-gate** admits only the intent's lock owner or a registered delegate to write into an
292
299
  active intent directory.
293
- - **bash-gate** intercepts a write attempted through a bash or interpreter one-liner, mirrored
294
- by the code gate for Write and Edit.
300
+ - **code-gate** denies a code edit before How is delivered and a code edit outside the
301
+ provisioned worktree (stage rule or worktree rule, first match wins), with the audited
302
+ `# plastic-ok` escape.
303
+ - **links-gate** enforces the derived `## Links` projection contract at write time.
304
+ - **create-gate** validates the proposed intent file at What write-time.
305
+ - **bash-gate** (on the Bash matcher) intercepts a write attempted through a bash or
306
+ interpreter one-liner, with the same audited escape.
307
+ - **gate-check** (PostToolUse) enforces lifecycle stage order after each write.
308
+ - **future-intent-check** (UserPromptSubmit) surfaces parked future intents whose keywords
309
+ match the user's message; it informs and never denies.
295
310
 
296
311
  See `plastic-conventions > references/gates-and-enforcement.md` for the escape and logging
297
312
  detail.
package/README.md CHANGED
@@ -63,7 +63,7 @@ It solves problems that builder already feels:
63
63
  One command installs Plastic for your agent:
64
64
 
65
65
  ```bash
66
- npx @zalom/plastic --claude
66
+ npx -y @zalom/plastic install --claude
67
67
  ```
68
68
 
69
69
  Then load the conventions and begin:
@@ -139,7 +139,7 @@ Walk this once in
139
139
  | Intent | `plastic-continuing`, `plastic-intent-creating`, `plastic-intent-starting`, `plastic-intent-brainstorming`, `plastic-intent-grilling`, `plastic-intent-speccing`, `plastic-intent-planning`, `plastic-intent-executing`, `plastic-intent-ending`, `plastic-intent-continuing`, `plastic-intent-researching` |
140
140
  | Project and delivery | `plastic-project-creating`, `plastic-project-continuing`, `plastic-roadmap`, `plastic-roadmap-continuing`, `plastic-releasing` |
141
141
  | Skill | `plastic-skill-creating`, `plastic-skill-evaluating` |
142
- | Product | `plastic-install`, `plastic-update`, `plastic-uninstall`, `plastic-rollback`, `plastic-doctor`, `plastic-humanizer` |
142
+ | Product | `plastic-install`, `plastic-update`, `plastic-uninstall`, `plastic-rollback`, `plastic-doctor` |
143
143
 
144
144
  See [`docs/guides/index.md`](docs/guides/index.md) for task-oriented walkthroughs.
145
145
 
@@ -149,7 +149,8 @@ plan a machine can build from exactly. Read
149
149
 
150
150
  ## Compatibility and ownership
151
151
 
152
- - Native installers for Claude (Codex, Hermes, and and others coming soon).
152
+ - Native installers for Claude Code and Codex CLI, both from npm. Hermes is a packaging
153
+ target for now. See [harness support](docs/reference/harness-adapters.md).
153
154
  - Plain Markdown plus Git. The work stays in files you own.
154
155
  - Personal stores by default.
155
156
  - Guided delivery with a human at every gate, or autonomous delivery when you
@@ -160,8 +161,18 @@ plan a machine can build from exactly. Read
160
161
  the frontier reasoning discipline inline, much cheaper; Fable 5 is the frontier model
161
162
  itself. Choose the default at install, switch it any time.
162
163
 
163
- Plastic needs Ruby (already on macOS and Linux) and Node.js 18 or later. Bun
164
- users can run `bunx` in place of `npx`; Bun is never required.
164
+ Plastic needs Ruby 3.0 or later and Node.js 18 or later. Most Linux systems already
165
+ have a new enough Ruby. macOS ships Ruby 2.6, which is too old, so a clean Mac needs
166
+ a newer one first:
167
+
168
+ ```
169
+ curl https://mise.run | sh # only if mise is not installed yet
170
+ mise use --global ruby@3.3
171
+ ```
172
+
173
+ The installer checks this before it does anything and tells you the same thing if the
174
+ Ruby it finds is too old. Bun users can run `bunx` in place of `npx`; Bun is never
175
+ required.
165
176
 
166
177
  ## Built with Plastic
167
178
 
@@ -68,13 +68,14 @@ ceremony, never these guarantees.
68
68
  1. Arm the gate, then dispatch the brainstorming specialist; gate its `## Context` + `### Decisions`
69
69
  2. Dispatch the spec-specialist; gate `spec.md`. Then the planner; gate `plan.md` + `checklist.md`
70
70
  3. Dispatch the executor; require a green suite. Sequential, one team per intent, on one branch when files are shared
71
- 4. Dispatch and review by default through Plastic's native engine, `plastic-intent-executing` (implementer plus two-stage review, no external plugin). If `superpowers:subagent-driven-development` and `superpowers:dispatching-parallel-agents` are available, or the user asks for them, delegate to them as an enhancement
71
+ 4. Dispatch and review by default through Plastic's native engine, `plastic-intent-executing` (no external plugin): at S and M, one executor dispatch for the whole consolidated action; at L, implementer plus two-stage review per task. If `superpowers:subagent-driven-development` and `superpowers:dispatching-parallel-agents` are available, or the user asks for them, delegate to them as an enhancement
72
72
  5. At the final gate, dispatch an independent reviewer subagent, then complete the intent
73
73
 
74
74
  ## Human-facing stage reporting
75
75
 
76
76
  At each gate, the orchestrator briefs the human in EM-to-CTO voice: impact first, the one risk
77
- that matters, then the decision left to them. The shape and per-stage content live in
77
+ that matters, then the decision left to them. This is the depth at M and L; at S the briefing
78
+ fires once, at How. The shape and per-stage content live in
78
79
  `skills/auto/references/human-report-contract.md`; follow it rather than improvising a report.
79
80
  This is separate from the intent 74 report contract (`skills/auto/references/agent-report-contract.md`),
80
81
  which is the internal, structured handoff a dispatched specialist sends back to the orchestrator.
@@ -11,6 +11,13 @@ activation, after the lock is armed and before Why begins, under the lock as
11
11
  the owner session, you gather the context that already exists and deposit it
12
12
  for the Why stage to consume.
13
13
 
14
+ ## Skip precondition (Tier S only)
15
+ If a `Tier: S` line is already stamped at the top of `spec.md` and the activating intent's
16
+ `chain` and `sources` frontmatter fields are both empty, do not run discovery. Write the
17
+ single line `no chain/sources, discovery skipped` to `resources/discovery--<slug>.md` and
18
+ stop. Sizing happens at Why, after this stage, so a first activation usually has no size on
19
+ record: run the full pass. Never guess a size to unlock the skip.
20
+
14
21
  ## Responsibilities
15
22
  1. **Read the intent's links.** Load the activating intent file's `chain` and
16
23
  `sources` frontmatter fields.
package/bin/plastic.js CHANGED
@@ -46,16 +46,25 @@ if (!existsSync(script)) {
46
46
  try {
47
47
  execFileSync('ruby', [script, ...rest], {
48
48
  stdio: 'inherit',
49
- env: { ...process.env, PLASTIC_PACKAGE_ROOT: packageRoot },
49
+ // Clear RUBYOPT last, after the spread, so a global setting (for example --yjit)
50
+ // cannot reach a ruby that does not know the flag. A machine with an old ruby
51
+ // must get preflight's real message, not a crash.
52
+ env: { ...process.env, PLASTIC_PACKAGE_ROOT: packageRoot, RUBYOPT: '' },
50
53
  })
51
54
  } catch (err) {
52
55
  if (err.status) process.exit(err.status)
53
- // Ruby cannot run to print its own message when it is missing, so this
54
- // mirrors scripts/lib/preflight.rb's FATAL block word for word.
55
- console.error('Plastic needs Ruby 3.0.0 or newer to run its scripts (found not found).')
56
- console.error('Install a pinned Ruby with mise:')
57
- console.error(' curl https://mise.run | sh # only if mise is not installed yet')
58
- console.error(' mise use --global ruby@3.3')
59
- console.error('Then re-run the Plastic installer.')
56
+ // We only get here when ruby could not be executed at all. A ruby that runs but is
57
+ // too old exits with a status, handled on the line above, and prints its own real
58
+ // message from scripts/lib/preflight.rb naming the version actually found.
59
+ if (err.code === 'ENOENT') {
60
+ console.error('Plastic needs Ruby 3.0.0 or newer to run its scripts (ruby was not found on PATH).')
61
+ console.error('Install a pinned Ruby with mise:')
62
+ console.error(' curl https://mise.run | sh # only if mise is not installed yet')
63
+ console.error(' mise use --global ruby@3.3')
64
+ console.error('Then re-run the Plastic installer.')
65
+ } else {
66
+ console.error(`Plastic could not run ruby: ${err.message}`)
67
+ console.error('Check that `ruby -v` works in this shell, then re-run the Plastic installer.')
68
+ }
60
69
  process.exit(1)
61
70
  }
package/hooks/auto-arm CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/bin/bash
2
2
  INPUT=$(cat)
3
- MESSAGE=$(echo "$INPUT" | ruby -rjson -e 'puts JSON.parse(STDIN.read)["user_prompt"].to_s' 2>/dev/null || echo "")
3
+ MESSAGE=$(echo "$INPUT" | env -u RUBYOPT ruby -rjson -e 'puts JSON.parse(STDIN.read)["user_prompt"].to_s' 2>/dev/null || echo "")
4
4
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
5
- ruby "$SCRIPT_DIR/../scripts/hook-auto-arm" "$MESSAGE"
5
+ env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-auto-arm" "$MESSAGE"
package/hooks/bash-gate CHANGED
@@ -1,3 +1,3 @@
1
1
  #!/bin/bash
2
2
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
3
- exec ruby "$SCRIPT_DIR/../scripts/hook-bash-gate"
3
+ exec env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-bash-gate"
@@ -31,7 +31,7 @@ fi
31
31
  exit 0
32
32
  fi
33
33
 
34
- TARGET=$(printf '%s' "$TAGS" | ruby "$SELECTOR" "$CURRENT" 2>/dev/null)
34
+ TARGET=$(printf '%s' "$TAGS" | env -u RUBYOPT ruby "$SELECTOR" "$CURRENT" 2>/dev/null)
35
35
  CHECKED=$(date -u +%Y-%m-%dT%H:%M:%SZ)
36
36
 
37
37
  if [ -n "$TARGET" ]; then
package/hooks/continue CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/bin/bash
2
2
  INPUT=$(cat)
3
- MESSAGE=$(echo "$INPUT" | ruby -rjson -e 'puts JSON.parse(STDIN.read)["user_prompt"]' 2>/dev/null || echo "")
3
+ MESSAGE=$(echo "$INPUT" | env -u RUBYOPT ruby -rjson -e 'puts JSON.parse(STDIN.read)["user_prompt"]' 2>/dev/null || echo "")
4
4
 
5
5
  if echo "$MESSAGE" | grep -iq '\bcontinue\b'; then
6
6
  GLOBAL_INDEX="$HOME/.plastic/INDEX.md"
@@ -14,7 +14,7 @@ if echo "$MESSAGE" | grep -iq '\bcontinue\b'; then
14
14
  MODE="global"
15
15
 
16
16
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
17
- OUTPUT=$(ruby "$SCRIPT_DIR/../scripts/hook-continue" "$INDEX" "$STORE_ROOT" "$MODE" 2>/dev/null)
17
+ OUTPUT=$(env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-continue" "$INDEX" "$STORE_ROOT" "$MODE" 2>/dev/null)
18
18
 
19
19
  if [ -n "$OUTPUT" ]; then
20
20
  echo "$OUTPUT"
package/hooks/edit-gates CHANGED
@@ -1,3 +1,3 @@
1
1
  #!/bin/bash
2
2
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
3
- exec ruby "$SCRIPT_DIR/../scripts/hook-edit-gates"
3
+ exec env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-edit-gates"
@@ -1,6 +1,6 @@
1
1
  #!/bin/bash
2
2
  INPUT=$(cat)
3
- MESSAGE=$(echo "$INPUT" | ruby -rjson -e 'puts JSON.parse(STDIN.read)["user_prompt"].downcase' 2>/dev/null || echo "")
3
+ MESSAGE=$(echo "$INPUT" | env -u RUBYOPT ruby -rjson -e 'puts JSON.parse(STDIN.read)["user_prompt"].downcase' 2>/dev/null || echo "")
4
4
 
5
5
  if [ ${#MESSAGE} -lt 10 ]; then
6
6
  exit 0
@@ -22,4 +22,4 @@ else
22
22
  fi
23
23
 
24
24
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
25
- ruby "$SCRIPT_DIR/../scripts/hook-future-intent-check" "$STORE_ROOT" "$MESSAGE"
25
+ env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-future-intent-check" "$STORE_ROOT" "$MESSAGE"
package/hooks/gate-check CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/bin/bash
2
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)
3
+ FILE_PATH=$(echo "$INPUT" | env -u RUBYOPT 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
4
 
5
5
  if [ -z "$FILE_PATH" ]; then
6
6
  exit 0
7
7
  fi
8
8
 
9
- SESSION_ID=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("session_id") || ""' 2>/dev/null)
9
+ SESSION_ID=$(echo "$INPUT" | env -u RUBYOPT ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("session_id") || ""' 2>/dev/null)
10
10
 
11
11
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12
- ruby "$SCRIPT_DIR/../scripts/hook-gate-check" "$FILE_PATH" "$SESSION_ID"
12
+ env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-gate-check" "$FILE_PATH" "$SESSION_ID"
package/hooks/power-tools CHANGED
@@ -5,4 +5,4 @@ if [ ! -f "$GLOBAL_INDEX" ]; then
5
5
  exit 0
6
6
  fi
7
7
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
- exec ruby "$SCRIPT_DIR/../scripts/hook-power-tools"
8
+ exec env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-power-tools"
@@ -6,4 +6,4 @@ if [ ! -f "$GLOBAL_INDEX" ]; then
6
6
  fi
7
7
 
8
8
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
- ruby "$SCRIPT_DIR/../scripts/hook-session-start" "$GLOBAL_INDEX" "$HOME/.plastic" "global" "${CLAUDE_PLUGIN_ROOT:-}"
9
+ env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-session-start" "$GLOBAL_INDEX" "$HOME/.plastic" "global" "${CLAUDE_PLUGIN_ROOT:-}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,34 +3,37 @@
3
3
  # frozen_string_literal: true
4
4
  #
5
5
  # Usage: codex-hook <gate>
6
- # file-mutation gates: code-gate | lock-gate | savepoint-pre | gate-check | create-gate | links-gate
6
+ # file-mutation gates: edit-gates | gate-check
7
7
  # live-state hooks (intent 199): session-start | check-update | continue |
8
8
  # future-intent-check | auto-arm | power-tools | savepoint
9
9
  # shell-tool gate (intent 203): bash-gate
10
10
  #
11
- # The Codex input adapter (intent 102, extended by 199 and 203). Reads a Codex
12
- # hook stdin payload once. Three shapes exist and the gate name alone selects
13
- # which path runs (Codex's own hooks.json registration guarantees they never
14
- # cross): the file-mutation gates read session_id at top level and the
15
- # apply_patch command in tool_input.command [guide Part 4], parse the diff
16
- # envelope via ApplyPatchEnvelope, and drive Plastic's payload-agnostic Ruby
17
- # gate/savepoint cores once per file operation, relaying stdout/stderr/exit
18
- # unchanged. The live-state hooks carry no tool_input at all (they are not tool
19
- # calls); the dispatcher execs the SAME launcher file Claude already runs for
20
- # that hook (hooks/<name>), which is already harness-agnostic (resolves
21
- # ~/.plastic off $HOME, reads only the common stdin fields the guide confirms
22
- # Codex shares with Claude for these three events), so the body is reused, not
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
- # *** Begin Patch/*** End Patch envelope, so it would parse to an empty op list
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 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
11
+ # The Codex input adapter (intent 102, extended by 199, 203, and collapsed by
12
+ # 251). Reads a Codex hook stdin payload once. Three shapes exist and the gate
13
+ # name alone selects which path runs (Codex's own hooks.json registration
14
+ # guarantees they never cross): the apply_patch path reads session_id at top
15
+ # level and the apply_patch command in tool_input.command [guide Part 4],
16
+ # parses the diff envelope once via ApplyPatchEnvelope, and runs all five gates
17
+ # (savepoint-pre, lock-gate, code-gate, links-gate, create-gate) IN-PROCESS
18
+ # through scripts/lib/codex_edit_gates.rb, which drives the same
19
+ # scripts/lib/edit_gates.rb functions Claude's merged dispatcher drives
20
+ # (mirroring intent 244). This replaced five registered PreToolUse commands and
21
+ # three nested run_core children with one process. The live-state hooks carry
22
+ # no tool_input at all (they are not tool calls); the dispatcher execs the SAME
23
+ # launcher file Claude already runs for that hook (hooks/<name>), which is
24
+ # already harness-agnostic (resolves ~/.plastic off $HOME, reads only the
25
+ # common stdin fields the guide confirms Codex shares with Claude for these
26
+ # three events), so the body is reused, not rewritten (D3). The shell-tool gate
27
+ # (bash-gate) reports tool_name: "Bash" with the command in tool_input.command,
28
+ # but MUST NOT be routed through ApplyPatchEnvelope.parse below: a plain shell
29
+ # command has no *** Begin Patch/*** End Patch envelope, so it would parse to
30
+ # an empty op list and hit this file's own `exit 0 if ops.empty?` line,
31
+ # silently allowing every Bash call and reopening the exact hole intent 203
32
+ # closes. So it execs the SAME scripts/hook-bash-gate file Claude already runs,
33
+ # unmodified, the identical "drive the body, relay its output" pattern used for
34
+ # the live-state hooks. The one adaptation shared by both the live-state and
35
+ # shell-tool paths is threading the payload's session_id into
36
+ # CLAUDE_CODE_SESSION_ID, since Codex's own process env never carries it, plus
34
37
  # a bounded timeout: hooks/check-update backgrounds a real npm network call
35
38
  # without redirecting its output away from the inherited stdout/stderr pipes,
36
39
  # and Codex invokes hooks synchronously, so without a bound a slow network call
@@ -39,9 +42,6 @@ require "json"
39
42
  require "open3"
40
43
  require "rbconfig"
41
44
  require "timeout"
42
- require_relative "lib/apply_patch_envelope"
43
- require_relative "lib/intent_validator"
44
- require_relative "lib/links_gate"
45
45
 
46
46
  # Sync stdout so a relayed launcher's stdout and stderr land on the merged
47
47
  # pipe in call order. Ruby buffers STDOUT (but not STDERR) when it is not a
@@ -68,7 +68,7 @@ if STATE_HOOKS.include?(gate)
68
68
  cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
69
69
  env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
70
70
  out, err, status = begin
71
- Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env, launcher, stdin_data: raw, chdir: cwd) }
71
+ Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env.merge("RUBYOPT" => nil), launcher, stdin_data: raw, chdir: cwd) }
72
72
  rescue StandardError
73
73
  ["", "", nil] # fail open: launcher missing, unexecutable, crashed, or too slow
74
74
  end
@@ -88,7 +88,7 @@ if SHELL_HOOKS.include?(gate)
88
88
  script = File.join(__dir__, "hook-#{gate}")
89
89
  argv = [script]
90
90
  out, err, status = begin
91
- Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env, RbConfig.ruby, *argv, stdin_data: raw, chdir: cwd) }
91
+ Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env.merge("RUBYOPT" => nil), RbConfig.ruby, *argv, stdin_data: raw, chdir: cwd) }
92
92
  rescue StandardError
93
93
  ["", "", nil] # fail open: script missing, unexecutable, crashed, or too slow
94
94
  end
@@ -97,6 +97,8 @@ if SHELL_HOOKS.include?(gate)
97
97
  exit(status ? status.exitstatus : 0)
98
98
  end
99
99
 
100
+ require_relative "lib/apply_patch_envelope"
101
+
100
102
  command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
101
103
  exit 0 if command.nil? # fail-open: no tool_input at all (unrecognized gate name or non-tool-call payload)
102
104
  ops = ApplyPatchEnvelope.parse(command)
@@ -104,54 +106,30 @@ exit 0 if ops.empty? # fail-open: nothing parseable to gate
104
106
 
105
107
  CORES = __dir__ # ~/.plastic/scripts
106
108
  def run_core(name, *argv)
107
- out = IO.popen([RbConfig.ruby, File.join(CORES, name), *argv], "r", err: [:child, :out], &:read)
109
+ out = IO.popen([{ "RUBYOPT" => nil }, RbConfig.ruby, File.join(CORES, name), *argv], "r", err: [:child, :out], &:read)
108
110
  [out, $?.exitstatus]
109
111
  end
110
112
 
111
- def intent_file?(path)
112
- abs = File.expand_path(path)
113
- dir = File.dirname(abs)
114
- dir.match?(%r{/store/[^/]+--[^/]+\z}) && File.basename(abs) == "#{File.basename(dir)}.md"
115
- end
116
-
117
113
  case gate
118
- when "create-gate"
119
- # Pre-write veto: born-complete validation for Add ops on intent files only.
120
- # Update/Delete/Move defer to the PostToolUse gate-check backstop (spec Decision 5).
121
- ops.each do |o|
122
- next unless o.op == :add && intent_file?(o.path)
123
- result = IntentValidator.validate_content(o.added_content.to_s)
124
- next if result[:ok]
125
- $stderr.puts "PLASTIC CREATE GATE - #{File.basename(o.path)} is not a valid intent:"
126
- result[:errors].each { |e| $stderr.puts " #{e}" }
127
- $stderr.puts "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
128
- exit 2
129
- end
130
- exit 0
131
-
132
- when "lock-gate"
133
- # Deny is signalled by JSON on stdout at exit 0. Relay the first deny.
134
- # "codex" is threaded as the harness (intent 201 D2): hook-lock-gate renders
135
- # any deny reason with the $plastic-<name> form instead of Claude's default
136
- # slash.
137
- ops.each do |o|
138
- out, _ = run_core("hook-lock-gate", o.path, session, "codex")
139
- if out.include?('"permissionDecision":"deny"') || out.include?('"permissionDecision": "deny"')
140
- print out
141
- exit 0
142
- end
114
+ when "edit-gates"
115
+ # The ONE registered PreToolUse command on the apply_patch matcher (intent
116
+ # 251). Five gates, in-process, in Claude's evaluation order, first deny wins,
117
+ # each deny shape reproduced exactly: stderr plus exit 2 for code-gate,
118
+ # links-gate and create-gate; stdout permissionDecision JSON plus exit 0 for
119
+ # lock-gate. Fail-open at both levels, per gate inside EditGates.dispatch and
120
+ # again at this file's own top level.
121
+ begin
122
+ require_relative "lib/codex_edit_gates"
123
+ exit CodexEditGates.dispatch(ops: ops, payload: payload)
124
+ rescue StandardError, ScriptError => e
125
+ # Dispatcher-internal failure: never impersonate a deny. ScriptError (a
126
+ # sibling of StandardError, not a subclass) covers LoadError: a future
127
+ # manifest gap (a lib file required here but not distributed, exactly the
128
+ # bug the full suite caught during this intent's own delivery) must fail
129
+ # open with a gate decision, never exit 1 with no decision at all.
130
+ $stderr.puts "plastic codex edit-gates error: #{e.message}"
131
+ exit 0
143
132
  end
144
- exit 0
145
-
146
- when "code-gate"
147
- ops.each do |o|
148
- out, code = run_core("hook-code-gate", o.path, session, o.added_content.to_s)
149
- if code == 2
150
- $stderr.print out
151
- exit 2
152
- end
153
- end
154
- exit 0
155
133
 
156
134
  when "gate-check"
157
135
  last_allow = nil
@@ -166,40 +144,6 @@ when "gate-check"
166
144
  print last_allow if last_allow
167
145
  exit 0
168
146
 
169
- when "savepoint-pre"
170
- ops.each { |o| run_core("hook-savepoint-pre", o.path) }
171
- exit 0
172
-
173
- when "links-gate"
174
- # Write-time belt for the PLASTIC.md ## Links contract (intent 192), reusing
175
- # the SAME LinksGate.decision Claude's hook-links-gate already drives, so
176
- # both harnesses share one decision function. before_content is the real
177
- # on-disk file (File.read, or "" when it does not yet exist); after_content
178
- # is the proposed result, o.added_content.
179
- #
180
- # DISCLOSED, NARROWER judgment than Claude's version (intent 198, Decision
181
- # D3): ApplyPatchEnvelope.parse only ever captures a diff's ADDED lines,
182
- # never its removed or context lines, so for an Update whose diff is a
183
- # partial hunk rather than a full-file rewrite, after_content may not be the
184
- # complete proposed file. This is the same class of disclosed limitation
185
- # create-gate already carries for Update/Delete/Move (best effort, never a
186
- # hard crash). links-gate has no PostToolUse backstop to defer to on Claude
187
- # either, so running this best-effort compare on Update ops too is strictly
188
- # more coverage than the prior total fail-open.
189
- ops.each do |o|
190
- next unless intent_file?(o.path)
191
-
192
- before_content = File.exist?(o.path) ? File.read(o.path) : ""
193
- plastic_home = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
194
- reason = LinksGate.decision(file_path: o.path, before_content: before_content,
195
- after_content: o.added_content.to_s, plastic_home: plastic_home)
196
- next unless reason
197
-
198
- $stderr.puts reason
199
- exit 2
200
- end
201
- exit 0
202
-
203
147
  else
204
148
  exit 0
205
149
  end