@zalom/plastic 1.0.0-alpha.34 → 1.0.0-alpha.36

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.
@@ -12,6 +12,8 @@ model: inherit
12
12
 
13
13
  You are the Plastic Brainstorming specialist. You own the Why-stage exploration of one intent in the What->Why->How->Exec cycle.
14
14
 
15
+ When dispatched in auto mode you receive the standard Plastic spawn preamble (from `scripts/spawn-preamble`) prepended to your prompt: it states the active intent id, intent line, current stage, your role, and the instruction to emit valid lifecycle artifacts. Honor it as your live state; do not re-derive or contradict it.
16
+
15
17
  ## Your Responsibilities
16
18
 
17
19
  1. **Explore the problem** — read the intent's `## Intent` and `## Context`, the linked intents, and the relevant code
@@ -12,6 +12,8 @@ model: inherit
12
12
 
13
13
  You are the Plastic Executor. You own the Exec stage of the What->Why->How->Exec cycle.
14
14
 
15
+ When dispatched in auto mode you receive the standard Plastic spawn preamble (from `scripts/spawn-preamble`) prepended to your prompt: it states the active intent id, intent line, current stage, your role, and the instruction to emit valid lifecycle artifacts. Honor it as your live state; do not re-derive or contradict it.
16
+
15
17
  ## Your Responsibilities
16
18
 
17
19
  1. **Implement the actions** — make the code changes for each action in order
@@ -12,6 +12,8 @@ model: inherit
12
12
 
13
13
  You are the Plastic Planner. You own the How stage of the What->Why->How->Exec cycle.
14
14
 
15
+ When dispatched in auto mode you receive the standard Plastic spawn preamble (from `scripts/spawn-preamble`) prepended to your prompt: it states the active intent id, intent line, current stage, your role, and the instruction to emit valid lifecycle artifacts. Honor it as your live state; do not re-derive or contradict it.
16
+
15
17
  ## Your Responsibilities
16
18
 
17
19
  1. **Decompose the spec** — break the approach into ordered, independent actions
@@ -12,6 +12,8 @@ model: inherit
12
12
 
13
13
  You are the Plastic Spec Specialist. You own the Why-to-How boundary in the What->Why->How->Exec cycle.
14
14
 
15
+ When dispatched in auto mode you receive the standard Plastic spawn preamble (from `scripts/spawn-preamble`) prepended to your prompt: it states the active intent id, intent line, current stage, your role, and the instruction to emit valid lifecycle artifacts. Honor it as your live state; do not re-derive or contradict it.
16
+
15
17
  ## Your Responsibilities
16
18
 
17
19
  1. **Consolidate the Why** — turn the enriched `## Context` and `### Decisions` into one spec
package/hooks/hooks.json CHANGED
@@ -93,6 +93,16 @@
93
93
  "statusMessage": "Checking auto mode..."
94
94
  }
95
95
  ]
96
+ },
97
+ {
98
+ "matcher": "",
99
+ "hooks": [
100
+ {
101
+ "type": "command",
102
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" qmd-search",
103
+ "statusMessage": "Searching QMD..."
104
+ }
105
+ ]
96
106
  }
97
107
  ]
98
108
  }
@@ -0,0 +1,8 @@
1
+ #!/bin/bash
2
+ # qmd-first search hook launcher (intent 66). 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-qmd-search" "$HOME/.plastic"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-alpha.34",
3
+ "version": "1.0.0-alpha.36",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.rb CHANGED
@@ -42,6 +42,7 @@ class Doctor
42
42
  plastic-gate-check
43
43
  plastic-continue
44
44
  plastic-future-intent-check
45
+ plastic-qmd-search
45
46
  ].freeze
46
47
 
47
48
  CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
@@ -8,6 +8,7 @@
8
8
 
9
9
  require "json"
10
10
  require_relative "lib/bridge"
11
+ require_relative "lib/intent_validator"
11
12
 
12
13
  file_path = ARGV[0]
13
14
  exit 0 unless file_path && !file_path.empty?
@@ -28,6 +29,24 @@ if intent_dir_abs
28
29
  end
29
30
  end
30
31
 
32
+ # --- Artifact-validity backstop (intent 4a1c1) ---
33
+ # When the written file IS the intent file itself (the `<id>--<slug>.md` directly
34
+ # inside `store/<id>--<slug>/`), run IntentValidator on it. This is headless-safe:
35
+ # it depends only on the file path and disk, never on a bridge or session. In the
36
+ # PostToolUse model the write has already happened, so we cannot prevent it; the
37
+ # loud non-zero exit + stderr is the rejection signal. NOT for spec.md/plan.md/
38
+ # checklist.md/outcome.md/savepoint.md — those are validated by the stage gates.
39
+ if intent_dir_abs && File.basename(file_path_abs) == File.basename(Bridge.intent_file(intent_dir_abs))
40
+ result = IntentValidator.validate(intent_dir_abs)
41
+ unless result[:ok]
42
+ warn "PLASTIC ARTIFACT INVALID — #{File.basename(file_path_abs)} is not born complete:"
43
+ result[:missing].each { |field| warn " missing required field: #{field}" }
44
+ result[:errors].each { |error| warn " #{error}" }
45
+ warn "Fix the frontmatter; the intent is not valid until every required field is present and sources/chain are well-formed."
46
+ exit 1
47
+ end
48
+ end
49
+
31
50
  # --- Find bridge file ---
32
51
  bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
33
52
 
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # qmd-first UserPromptSubmit hook (intent 66). Reads the prompt from stdin JSON,
6
+ # delegates the decision to QmdHook.run under a hard timeout, and prints
7
+ # hookSpecificOutput when there is something to inject/remind. Silent no-op (exit
8
+ # 0) when qmd is absent, the prompt is trivial, or anything goes wrong. ARGV[0]
9
+ # is the plastic_home (passed by the launcher); defaults to ~/.plastic.
10
+ require "json"
11
+ require "timeout"
12
+ require_relative "lib/qmd_hook"
13
+
14
+ raw = begin
15
+ STDIN.read
16
+ rescue StandardError
17
+ ""
18
+ end
19
+
20
+ prompt = begin
21
+ parsed = JSON.parse(raw)
22
+ parsed.is_a?(Hash) ? parsed["user_prompt"].to_s : ""
23
+ rescue StandardError
24
+ ""
25
+ end
26
+
27
+ plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
28
+
29
+ context = begin
30
+ Timeout.timeout(2) do
31
+ QmdHook.run(prompt: prompt, cwd: Dir.pwd, plastic_home: plastic_home)
32
+ end
33
+ rescue Exception
34
+ nil
35
+ end
36
+
37
+ exit 0 if context.nil? || context.strip.empty?
38
+
39
+ puts JSON.generate(
40
+ "hookSpecificOutput" => {
41
+ "hookEventName" => "UserPromptSubmit",
42
+ "additionalContext" => context,
43
+ }
44
+ )
@@ -199,6 +199,8 @@ class InstallerCore
199
199
  "scripts/hook-continue" => "scripts/hook-continue",
200
200
  "scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
201
201
  "scripts/hook-gate-check" => "scripts/hook-gate-check",
202
+ "scripts/hook-qmd-search" => "scripts/hook-qmd-search",
203
+ "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
202
204
  "scripts/hook-code-gate" => "scripts/hook-code-gate",
203
205
  "scripts/hook-bash-gate" => "scripts/hook-bash-gate",
204
206
  "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
@@ -208,6 +210,7 @@ class InstallerCore
208
210
  "scripts/qmd-sync" => "scripts/qmd-sync",
209
211
  "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
210
212
  "scripts/validate-intent" => "scripts/validate-intent",
213
+ "scripts/spawn-preamble" => "scripts/spawn-preamble",
211
214
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
212
215
  "scripts/provision-project-store" => "scripts/provision-project-store",
213
216
  "scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
@@ -542,6 +545,7 @@ class InstallerCore
542
545
  { "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
543
546
  { "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
544
547
  { "type" => "command", "command" => "#{hook_dir}/plastic-auto-arm", "statusMessage" => "Checking auto mode..." },
548
+ { "type" => "command", "command" => "#{hook_dir}/plastic-qmd-search", "statusMessage" => "Searching QMD..." },
545
549
  ],
546
550
  },
547
551
  }
@@ -0,0 +1,44 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "qmd_sync"
5
+
6
+ # QmdHook — decision logic for the qmd-first UserPromptSubmit hook (intent 66).
7
+ # Pure and dependency-injected: returns the additionalContext string to emit, or
8
+ # nil to emit nothing. The executable hook wires real deps and prints; this is
9
+ # unit-tested with a fake runner/detector (no real qmd, no network).
10
+ module QmdHook
11
+ module_function
12
+
13
+ MIN_PROMPT_LENGTH = 10
14
+ REMINDER = "qmd is available: query it (`qmd search` / `qmd query` over the " \
15
+ "`plastic-*` collections) before grep/Read when gathering intent " \
16
+ "context (sources/chain) or checking whether this work already " \
17
+ "exists as an intent."
18
+
19
+ def run(prompt:, cwd:, plastic_home:, runner: QmdSync.default_runner,
20
+ detector: QmdSync.method(:detect), limit: 3, min_score: 0.5)
21
+ return nil unless detector.call
22
+ p = prompt.to_s.strip
23
+ return nil if p.length < MIN_PROMPT_LENGTH
24
+ return nil if p.downcase == "continue"
25
+
26
+ collections = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
27
+ hits = QmdSync.search(p, collections: collections, limit: limit,
28
+ min_score: min_score, runner: runner, detector: detector)
29
+
30
+ parts = []
31
+ if hits.any?
32
+ parts << "Related / prior Plastic intents (qmd BM25, includes completed) — " \
33
+ "check before treating this as new work:"
34
+ hits.each do |h|
35
+ loc = h[:file].to_s.sub(%r{\Aqmd://}, "")
36
+ pct = (h[:score] * 100).round
37
+ parts << "- [#{pct}%] #{loc} — #{h[:title]}"
38
+ end
39
+ parts << ""
40
+ end
41
+ parts << REMINDER
42
+ parts.join("\n")
43
+ end
44
+ end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "yaml"
5
+ require "json"
5
6
 
6
7
  # QmdSync — the single place Plastic talks to QMD (intent 45a).
7
8
  #
@@ -111,6 +112,55 @@ module QmdSync
111
112
  missing: missing, all_registered: missing.empty? }
112
113
  end
113
114
 
115
+ # Read-only BM25 search over one or more collections. Returns hits sorted by
116
+ # score (desc), filtered by min_score, capped at limit:
117
+ # [{ score: Float, file: String, line: Integer|nil, title: String, snippet: String }, ...]
118
+ # No-ops to [] when qmd is absent or the query is blank. Uses `qmd search --json`
119
+ # (BM25, no embeddings / no model downloads). Pure via the injected runner.
120
+ def search(query, collections:, limit: 3, min_score: 0.5, runner: default_runner, detector: method(:detect))
121
+ return [] unless detector.call
122
+ q = query.to_s.strip
123
+ return [] if q.empty? || Array(collections).empty?
124
+
125
+ args = ["search", q]
126
+ Array(collections).each { |c| args.concat(["-c", c]) }
127
+ args << "--json"
128
+
129
+ out, ok = runner.call(args)
130
+ return [] unless ok && out && !out.strip.empty?
131
+
132
+ parsed = begin
133
+ JSON.parse(out)
134
+ rescue StandardError
135
+ return []
136
+ end
137
+ return [] unless parsed.is_a?(Array)
138
+
139
+ parsed.filter_map do |h|
140
+ next unless h.is_a?(Hash)
141
+ score = h["score"].to_f
142
+ next if score < min_score
143
+ { score: score, file: h["file"].to_s, line: h["line"],
144
+ title: h["title"].to_s, snippet: h["snippet"].to_s }
145
+ end.sort_by { |h| -h[:score] }.first(limit)
146
+ end
147
+
148
+ # Which collections to search for a given working directory: the matched
149
+ # project's collection plus plastic-global, or just plastic-global when the
150
+ # CWD is not inside any registered project. Project match = CWD equals the
151
+ # registered path or is nested under it.
152
+ def collections_for_cwd(cwd, plastic_home:)
153
+ cwd = File.expand_path(cwd)
154
+ projects = load_projects(plastic_home)
155
+ slug, = projects.find do |_s, info|
156
+ path = info.is_a?(Hash) ? info["path"] : nil
157
+ next false unless path
158
+ root = File.expand_path(path)
159
+ cwd == root || cwd.start_with?(root + File::SEPARATOR)
160
+ end
161
+ slug ? ["plastic-#{slug}", "plastic-global"] : ["plastic-global"]
162
+ end
163
+
114
164
  # --- internals ---
115
165
 
116
166
  def skip_result
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # spawn-preamble — deterministic live-state injection for spawned agents
6
+ # (intent 4a1c1, agent harness foundation).
7
+ #
8
+ # Emits a preamble block built ONLY from filesystem state for a single intent
9
+ # directory. It is a PURE function of the intent dir: no network, no randomness,
10
+ # no wall-clock reads. Two runs over the same on-disk state produce byte-identical
11
+ # output. This is the authoritative L2 (live-state) mechanism for harnesses whose
12
+ # spawned sub-agents do not inherit a top-level session event (see
13
+ # docs/reference/harness-adapters.md).
14
+ #
15
+ # Usage:
16
+ # spawn-preamble <intent_dir> [--role ROLE] [--step STEP]
17
+ #
18
+ # The preamble reports the active intent (id + intent line from frontmatter), the
19
+ # current lifecycle stage (last savepoint line if present, else derived from which
20
+ # lifecycle files exist), the cycle role/step (from --role/--step, else the
21
+ # derived stage), and an imperative honoring instruction.
22
+ #
23
+ # Exit codes: 0 (preamble emitted), 2 (usage).
24
+
25
+ require_relative "lib/bridge"
26
+
27
+ # Verbatim honoring instruction. Kept as one constant so the contract doc and the
28
+ # test assert against the exact same string.
29
+ HONOR_INSTRUCTION =
30
+ "You are operating inside Plastic. Use it as your operating scaffold. " \
31
+ "Emit VALID lifecycle artifacts; do not hallucinate intents or stages. " \
32
+ "Your output is a deliverable, not a message."
33
+
34
+ def parse_args(argv)
35
+ role = nil
36
+ step = nil
37
+ positional = []
38
+ i = 0
39
+ while i < argv.length
40
+ case argv[i]
41
+ when "--role"
42
+ role = argv[i + 1]
43
+ i += 2
44
+ when "--step"
45
+ step = argv[i + 1]
46
+ i += 2
47
+ else
48
+ positional << argv[i]
49
+ i += 1
50
+ end
51
+ end
52
+ [positional.first, role, step]
53
+ end
54
+
55
+ # Read the intent file frontmatter via the same parser the validator uses, so the
56
+ # id/intent we report match what the rest of Plastic sees. Returns a Hash (possibly
57
+ # empty) — never raises.
58
+ def frontmatter_for(intent_dir)
59
+ ifile = Bridge.intent_file(intent_dir)
60
+ return {} unless File.exist?(ifile)
61
+
62
+ content = File.read(ifile)
63
+ return {} unless content.start_with?("---")
64
+
65
+ parts = content.split("---", 3)
66
+ return {} if parts.length < 3
67
+
68
+ require "yaml"
69
+ require "date"
70
+ require "time"
71
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
72
+ rescue StandardError
73
+ {}
74
+ end
75
+
76
+ # Current stage label. Prefer the last non-empty line of savepoint.md (the ledger
77
+ # already encodes the furthest-reached milestone). Fall back to the file-derived
78
+ # stage when there is no ledger.
79
+ STAGE_LABELS = {
80
+ "what" => "What", "why" => "Why", "how" => "How",
81
+ "exec" => "Exec", "done" => "Done"
82
+ }.freeze
83
+
84
+ def current_stage(intent_dir)
85
+ ledger = File.join(intent_dir, Bridge::SAVEPOINT_FILE)
86
+ if File.exist?(ledger)
87
+ last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
88
+ return last if last
89
+ end
90
+ STAGE_LABELS.fetch(Bridge.derive_stage(intent_dir), Bridge.derive_stage(intent_dir))
91
+ end
92
+
93
+ intent_dir_arg, role, step = parse_args(ARGV)
94
+
95
+ if intent_dir_arg.nil? || intent_dir_arg.empty?
96
+ warn "usage: spawn-preamble <intent_dir> [--role ROLE] [--step STEP]"
97
+ exit 2
98
+ end
99
+
100
+ intent_dir = File.expand_path(intent_dir_arg)
101
+
102
+ fm = frontmatter_for(intent_dir)
103
+ id = fm["id"].to_s.strip
104
+ intent_name = fm["intent"].to_s.strip
105
+ id = "(unknown)" if id.empty?
106
+ intent_name = "(unknown)" if intent_name.empty?
107
+
108
+ stage = current_stage(intent_dir)
109
+ cycle = role || step || stage
110
+
111
+ lines = []
112
+ lines << "=== Plastic spawn preamble ==="
113
+ lines << "Store: #{intent_dir}"
114
+ lines << "Active intent: #{id} - #{intent_name}"
115
+ lines << "Current stage: #{stage}"
116
+ lines << "Cycle step / role: #{cycle}"
117
+ lines << ""
118
+ lines << HONOR_INSTRUCTION
119
+ lines << "=== end preamble ==="
120
+
121
+ puts lines.join("\n")
@@ -66,6 +66,8 @@ Roster (one role per cycle stage):
66
66
 
67
67
  Dispatch rule: sequential, one specialist per stage on one branch (the deliverables share files). Gate each deliverable against the stage's exit criteria before handing off. The How and Exec phases below default to Plastic's native dispatch (`plastic-executing-plan`) and delegate to the superpowers skills only when they are available or the user asks; do not restate the phase mechanics here.
68
68
 
69
+ Spawn preamble (live-state injection): before dispatching any specialist, run `scripts/spawn-preamble <intent_dir> --role <role>` and PREPEND its output to that specialist's prompt. The preamble is a deterministic, filesystem-only snapshot of the active intent (id, intent line, current stage) plus the honoring instruction, so every spawned agent boots with accurate live state instead of guessing. This is the authoritative L2 mechanism for harnesses whose sub-agents do not inherit a top-level session event (see `docs/reference/harness-adapters.md`).
70
+
69
71
  Final-gate review: dispatch an independent reviewer subagent at the final gate only, not as a standing role.
70
72
 
71
73
  Headless manual gate: when running headless or in the background, enforce gates manually and do not rely on hooks, because `CLAUDE_SESSION_ID` may be unset (this ties to the arm-gate fallback above).
@@ -48,6 +48,19 @@ The chain: intent `## Intent` / `## Context`, then enriched `## Context` plus
48
48
  `### Decisions`, then `spec.md`, then `plan.md` plus `actions/` plus `checklist.md`,
49
49
  then the code changes plus a checked-off checklist plus `## Insights`.
50
50
 
51
+ ### Spawn Preamble (L2 live-state injection)
52
+
53
+ Every dispatched specialist is booted with a spawn preamble: the enforcer runs
54
+ `scripts/spawn-preamble <intent_dir> --role <role>` and prepends its output to the
55
+ specialist's prompt. The preamble is a pure function of the intent directory on disk
56
+ (no network, no clock, no randomness), so it is deterministic and rebuildable. It
57
+ carries the active intent id and intent line, the current lifecycle stage (the last
58
+ savepoint line, else stage derived from which lifecycle files exist), the cycle
59
+ role, and the honoring instruction that the agent must emit valid lifecycle artifacts
60
+ and not hallucinate intents or stages. This is the standard L2 live-state mechanism
61
+ for harnesses whose spawned sub-agents do not inherit the top-level session event. See
62
+ `docs/reference/harness-adapters.md` for how it slots into the per-harness contract.
63
+
51
64
  ### Gate Ownership
52
65
 
53
66
  The enforcer arms and verifies the lifecycle gate, then gates every stage transition.