@zalom/plastic 1.4.0 → 1.4.1

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 CHANGED
@@ -99,6 +99,11 @@ tags: [plastic, architecture]
99
99
  | **How** | Planning | `plan.md` + `actions/ACTION_N.md` (at least one) + `checklist.md` | `plastic-intent-planning` |
100
100
  | **Exec** | Execution | `outcome.md` | `plastic-intent-executing` |
101
101
 
102
+ Invoke a skill for your harness: Claude Code uses the slash form (`/plastic-intent-creating`);
103
+ Codex CLI uses a dollar prefix instead (`$plastic-intent-creating`), and may also select a skill
104
+ implicitly by matching its description. Skill names elsewhere in this document are given bare
105
+ (`plastic-intent-creating`); add the prefix for your harness.
106
+
102
107
  `## Insights` is the append-only log of durable discoveries captured throughout ALL stages.
103
108
  An insight is a discovery worth keeping for later reads: novel, or old but newly relevant,
104
109
  surfaced at any stage (What, Why, How, Exec). It is the most interesting residue of an
@@ -288,8 +293,8 @@ Beyond the lifecycle agents, Plastic ships thin skills for day-to-day operation:
288
293
  - **`plastic-feedback`** (intent 174) turns a described Plastic quirk, bug, or feature idea
289
294
  into a redacted local report file and a prefilled GitHub issue URL; only the user can submit
290
295
  it. `disable-model-invocation` hides its description from your own context, so if the user
291
- hits a Plastic quirk, bug, or missing feature, offer to run `/plastic-feedback` yourself
292
- instead of waiting to be asked; the user still sends it, you never do.
296
+ hits a Plastic quirk, bug, or missing feature, offer to invoke the plastic-feedback skill
297
+ yourself instead of waiting to be asked; the user still sends it, you never do.
293
298
 
294
299
  ## Releases and Versioning
295
300
 
@@ -505,18 +510,31 @@ Each gate guards one thing. All are hard except the retrieval gate:
505
510
 
506
511
  Exactly one session or agent develops an intent's delivery at a time. Ownership is
507
512
  session-keyed and durable: arming acquires `delivery.lock` inside the intent directory
508
- (atomically, O_EXCL), recording the owner session, the host, the acquired-at time, a
509
- delegates list, and the lock type. Liveness is a lease: the owner's hooks refresh the lock
510
- file's mtime on tool activity, and the lock counts as stale only when that heartbeat is
511
- older than the TTL. No process id is consulted anywhere. The /tmp session bridge is a cache
513
+ (atomically, O_EXCL). The session id is the authorization identity. Descriptive provenance
514
+ records the controller's explicit `harness`, `agent`, `model`, `thread`, and `mode` values,
515
+ but never grants access and is never inferred from transcripts or filesystem paths. Missing
516
+ fields on legacy locks display as `Unknown`. Liveness is a lease: the owner's hooks refresh
517
+ the lock file's mtime on tool activity, and that mtime is the sole heartbeat truth. The lock
518
+ counts as stale only when the mtime is older than the TTL. No process id is consulted anywhere.
519
+ The /tmp session bridge is a cache
512
520
  of this state; on any disagreement, or when the bridge is missing, the lock file wins.
513
521
  Another session that finds a fresh lock backs off; a stale lock is reclaimed only by
514
522
  explicit takeover, which replaces the lock and appends an audit line to the intent's
515
- savepoint.md. Subagents spawned by the owner write under the owner's lock once registered
516
- as delegates. Disarm clears the lock; the End tail is ordered: verify, merge and remove
523
+ savepoint.md. Rearming the same session preserves its acquired identity and refreshes known
524
+ provenance; an explicit takeover replaces the controller and starts new provenance.
525
+ Subagents spawned by the owner write under the owner's lock once registered as delegates.
526
+ Delegate activity status (`active`, `finished`, or `failed`) is descriptive and does not revoke
527
+ the session's string-array authorization. A registered delegate remains authorized until a
528
+ separate authorization-removal mechanism exists. Finished and failed delegate activity is
529
+ retained as descriptive history, bounded to the 20 most recent terminal entries. A controller,
530
+ a delegate, and an artifact claim are distinct evidence: controller ownership authorizes the
531
+ delivery, delegate registration authorizes a child session, and a claim selects one current
532
+ writer for one artifact. Disarm clears the lock; the End tail is ordered: verify, merge and remove
517
533
  worktrees, clear the lock, and only then is the bridge purge-eligible. Repair is one
