@zalom/plastic 1.0.0-beta.11 → 1.0.0-beta.13

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
@@ -183,6 +183,10 @@ Sections: `## Active`, `## Future`, `## Clusters`, `## Abandoned`, `## Completed
183
183
 
184
184
  For index maintenance, use `plastic-managing-index`.
185
185
 
186
+ One-line entry convention. Each index entry is ONE line: `- [<id> <terse title>](<dir>) <tags>`.
187
+ The title is the title, not a summary: aim for about 80 characters, no multi-sentence
188
+ descriptions. This is a self-check, not a gate.
189
+
186
190
  ## Rules for Skills
187
191
 
188
192
  ALL work flows through intents.
@@ -194,6 +198,53 @@ ALL work flows through intents.
194
198
  5. Researches are intents. No separate folder.
195
199
  6. Intents are created only via `plastic-creating-intent`. Never hand-author an intent file. The skill self-verifies the written intent with `scripts/validate-intent` before announcing or committing, so every intent is born complete.
196
200
 
201
+ ## House Style (self-check)
202
+
203
+ The agent is the heaviest contributor to the transcript, so terseness pays every turn. These
204
+ are pre-send self-checks the agent applies to its own output. They are not gated.
205
+
206
+ - Answer or decision first. Lead with the result, then support it.
207
+ - Bullets over paragraphs.
208
+ - No preamble, no end-recap. Do not restate the question or summarize what you just said.
209
+ - One question-cluster at a time when asking the human.
210
+ - Reasoning goes in the thinking channel, not duplicated into the visible reply. This keeps
211
+ the human's visibility into your reasoning without paying for it twice in the transcript.
212
+
213
+ Active-intent cache rule. For the intent under active development you already hold its
214
+ delivered artifacts in your own context: prefer revisiting that in-context memory (hit the
215
+ cache) over re-reading them from disk, which only widens context. QMD is for OTHER or indexed
216
+ intents, not for re-reading what you just wrote. Pairs with `/clear` plus savepoint-resume
217
+ hygiene after each intent. Advisory self-check, not hard-verifiable.
218
+
219
+ ## Retrieval Gate
220
+
221
+ A single capability-aware PreToolUse gate enforces retrieval-first routing on the agent's own
222
+ Bash/Read/Grep/Glob calls (and on subagents, since PreToolUse binds them). Detection is binary:
223
+ present means enforce, absent or down means off, with no warning and no advisory tier.
224
+
225
+ - Store markdown (under a Plastic store) routes to QMD when QMD is present and the index is
226
+ fresh: the raw grep/find/Read is blocked and you use `qmd search`/`qmd query` (or
227
+ `scripts/qmd-sync search`) instead. When QMD is present but stale, the read is allowed this
228
+ turn and a background reindex is fired so the next turn enforces against a fresh index;
229
+ reindex is never synchronous. When QMD is absent or down, raw reads are allowed.
230
+ - Serena-supported code and data files route to Serena symbolic tools when Serena is present;
231
+ absent means allowed.
232
+ - Images, binaries, and everything else are allowed.
233
+ - Bypass: append a trailing `# qmd-ok` shell comment to a Bash command for the rare case where
234
+ QMD is healthy but you genuinely need the raw read. A quoted or echoed occurrence does not
235
+ bypass. Bypasses are logged.
236
+ - Scope: only the agent's tool calls. Ruby `File.read` inside a script is invisible to the gate
237
+ and is out of scope by design.
238
+
239
+ ## Context-economy measurement buckets (84a)
240
+
241
+ Intent 84 defines three buckets for sibling 84a to audit against; 84 does not run the audit.
242
+
243
+ - (a) gate-hook prose tokens: the per-transition narration emitted by the gate hook.
244
+ - (b) main-loop store-read tokens: tokens the main agent spends reading or grepping the store
245
+ in the transcript.
246
+ - (c) authored-section sizes: sizes of authored artifacts (INDEX entries and the like).
247
+
197
248
  ## Transition Gates
198
249
 
199
250
  | Transition | Trigger | Gate |
@@ -205,6 +256,26 @@ ALL work flows through intents.
205
256
 
206
257
  Hard blocking — hooks exit code 2 on gate failure.
207
258
 
