@zalom/plastic 1.0.0-alpha.13 → 1.0.0-alpha.15

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
@@ -18,9 +18,15 @@ store/
18
18
  checklist.md # optional — execution registry (How deliverable)
19
19
  outcome.md # optional — detailed result (Exec deliverable)
20
20
  actions/ # optional — individual work items
21
+ resources/ # optional — research, references, screenshots, diagrams
21
22
  savepoint.md # optional — session state for resume
22
23
  ```
23
24
 
25
+ Lifecycle files (`spec.md`, `plan.md`, `checklist.md`, `outcome.md`) have defined
26
+ roles. Supporting artifacts that aren't lifecycle deliverables — research reports,
27
+ reference docs, external API snapshots, screenshots, diagrams — go in `resources/`.
28
+ Name files inside as `{type}--{description}.md` (e.g., `deep-research--gsd-core.md`).
29
+
24
30
  ## Frontmatter
25
31
 
26
32
  Identity and knowledge graph only. Nothing operational.
@@ -115,6 +121,15 @@ Format: `ID--three-to-five-words` (all stores).
115
121
  - Intent file matches directory: `1a1--slug/1a1--slug.md`
116
122
  - Next ID: `"${CLAUDE_PLUGIN_ROOT}/scripts/folgezettel-id" <parent_id> <store_path>`
117
123
 
124
+ **Branch vs root — the semantic decision.** The numbering is mechanics; choosing
125
+ *whether* to branch is meaning:
126
+
127
+ - **Branch (`14a`, `14b`)** — a sub-task, refinement, or direct continuation of the
128
+ parent. It cannot stand on its own; it only makes sense as part of the parent's work.
129
+ - **Root (`15`, `16`)** — an independent thought, even if inspired by another intent.
130
+ Record provenance with `sources: ["14"]`, not by branching.
131
+ - **Rule of thumb:** if the intent could exist without its parent, it's a root.
132
+
118
133
  ## INDEX.md
119
134
 
120
135
  A Zettelkasten structure note, not a table of contents. Clusters by meaning.
package/hooks/auto-arm ADDED
@@ -0,0 +1,5 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ MESSAGE=$(echo "$INPUT" | ruby -rjson -e 'puts JSON.parse(STDIN.read)["user_prompt"].to_s' 2>/dev/null || echo "")
4
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
5
+ ruby "$SCRIPT_DIR/../scripts/hook-auto-arm" "$MESSAGE"
@@ -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-code-gate" "$FILE_PATH"
package/hooks/hooks.json CHANGED
@@ -29,6 +29,18 @@
29
29
  ]
30
30
  }
31
31
  ],
32
+ "PreToolUse": [
33
+ {
34
+ "matcher": "Write|Edit|NotebookEdit",
35
+ "hooks": [
36
+ {
37
+ "type": "command",
38
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" code-gate",
39
+ "statusMessage": "Checking lifecycle gate..."
40
+ }
41
+ ]
42
+ }
43
+ ],
32
44
  "PostToolUse": [
33
45
  {
34
46
  "matcher": "Write|Edit",
@@ -61,6 +73,16 @@
61
73
  "statusMessage": "Checking future intents..."
62
74
  }
63
75
  ]
76
+ },
77
+ {
78
+ "matcher": "",
79
+ "hooks": [
80
+ {
81
+ "type": "command",
82
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/auto-arm\"",
83
+ "statusMessage": "Checking auto mode..."
84
+ }
85
+ ]
64
86
  }
65
87
  ]
66
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-alpha.13",
3
+ "version": "1.0.0-alpha.15",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Usage: hook-auto-arm <message>
6
+ # UserPromptSubmit arming (intent 27). Two jobs, both additive context — never blocks:
7
+ # 1. Detect auto-trigger phrases -> steer the agent to invoke plastic:auto.
8
+ # 2. If auto mode is armed and the active intent hasn't reached How -> remind the agent
9
+ # that project-code edits are gate-blocked until plan.md + checklist.md exist.
10
+ # Outputs UserPromptSubmit hookSpecificOutput JSON, or exits silently.
11
+
12
+ require "json"
13
+ require_relative "lib/bridge"
14
+
15
+ message = (ARGV[0] || "").downcase
16
+ parts = []
17
+
18
+ # --- 1. Auto-trigger steering ---
19
+ AUTO_TRIGGERS = ["take it from here", "deliver this", "/plastic:auto", "auto mode"].freeze
20
+ # bare "auto" only as a standalone word to avoid matching "automatic", "automation", etc.
21
+ triggered = AUTO_TRIGGERS.any? { |p| message.include?(p) } || message.match?(/\bauto\b/)
22
+ if triggered
23
+ parts << "Auto-mode request detected. Invoke the plastic:auto skill to deliver the " \
24
+ "active intent autonomously — it arms the lifecycle gate so project code " \
25
+ "cannot be edited before plan.md + checklist.md exist."
26
+ end
27
+
28
+ # --- 2. Standing gate reminder when armed and pre-How ---
29
+ session = ENV["CLAUDE_SESSION_ID"]
30
+ bridge_data = (session && !session.empty? && File.exist?(Bridge.path(session))) ? Bridge.read(session) : nil
31
+ if bridge_data && bridge_data.dig("build", "auto") == true
32
+ intent = bridge_data["intent"] || {}
33
+ store = intent["store"]; dir = intent["dir"]
34
+ if store && dir
35
+ intent_dir = File.expand_path("#{store}/#{dir}")
36
+ reached_how = File.exist?("#{intent_dir}/plan.md") && File.exist?("#{intent_dir}/checklist.md")
37
+ unless reached_how
38
+ parts << "Auto mode armed on intent #{intent["id"]} — produce spec -> plan -> " \
39
+ "checklist before editing project code. Code edits are gate-blocked until then."
40
+ end
41
+ end
42
+ end
43
+
44
+ exit 0 if parts.empty?
45
+
46
+ payload = {
47
+ "hookSpecificOutput" => {
48
+ "hookEventName" => "UserPromptSubmit",
49
+ "additionalContext" => parts.join("\n")
50
+ }
51
+ }
52
+ puts JSON.generate(payload)
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
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.
8
+ #
9
+ # 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.
11
+
12
+ require_relative "lib/bridge"
13
+
14
+ file_path = ARGV[0]
15
+ exit 0 unless file_path && !file_path.empty?
16
+
17
+ # --- Load bridge (mirror hook-gate-check resolution) ---
18
+ session = ENV["CLAUDE_SESSION_ID"]
19
+ bridge_data = nil
20
+
21
+ if session && !session.empty?
22
+ bridge_data = Bridge.read(session) if File.exist?(Bridge.path(session))
23
+ end
24
+
25
+ unless bridge_data
26
+ candidates = Dir.glob("/tmp/plastic-*.json").reject { |f| f.end_with?(".tmp") }
27
+ if candidates.any?
28
+ newest = candidates.max_by { |f| File.mtime(f) }
29
+ bridge_data = JSON.parse(File.read(newest)) rescue nil
30
+ end
31
+ end
32
+
33
+ exit 0 unless bridge_data
34
+
35
+ reason = Bridge.code_gate_decision(bridge_data, file_path)
36
+ exit 0 unless reason
37
+
38
+ $stderr.puts "PLASTIC GATE — #{reason}"
39
+ exit 2
@@ -164,6 +164,8 @@ def distribute(mode)
164
164
  "scripts/hook-continue" => "scripts/hook-continue",
165
165
  "scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
166
166
  "scripts/hook-gate-check" => "scripts/hook-gate-check",
167
+ "scripts/hook-code-gate" => "scripts/hook-code-gate",
168
+ "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
167
169
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
168
170
  "scripts/doctor.rb" => "scripts/doctor.rb",
169
171
  }
@@ -441,6 +443,12 @@ def merge_claude_hooks(settings_path)
441
443
  { "type" => "command", "command" => "#{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
442
444
  ],
443
445
  },
446
+ "PreToolUse" => {
447
+ "matcher" => "Write|Edit|NotebookEdit",
448
+ "hooks" => [
449
+ { "type" => "command", "command" => "#{hook_dir}/plastic-code-gate", "statusMessage" => "Checking lifecycle gate..." },
450
+ ],
451
+ },
444
452
  "PostToolUse" => {
445
453
  "matcher" => "Write|Edit",
446
454
  "hooks" => [
@@ -452,6 +460,7 @@ def merge_claude_hooks(settings_path)
452
460
  "hooks" => [
453
461
  { "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
454
462
  { "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
463
+ { "type" => "command", "command" => "#{hook_dir}/plastic-auto-arm", "statusMessage" => "Checking auto mode..." },
455
464
  ],
456
465
  },
457
466
  }
@@ -88,6 +88,7 @@ module Bridge
88
88
  "has" => has,
89
89
  "missing" => missing,
90
90
  "gate_failures" => 0,
91
+ "auto" => false,
91
92
  "last_activity" => Time.now.utc.iso8601
92
93
  },
93
94
  "observe" => {
@@ -159,6 +160,61 @@ module Bridge
159
160
  PROJECT_CONFIG_DEFAULTS.dup
160
161
  end
161
162
 
163
+ # --- Auto mode (intent 27) ---
164
+
165
+ # Arm auto mode for a session+intent. Works even when no bridge exists yet
166
+ # (mid-session intent creation). Re-derives intent state, then sets build.auto.
167
+ def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
168
+ data = derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
169
+ data["build"]["auto"] = true
170
+ write(session, data)
171
+ data
172
+ end
173
+
174
+ # Disarm auto mode. No-op if no bridge exists for the session.
175
+ def self.disarm_auto(session)
176
+ data = read(session)
177
+ return nil unless data
178
+ data["build"] ||= {}
179
+ data["build"]["auto"] = false
180
+ write(session, data)
181
+ data
182
+ end
183
+
184
+ # Decide whether a code edit should be blocked while auto mode is armed.
185
+ # Returns a reason string to BLOCK, or nil to ALLOW.
186
+ #
187
+ # Blocks iff: auto armed AND intent hasn't reached How (stage what/why) AND the
188
+ # target is project code — i.e. NOT under ~/.plastic and NOT inside the intent dir.
189
+ def self.code_gate_decision(bridge_data, file_path, home: Dir.home)
190
+ return nil unless bridge_data.is_a?(Hash)
191
+ build = bridge_data["build"] || {}
192
+ return nil unless build["auto"] == true
193
+
194
+ intent_info = bridge_data["intent"] || {}
195
+ store = intent_info["store"]
196
+ dir = intent_info["dir"]
197
+ return nil unless store && dir
198
+ intent_dir_abs = File.expand_path("#{store}/#{dir}")
199
+
200
+ # "How reached" = the plan triplet exists. Gate by artifact presence, not the
201
+ # stage label (derive_stage returns "how" as soon as spec.md exists, before any
202
+ # plan). Code edits stay blocked until plan.md + checklist.md are both present.
203
+ reached_how = File.exist?("#{intent_dir_abs}/plan.md") &&
204
+ File.exist?("#{intent_dir_abs}/checklist.md")
205
+ return nil if reached_how
206
+
207
+ file_abs = File.expand_path(file_path.to_s)
208
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
209
+ return nil if file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
210
+ return nil if file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/")
211
+
212
+ id = intent_info["id"]
213
+ "intent #{id} has not reached How — write plan.md + checklist.md before " \
214
+ "editing project code. Run plastic:auto or plastic:writing-plans first. " \
215
+ "(blocked edit: #{file_abs})"
216
+ end
217
+
162
218
  def self.deep_merge(base, overlay)
163
219
  result = base.dup
164
220
  overlay.each do |key, value|
@@ -16,6 +16,25 @@ An active intent MUST exist in INDEX.md. If none exists, refuse: "No active inte
16
16
 
17
17
  If multiple active intents exist, ask the user which one to deliver (this is the only question auto asks).
18
18
 
19
+ ## Arm the Lifecycle Gate (do this FIRST)
20
+
21
+ Immediately after selecting the intent — before any other work — arm auto mode. This
22
+ writes the session bridge that makes the code-edit gate live, so project code cannot be
23
+ edited before the plan exists (the gate applies to YOU, the orchestrator):
24
+
25
+ ```bash
26
+ ruby -r ~/.plastic/scripts/lib/bridge -e \
27
+ 'Bridge.arm_auto(ENV["CLAUDE_SESSION_ID"], intent_id: "<ID>", intent_dir: "<STORE>/<dir>", store: "<STORE>", name: "<name>")'
28
+ ```
29
+
30
+ Replace `<ID>`, `<STORE>` (e.g. `~/.plastic/projects/<slug>/store` or `~/.plastic/store`),
31
+ `<dir>` (the `ID--slug` directory), and `<name>`. If `CLAUDE_SESSION_ID` is unset, arming
32
+ is best-effort — proceed regardless; the gate simply won't engage.
33
+
34
+ **Hard rule for the rest of this run:** do NOT edit project code (anything outside the
35
+ intent directory / `~/.plastic/`) until `plan.md` AND `checklist.md` exist for the intent.
36
+ Honor the cycle: What → Why (spec.md) → How (plan.md + actions/ + checklist.md) → Exec.
37
+
19
38
  ## Flags
20
39
 
21
40
  - `--skip-permissions` — bypass hard stops on destructive actions on existing projects. Full trust mode. Default: off.
@@ -130,7 +149,11 @@ During initial project creation, all decisions are non-destructive by definition
130
149
  - Update `chain` in the current intent's frontmatter
131
150
  6. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
132
151
  7. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: deliver intent <ID> — <name>"`