518
- idempotent function with two entry points: the `plastic-lock` command (status, fix,
519
- release, reclaim, delegate) and `/plastic-intent-starting`, so boarding self-heals. This is
534
+ idempotent function with two entry points: the `plastic-lock` command (`who`, status, fix,
535
+ release, reclaim, delegate) and `/plastic-intent-starting`, so boarding self-heals. `who` is
536
+ read-only and reports the controller, mtime heartbeat, delegates, and claims from durable files.
537
+ This is
520
538
  mandatory, not a convention.
521
539
 
522
540
  Solo-mode gate defaults (intent 128): on a confirmed positive solo determination
@@ -658,4 +676,3 @@ the lock-bounded post-done window with its keep-guard test. Intent 111 owns the
658
676
  liveness surface, the lock-issue message, orchestrator auto-repair, and the fail-open
659
677
  behavior itself. Intent 112 owns the maintenance lock and the immutability gate (it inherits
660
678
  fail-open from 111). Intent 4a1b1 owns deep agent stuck-detection and is not superseded.
661
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,18 +2,57 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
  #
5
- # Usage: codex-hook <gate> (gate: code-gate | lock-gate | savepoint-pre | gate-check | create-gate)
5
+ # Usage: codex-hook <gate>
6
+ # file-mutation gates: code-gate | lock-gate | savepoint-pre | gate-check | create-gate | links-gate
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
6
10
  #
7
- # The Codex input adapter (intent 102). Reads a Codex hook stdin payload
8
- # (session_id at top level, the apply_patch command in tool_input.command,
9
- # [guide Part 4]), parses the diff envelope via ApplyPatchEnvelope, and drives
10
- # Plastic's payload-agnostic Ruby gate/savepoint cores once per file operation.
11
- # The cores' output contracts are already Codex-compatible, so their stdout/
12
- # stderr/exit are relayed unchanged (see ACTION_1's ARGV-contract table).
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 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
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 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
34
+ # a bounded timeout: hooks/check-update backgrounds a real npm network call
35
+ # without redirecting its output away from the inherited stdout/stderr pipes,
36
+ # and Codex invokes hooks synchronously, so without a bound a slow network call
37
+ # could hold this dispatcher's pipe open past the launcher's own (fast) exit.
13
38
  require "json"
39
+ require "open3"
14
40
  require "rbconfig"
41
+ require "timeout"
15
42
  require_relative "lib/apply_patch_envelope"
16
43
  require_relative "lib/intent_validator"
44
+ require_relative "lib/links_gate"
45
+
46
+ # Sync stdout so a relayed launcher's stdout and stderr land on the merged
47
+ # pipe in call order. Ruby buffers STDOUT (but not STDERR) when it is not a
48
+ # TTY; without this, `warn err` below can reach the OS pipe before a
49
+ # preceding `print out` is flushed, corrupting the relayed JSON for any
50
+ # caller that merges the dispatcher's own stdout and stderr.
51
+ $stdout.sync = true
52
+
53
+ STATE_HOOKS = %w[session-start check-update continue future-intent-check auto-arm qmd-search savepoint].freeze
54
+ STATE_TIMEOUT = 5
55
+ SHELL_HOOKS = %w[bash-gate retrieval-gate].freeze
17
56
 
18
57
  gate = ARGV[0].to_s
19
58
  raw = ($stdin.read rescue nil)
@@ -22,7 +61,49 @@ payload = (JSON.parse(raw) rescue nil)
22
61
  exit 0 unless payload.is_a?(Hash)
23
62
 
24
63
  session = payload["session_id"].to_s
64
+
65
+ if STATE_HOOKS.include?(gate)
66
+ launcher = File.expand_path(File.join(__dir__, "..", "hooks", gate))
67
+ cwd = payload["cwd"].to_s
68
+ cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
69
+ env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
70
+ out, err, status = begin
71
+ Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env, launcher, stdin_data: raw, chdir: cwd) }
72
+ rescue StandardError
73
+ ["", "", nil] # fail open: launcher missing, unexecutable, crashed, or too slow
74
+ end
75
+ print out unless out.to_s.empty?
76
+ warn err unless err.to_s.empty?
77
+ exit(status ? status.exitstatus : 0)
78
+ end
79
+
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).
86
+ cwd = payload["cwd"].to_s
87
+ cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
88
+ env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
89
+ script = File.join(__dir__, "hook-#{gate}")
90
+ 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
+ out, err, status = begin
96
+ Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env, RbConfig.ruby, *argv, stdin_data: raw, chdir: cwd) }
97
+ rescue StandardError
98
+ ["", "", nil] # fail open: script missing, unexecutable, crashed, or too slow
99
+ end
100
+ print out unless out.to_s.empty?
101
+ warn err unless err.to_s.empty?
102
+ exit(status ? status.exitstatus : 0)
103
+ end
104
+
25
105
  command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
106
+ exit 0 if command.nil? # fail-open: no tool_input at all (unrecognized gate name or non-tool-call payload)
26
107
  ops = ApplyPatchEnvelope.parse(command)
27
108
  exit 0 if ops.empty? # fail-open: nothing parseable to gate
28
109
 
@@ -55,8 +136,11 @@ when "create-gate"
55
136
 
56
137
  when "lock-gate"
57
138
  # Deny is signalled by JSON on stdout at exit 0. Relay the first deny.
139
+ # "codex" is threaded as the harness (intent 201 D2): hook-lock-gate renders
140
+ # any deny reason with the $plastic-<name> form instead of Claude's default
141
+ # slash.
58
142
  ops.each do |o|
59
- out, _ = run_core("hook-lock-gate", o.path, session)
143
+ out, _ = run_core("hook-lock-gate", o.path, session, "codex")
60
144
  if out.include?('"permissionDecision":"deny"') || out.include?('"permissionDecision": "deny"')
61
145
  print out
62
146
  exit 0
@@ -91,6 +175,36 @@ when "savepoint-pre"
91
175
  ops.each { |o| run_core("hook-savepoint-pre", o.path) }
92
176
  exit 0
93
177
 
178
+ when "links-gate"
179
+ # Write-time belt for the PLASTIC.md ## Links contract (intent 192), reusing
180
+ # the SAME LinksGate.decision Claude's hook-links-gate already drives, so
181
+ # both harnesses share one decision function. before_content is the real
182
+ # on-disk file (File.read, or "" when it does not yet exist); after_content
183
+ # is the proposed result, o.added_content.
184
+ #
185
+ # DISCLOSED, NARROWER judgment than Claude's version (intent 198, Decision
186
+ # D3): ApplyPatchEnvelope.parse only ever captures a diff's ADDED lines,
187
+ # never its removed or context lines, so for an Update whose diff is a
188
+ # partial hunk rather than a full-file rewrite, after_content may not be the
189
+ # complete proposed file. This is the same class of disclosed limitation
190
+ # create-gate already carries for Update/Delete/Move (best effort, never a
191
+ # hard crash). links-gate has no PostToolUse backstop to defer to on Claude
192
+ # either, so running this best-effort compare on Update ops too is strictly
193
+ # more coverage than the prior total fail-open.
194
+ ops.each do |o|
195
+ next unless intent_file?(o.path)
196
+
197
+ before_content = File.exist?(o.path) ? File.read(o.path) : ""
198
+ plastic_home = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
199
+ reason = LinksGate.decision(file_path: o.path, before_content: before_content,
200
+ after_content: o.added_content.to_s, plastic_home: plastic_home)
201
+ next unless reason
202
+
203
+ $stderr.puts reason
204
+ exit 2
205
+ end
206
+ exit 0
207
+
94
208
  else
95
209
  exit 0
96
210
  end