259
+ ## Delivery Isolation and the Single-Owner Lock
260
+
261
+ Exactly one session or agent develops an intent's delivery at a time. Ownership is the armed
262
+ session bridge, which doubles as the delivery lock: arming records the owning session, the
263
+ owner pid, an acquired-at timestamp, and the host. Another session that finds an armed bridge
264
+ for the same intent with a live owner backs off; if the owner pid is dead the lock is
265
+ reclaimable. This is mandatory, not a convention.
266
+
267
+ Every code-touching intent gets its own git worktree named `{id}--{slug}`, and all code edits
268
+ for that intent happen only inside it. Plastic provisions the worktree deterministically: it
269
+ resolves the project repo from `projects.yml` and runs `git -C <repo> worktree add`, so
270
+ isolation never depends on the current working directory. There are two worktrees per project
271
+ intent: a code worktree at `<repo>/.claude/worktrees/{id}--{slug}` (branch `plastic/{id}--{slug}`)
272
+ and a store worktree at `<plastic_home>/.worktrees/{id}--{slug}` (branch
273
+ `plastic-store/{id}--{slug}`), so lifecycle-doc commits and code commits move as one unit.
274
+
275
+ Provisioning fails open for intents that touch no project code (pure research or decision
276
+ intents in the global store, or a non-git repo): those get the lock only, and the worktree
277
+ block stays unprovisioned. The fail-open path is always logged, never silent.
278
+
208
279
  ## Deprecation Process
209
280
 
210
281
  Deprecations live in `deprecations.yml` and are shown at SessionStart. While Plastic is
package/hooks/hooks.json CHANGED
@@ -40,6 +40,16 @@
40
40
  }
41
41
  ]
42
42
  },
43
+ {
44
+ "matcher": "Write|Edit",
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" savepoint-pre",
49
+ "statusMessage": "Recording stage start..."
50
+ }
51
+ ]
52
+ },
43
53
  {
44
54
  "matcher": "Write",
45
55
  "hooks": [
@@ -59,6 +69,16 @@
59
69
  "statusMessage": "Checking lifecycle gate..."
60
70
  }
61
71
  ]
72
+ },
73
+ {
74
+ "matcher": "Bash|Read|Grep|Glob",
75
+ "hooks": [
76
+ {
77
+ "type": "command",
78
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" retrieval-gate",
79
+ "statusMessage": "Checking retrieval gate..."
80
+ }
81
+ ]
62
82
  }
63
83
  ],
64
84
  "PostToolUse": [
@@ -0,0 +1,10 @@
1
+ #!/bin/bash
2
+ # Retrieval-gate hook launcher (intent 84, Lever 2). No-op when there is no
3
+ # global store. Passes the PreToolUse JSON through on stdin (exec inherits it)
4
+ # and the plastic_home as ARGV[0], mirroring the qmd-search launcher.
5
+ GLOBAL_INDEX="$HOME/.plastic/INDEX.md"
6
+ if [ ! -f "$GLOBAL_INDEX" ]; then
7
+ exit 0
8
+ fi
9
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ exec ruby "$SCRIPT_DIR/../scripts/hook-retrieval-gate" "$HOME/.plastic"
@@ -0,0 +1,10 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ FILE_PATH=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("tool_params", "file_path") || data.dig("tool_input", "file_path") || ""' 2>/dev/null)
4
+
5
+ if [ -z "$FILE_PATH" ]; then
6
+ exit 0
7
+ fi
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ ruby "$SCRIPT_DIR/../scripts/hook-savepoint-pre" "$FILE_PATH"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-beta.11",
3
+ "version": "1.0.0-beta.13",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,11 +3,15 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  # Usage: hook-code-gate <file_path>
6
- # PreToolUse gate (intent 27): when auto mode is armed and the active intent has not
7
- # reached How (plan.md + checklist.md), block edits to project code outside the store.
6
+ # PreToolUse gate. Composes two independent block rules; EITHER blocks the edit:
7
+ # - Stage rule (intent 27): when auto mode is armed and the active intent has not
8
+ # reached How (plan.md + checklist.md), block edits to project code outside the store.
9
+ # - Worktree isolation rule (intent 73c2): when the intent has a provisioned code
10
+ # worktree, block project-code edits outside it; and block edits to another
11
+ # intent's store dir whose delivery lock is held by a live non-owner session.
8
12
  #
9
13
  # Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
10
- # No bridge / auto not armed / How reached / path under ~/.plastic or the intent dir = allow.
14
+ # No bridge = allow. Each rule fails open on its own conditions (see bridge.rb).
11
15
 
12
16
  require_relative "lib/bridge"
13
17
 
@@ -20,7 +24,8 @@ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
20
24
  bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
21
25
  exit 0 unless bridge_data
22
26
 
23
- reason = Bridge.code_gate_decision(bridge_data, file_path)
27
+ reason = Bridge.code_gate_decision(bridge_data, file_path) ||
28
+ Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
24
29
  exit 0 unless reason
25
30
 
26
31
  $stderr.puts "PLASTIC GATE — #{reason}"
@@ -24,6 +24,12 @@ intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
24
24
  if intent_dir_abs
25
25
  begin
26
26
  Bridge.append_savepoint(intent_dir_abs, file_path_abs)
27
+ # When checklist.md lands, How ends and Exec begins: emit the `Exec started`
28
+ # companion in the same event (intent 81). Guard on a real (non-placeholder)
29
+ # checklist so a scaffold sentinel does not trip it.
30
+ if File.basename(file_path_abs) == "checklist.md" && Bridge.stage_file_present?(file_path_abs)
31
+ Bridge.append_exec_started(intent_dir_abs)
32
+ end
27
33
  rescue StandardError
28
34
  # ignore — rebuildable from disk
29
35
  end
@@ -112,32 +118,17 @@ if is_stage_file || is_action_file
112
118
 
113
119
  Bridge.write(session, bridge_data)
114
120
 
115
- # Build transition context
116
- stage_labels = { "what" => "What", "why" => "Why", "how" => "How", "exec" => "Exec", "done" => "Done" }
117
- next_hints = {
118
- "why" => "write spec.md",
119
- "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
120
- "exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
121
- "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
122
- }
123
-
124
- context_parts = ["PLASTIC"]
125
- if old_stage != new_stage
126
- context_parts[0] = "PLASTIC — Stage transition: #{stage_labels[old_stage] || old_stage} → #{stage_labels[new_stage] || new_stage}."
127
- else
128
- context_parts[0] = "PLASTIC — #{basename} written (stage: #{stage_labels[new_stage] || new_stage})."
129
- end
130
- context_parts << "#{basename} written." if old_stage != new_stage
131
- if new_missing.any?
132
- context_parts << "Next: #{new_missing.join(", ")}"
133
- elsif next_hints[new_stage]
134
- context_parts << "Next: #{next_hints[new_stage]}"
135
- end
121
+ # Build transition context — ONE concise sentence (intent 84, Lever 1),
122
+ # preserving the `Next: ...` hint. Formatting is pure in Bridge.gate_narration.
123
+ context = Bridge.gate_narration(
124
+ old_stage: old_stage, new_stage: new_stage,
125
+ basename: basename, new_missing: new_missing
126
+ )
136
127
 
137
128
  payload = {
138
129
  "hookSpecificOutput" => {
139
130
  "hookEventName" => "PostToolUse",
140
- "additionalContext" => context_parts.join(" ")
131
+ "additionalContext" => context
141
132
  }
142
133
  }
143
134
  puts JSON.generate(payload)
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # PreToolUse retrieval gate (intent 84, Lever 2). Reads the tool call (JSON on
6
+ # stdin: tool_name + tool_input), computes capabilities (QMD detect+freshness,
7
+ # Serena detect), delegates the decision to RetrievalGate, and enforces:
8
+ # ALLOW = exit 0 ; BLOCK = exit 2 with reason on stderr (shown to the agent).
9
+ # Fail-open: any parse error, timeout, or unexpected exception exits 0. On the
10
+ # STALE QMD path RetrievalGate fires QmdSync.reindex_async (NEVER synchronous).
11
+ # Binds subagents (PreToolUse hooks apply to subagent tool calls too).
12
+ #
13
+ # Scope: only the agent's own Bash/Read/Grep/Glob calls. Ruby `File.read` inside
14
+ # scripts is invisible to a PreToolUse hook and is out of scope (no exemptions).
15
+ #
16
+ # ARGV[0] is plastic_home (passed by the launcher), like hook-qmd-search.
17
+
18
+ require "json"
19
+ require "timeout"
20
+ require_relative "lib/retrieval_gate"
21
+ require_relative "lib/qmd_sync"
22
+ require_relative "lib/power_tools"
23
+
24
+ module RetrievalGateHook
25
+ module_function
26
+
27
+ # Pure-ish core: capabilities and reindex are injected so this is unit-testable
28
+ # with no real qmd/serena. Returns [exit_code, stderr_string].
29
+ # stdin: raw PreToolUse JSON
30
+ # capabilities: { qmd:, qmd_fresh:, serena: }
31
+ # reindex: callable fired on the STALE path
32
+ def run(stdin:, plastic_home:, cwd:, capabilities:, reindex: -> {})
33
+ payload = parse(stdin)
34
+ return [0, nil] unless payload
35
+
36
+ tool_name = payload["tool_name"].to_s
37
+ tool_input = payload["tool_input"]
38
+ tool_input = {} unless tool_input.is_a?(Hash)
39
+
40
+ bypassed = false
41
+ reason = RetrievalGate.decision(
42
+ tool_name: tool_name, tool_input: tool_input,
43
+ plastic_home: plastic_home, cwd: cwd,
44
+ capabilities: capabilities, reindex: reindex
45
+ ) { |_sig| bypassed = true }
46
+
47
+ if reason
48
+ [2, "PLASTIC GATE — #{reason}"]
49
+ elsif bypassed
50
+ [0, "PLASTIC GATE — bypassed via # qmd-ok"]
51
+ else
52
+ [0, nil]
53
+ end
54
+ rescue StandardError
55
+ [0, nil] # fail-open
56
+ end
57
+
58
+ def parse(raw)
59
+ data = JSON.parse(raw.to_s)
60
+ data.is_a?(Hash) ? data : nil
61
+ rescue StandardError
62
+ nil
63
+ end
64
+
65
+ # Detect real capabilities for the live executable. A slow `qmd status` cannot
66
+ # stall a tool call: a Timeout around the freshness probe degrades to "absent
67
+ # for this turn" (allow, no reindex), staying fail-open and non-blocking.
68
+ def detect_capabilities(cwd:)
69
+ qmd = QmdSync.detect
70
+ qmd_fresh = false
71
+ if qmd
72
+ qmd_fresh = begin
73
+ Timeout.timeout(2) { QmdSync.fresh? }
74
+ rescue StandardError
75
+ # Probe stalled/failed: treat as absent this turn (allow, no reindex).
76
+ qmd = false
77
+ false
78
+ end
79
+ end
80
+ serena = PowerTools.serena?(cwd: cwd)
81
+ { qmd: qmd, qmd_fresh: qmd_fresh, serena: serena }
82
+ end
83
+
84
+ # Best-effort reindex callable for the STALE path. Resolves the collection from
85
+ # cwd (project + global) and fires the async reindexer for each; never raises.
86
+ def reindex_for(cwd:, plastic_home:)
87
+ lambda do
88
+ begin
89
+ cols = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
90
+ cols.each { |c| QmdSync.reindex_async(collection: c) }
91
+ rescue StandardError
92
+ # non-fatal; the read is already allowed this turn
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ # --- executable entrypoint ---
99
+ if $PROGRAM_NAME == __FILE__
100
+ raw = begin
101
+ $stdin.read
102
+ rescue StandardError
103
+ ""
104
+ end
105
+
106
+ plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
107
+ cwd = Dir.pwd
108
+
109
+ code, err = begin
110
+ caps = RetrievalGateHook.detect_capabilities(cwd: cwd)
111
+ RetrievalGateHook.run(
112
+ stdin: raw, plastic_home: plastic_home, cwd: cwd,
113
+ capabilities: caps,
114
+ reindex: RetrievalGateHook.reindex_for(cwd: cwd, plastic_home: plastic_home)
115
+ )
116
+ rescue StandardError
117
+ [0, nil] # fail-open at the outermost boundary too
118
+ end
119
+
120
+ $stderr.puts(err) if err && !err.empty?
121
+ exit code
122
+ end
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+ # Usage: hook-savepoint-pre <file_path>
5
+ #
6
+ # PreToolUse savepoint trigger (intent 81). When a stage-opening lifecycle file
7
+ # (spec.md => Why, plan.md => How) is ABOUT to be written into an intent dir,
8
+ # append the pre-stage `started` ledger line so the ledger records "this stage
9
+ # was entered" before its artifact lands.
10
+ #
11
+ # Like the PostToolUse decoupled savepoint write (intent 52), it is derived from
12
+ # the file path alone: no bridge, no session, fires headless. It NEVER blocks a
13
+ # write: any non-match or failure exits 0. The append is idempotent and only
14
+ # fires while the stage is genuinely starting (the artifact is not yet a real,
15
+ # non-placeholder file), so re-edits add nothing.
16
+
17
+ require_relative "lib/bridge"
18
+
19
+ file_path = ARGV[0]
20
+ exit 0 if file_path.nil? || file_path.empty?
21
+
22
+ file_path_abs = File.expand_path(file_path)
23
+ intent_dir = Bridge.intent_dir_for(file_path_abs)
24
+ exit 0 unless intent_dir
25
+
26
+ begin
27
+ Bridge.append_started_savepoint(intent_dir, file_path_abs)
28
+ rescue StandardError
29
+ # best-effort; the ledger is rebuildable and the post line still lands
30
+ end
31
+
32
+ exit 0