133
- 8. Notify user: "Intent [ID] [name] delivered. [1-2 sentence summary]. See outcome.md for details."
152
+ 8. Disarm the lifecycle gate (auto delivery is finished):
153
+ ```bash
154
+ ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_SESSION_ID"])'
155
+ ```
156
+ 9. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
134
157
 
135
158
  ## Error Handling
136
159
 
@@ -0,0 +1,92 @@
1
+ {
2
+ "skill_name": "plastic:auto",
3
+ "notes": "Intent 27. Scopes: description triggering (1-8) and behavior/output quality (9). Assertions written after observing Step-3 runs (one clean subagent router per case).",
4
+ "results": {
5
+ "triggering": { "cases": 8, "passed": 8, "pass_at_1": 1.0, "run": "2026-06-10, one subagent per case" },
6
+ "behavior": { "cases": 1, "passed": 1, "evidence": "dogfood: intent 27 itself delivered via auto produced spec->plan->checklist before any code edit; code-gate unit test proves pre-How project-code edits are blocked" }
7
+ },
8
+ "evals": [
9
+ {
10
+ "id": 1, "scope": "triggering", "set": "train",
11
+ "prompt": "auto",
12
+ "expected_output": "Activates plastic:auto (the bare 'auto' keyword is the documented trigger).",
13
+ "files": [],
14
+ "assertions": [
15
+ { "type": "code", "check": "router CHOICE == plastic:auto", "observed": "plastic:auto", "result": "pass" }
16
+ ]
17
+ },
18
+ {
19
+ "id": 2, "scope": "triggering", "set": "train",
20
+ "prompt": "take it from here and deliver intent 27 end to end",
21
+ "expected_output": "Activates plastic:auto (autonomous delivery of an active intent).",
22
+ "files": [],
23
+ "assertions": [
24
+ { "type": "code", "check": "router CHOICE == plastic:auto", "observed": "plastic:auto", "result": "pass" }
25
+ ]
26
+ },
27
+ {
28
+ "id": 3, "scope": "triggering", "set": "validation",
29
+ "prompt": "go fully autonomous on the active intent, don't ask me questions",
30
+ "expected_output": "Activates plastic:auto.",
31
+ "files": [],
32
+ "assertions": [
33
+ { "type": "code", "check": "router CHOICE == plastic:auto", "observed": "plastic:auto", "result": "pass" }
34
+ ]
35
+ },
36
+ {
37
+ "id": 4, "scope": "triggering", "set": "train",
38
+ "prompt": "deliver this intent for me",
39
+ "expected_output": "Activates plastic:auto.",
40
+ "files": [],
41
+ "assertions": [
42
+ { "type": "code", "check": "router CHOICE == plastic:auto", "observed": "plastic:auto", "result": "pass" }
43
+ ]
44
+ },
45
+ {
46
+ "id": 5, "scope": "triggering", "set": "train",
47
+ "prompt": "set up a hook to automatically format the file on every save",
48
+ "expected_output": "Does NOT activate plastic:auto. Near-miss: shares 'auto*' but is a settings/hooks task (update-config).",
49
+ "files": [],
50
+ "assertions": [
51
+ { "type": "code", "check": "router CHOICE != plastic:auto", "observed": "update-config", "result": "pass" }
52
+ ]
53
+ },
54
+ {
55
+ "id": 6, "scope": "triggering", "set": "validation",
56
+ "prompt": "deliver the built package to the dist directory",
57
+ "expected_output": "Does NOT activate plastic:auto. Near-miss: shares 'deliver' but is a build/file task.",
58
+ "files": [],
59
+ "assertions": [
60
+ { "type": "code", "check": "router CHOICE != plastic:auto", "observed": "none", "result": "pass" }
61
+ ]
62
+ },
63
+ {
64
+ "id": 7, "scope": "triggering", "set": "train",
65
+ "prompt": "create a new intent for the dashboard idea",
66
+ "expected_output": "Does NOT activate plastic:auto; activates plastic:creating-intent.",
67
+ "files": [],
68
+ "assertions": [
69
+ { "type": "code", "check": "router CHOICE != plastic:auto", "observed": "plastic:creating-intent", "result": "pass" }
70
+ ]
71
+ },
72
+ {
73
+ "id": 8, "scope": "triggering", "set": "validation",
74
+ "prompt": "what's the status of my active intents?",
75
+ "expected_output": "Does NOT activate plastic:auto; this is a read/continuing/managing-index query.",
76
+ "files": [],
77
+ "assertions": [
78
+ { "type": "code", "check": "router CHOICE != plastic:auto", "observed": "plastic:managing-index", "result": "pass" }
79
+ ]
80
+ },
81
+ {
82
+ "id": 9, "scope": "behavior", "set": "train",
83
+ "prompt": "Active intent X exists with only a '## Intent' section. Deliver it in auto mode.",
84
+ "expected_output": "Arms the lifecycle gate first, then produces spec.md (Why), then plan.md + actions/ + checklist.md (How), and edits NO project code before plan.md + checklist.md exist. Disarms on completion.",
85
+ "files": [],
86
+ "assertions": [
87
+ { "type": "human", "check": "spec.md written before plan.md before any project-code edit", "observed": "dogfood run of intent 27 followed this order", "result": "pass" },
88
+ { "type": "code", "check": "code-gate blocks project-code Edit/Write while pre-How (test/code_gate_test.rb)", "observed": "test green", "result": "pass" }
89
+ ]
90
+ }
91
+ ]
92
+ }
@@ -60,6 +60,16 @@ IDs are scoped to the store they live in. Use the correct store path:
60
60
  "${CLAUDE_PLUGIN_ROOT}/scripts/folgezettel-id" "<STORE>" "<parent_id>"
61
61
  ```
62
62
 
63
+ **Branch vs root — decide before assigning the ID.** Having a "parent" in mind does
64
+ NOT automatically mean branch. Choose by meaning:
65
+
66
+ - **Branch (`14a`, `14b`)** — a sub-task, refinement, or direct continuation. It only
67
+ makes sense as part of the parent's work.
68
+ - **Root (`15`, `16`)** — an independent thought, even if inspired by another intent.
69
+ Capture the inspiration in `sources` (e.g., `sources: ["14"]`), not in the ID.
70
+ - **Rule of thumb:** if the intent could exist without its parent, make it a root and
71
+ set `sources`. Only branch when it genuinely cannot stand alone.
72
+
63
73
  ### 3. Determine Intent Properties
64
74
 
65
75
  Ask or infer from context: