@zalom/plastic 1.0.0-alpha.36 → 1.0.0-alpha.38
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/hooks/create-gate +3 -0
- package/hooks/hooks.json +10 -0
- package/package.json +1 -1
- package/scripts/dashboard.rb +11 -6
- package/scripts/doctor.rb +34 -0
- package/scripts/hook-create-gate +59 -0
- package/scripts/lib/bridge.rb +84 -13
- package/scripts/lib/installer_core.rb +47 -13
- package/scripts/lib/intent_validator.rb +72 -6
- package/scripts/new-intent +177 -0
- package/skills/auto/SKILL.md +2 -0
- package/skills/creating-intent/SKILL.md +38 -41
- package/skills/creating-intent/references/lifecycle.md +7 -8
package/hooks/hooks.json
CHANGED
|
@@ -40,6 +40,16 @@
|
|
|
40
40
|
}
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
|
+
{
|
|
44
|
+
"matcher": "Write",
|
|
45
|
+
"hooks": [
|
|
46
|
+
{
|
|
47
|
+
"type": "command",
|
|
48
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" create-gate",
|
|
49
|
+
"statusMessage": "Checking create gate..."
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
},
|
|
43
53
|
{
|
|
44
54
|
"matcher": "Bash",
|
|
45
55
|
"hooks": [
|
package/package.json
CHANGED
package/scripts/dashboard.rb
CHANGED
|
@@ -23,6 +23,7 @@ require "json"
|
|
|
23
23
|
require "yaml"
|
|
24
24
|
require "date"
|
|
25
25
|
require_relative "doctor"
|
|
26
|
+
require_relative "lib/bridge"
|
|
26
27
|
|
|
27
28
|
PLASTIC_HOME = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
|
|
28
29
|
|
|
@@ -113,10 +114,14 @@ def parse_intent(store_info, dir_name, status_index)
|
|
|
113
114
|
|
|
114
115
|
id = fm["id"].to_s
|
|
115
116
|
has = ->(f) { File.exist?(File.join(dir, f)) }
|
|
117
|
+
# Sentinel-aware presence for lifecycle files (intent 60b): a scaffolded
|
|
118
|
+
# placeholder spec/plan/checklist/outcome reads as absent, so a freshly
|
|
119
|
+
# scaffolded intent reports What/Why and is never marked completed/advanced.
|
|
120
|
+
real = ->(f) { Bridge.stage_file_present?(File.join(dir, f)) }
|
|
116
121
|
body = File.exist?(md) ? File.read(md) : ""
|
|
117
122
|
|
|
118
123
|
status =
|
|
119
|
-
if
|
|
124
|
+
if real.("outcome.md") then "completed"
|
|
120
125
|
elsif status_index[:active].include?(id) then "active"
|
|
121
126
|
elsif status_index[:abandoned].include?(id) then "abandoned"
|
|
122
127
|
elsif status_index[:completed].include?(id) then "completed"
|
|
@@ -134,12 +139,12 @@ def parse_intent(store_info, dir_name, status_index)
|
|
|
134
139
|
created: (fm["created"].to_s rescue ""),
|
|
135
140
|
value_field: fm["value"] && fm["value"].to_s,
|
|
136
141
|
status: status,
|
|
137
|
-
spec:
|
|
138
|
-
plan:
|
|
139
|
-
checklist:
|
|
140
|
-
outcome:
|
|
142
|
+
spec: real.("spec.md"),
|
|
143
|
+
plan: real.("plan.md"),
|
|
144
|
+
checklist: real.("checklist.md"),
|
|
145
|
+
outcome: real.("outcome.md"),
|
|
141
146
|
savepoint: has.("savepoint.md"),
|
|
142
|
-
checklist_partial:
|
|
147
|
+
checklist_partial: real.("checklist.md") && checklist_partially_done?(File.join(dir, "checklist.md")),
|
|
143
148
|
body_has_context: body.include?("## Context"),
|
|
144
149
|
last_accessed_at: last_accessed_at(dir, (fm["created"].to_s rescue "")),
|
|
145
150
|
}
|
package/scripts/doctor.rb
CHANGED
|
@@ -439,6 +439,40 @@ class Doctor
|
|
|
439
439
|
)
|
|
440
440
|
end
|
|
441
441
|
|
|
442
|
+
# section_structure — per-intent top-level `##` section set. Reuses
|
|
443
|
+
# IntentValidator::SANCTIONED_SECTIONS / validate_sections so the sanctioned
|
|
444
|
+
# set is defined in exactly one place (shared with the create gate and the
|
|
445
|
+
# validate-intent CLI). Read-only diagnostic: unknown or missing sections are
|
|
446
|
+
# not auto-fixable here.
|
|
447
|
+
bad_sections = []
|
|
448
|
+
intent_dirs.each do |d|
|
|
449
|
+
md_path = File.join(d[:path], "#{d[:name]}.md")
|
|
450
|
+
next unless File.exist?(md_path)
|
|
451
|
+
|
|
452
|
+
body = IntentValidator.body_of(File.read(md_path))
|
|
453
|
+
result = IntentValidator.validate_sections(body)
|
|
454
|
+
next if result[:ok]
|
|
455
|
+
|
|
456
|
+
issues = []
|
|
457
|
+
issues.concat(result[:unknown].map { |h| "unknown #{h}" })
|
|
458
|
+
issues.concat(result[:missing].map { |s| "missing #{s}" })
|
|
459
|
+
bad_sections << { dir: tilde(d[:path]), issues: issues }
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
if bad_sections.empty?
|
|
463
|
+
checks << check(
|
|
464
|
+
category: "conventions", name: "section_structure", status: "pass",
|
|
465
|
+
message: "All intent files have the sanctioned ## section structure"
|
|
466
|
+
)
|
|
467
|
+
else
|
|
468
|
+
checks << check(
|
|
469
|
+
category: "conventions", name: "section_structure", status: "warn",
|
|
470
|
+
message: "#{bad_sections.size} intent file(s) have non-sanctioned ## sections",
|
|
471
|
+
details: bad_sections.map { |b| "#{b[:dir]}: #{b[:issues].join(", ")}" },
|
|
472
|
+
fixable: false
|
|
473
|
+
)
|
|
474
|
+
end
|
|
475
|
+
|
|
442
476
|
checks
|
|
443
477
|
end
|
|
444
478
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# PreToolUse create gate (intent 60b): validate the PROPOSED content of a Write
|
|
6
|
+
# to an intent file before it lands. Fires when the target path is an intent file
|
|
7
|
+
# inside its own equally-named directory (store/<id>--<slug>/<id>--<slug>.md). It
|
|
8
|
+
# validates the proposed content from the hook payload (tool_input.content), NOT
|
|
9
|
+
# the on-disk file (which does not exist yet at PreToolUse), using IntentValidator
|
|
10
|
+
# for born-complete frontmatter plus the sanctioned section structure.
|
|
11
|
+
#
|
|
12
|
+
# It depends ONLY on the stdin path + content, never on the auto-bridge or
|
|
13
|
+
# CLAUDE_SESSION_ID, so it runs unconditionally (headless / background sessions).
|
|
14
|
+
# It validates ONLY the intent file, never sentinel placeholder lifecycle files
|
|
15
|
+
# (spec.md/plan.md/etc.); the path matcher excludes them.
|
|
16
|
+
#
|
|
17
|
+
# Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
|
|
18
|
+
#
|
|
19
|
+
# Reads the Claude Code PreToolUse payload as JSON on STDIN:
|
|
20
|
+
# { "tool_input": { "file_path": "...", "content": "..." } }
|
|
21
|
+
# Empty / unparseable / non-matching path => exit 0 (cannot judge, allow).
|
|
22
|
+
# Matching path but missing content => exit 2 (fail-safe: refuse to allow an
|
|
23
|
+
# unvalidated intent write).
|
|
24
|
+
|
|
25
|
+
require "json"
|
|
26
|
+
require_relative "lib/intent_validator"
|
|
27
|
+
|
|
28
|
+
raw = $stdin.read rescue nil
|
|
29
|
+
exit 0 if raw.nil? || raw.strip.empty?
|
|
30
|
+
|
|
31
|
+
payload = JSON.parse(raw) rescue nil
|
|
32
|
+
exit 0 unless payload.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
path = payload.dig("tool_input", "file_path") || payload.dig("tool_params", "file_path")
|
|
35
|
+
exit 0 if path.nil? || path.to_s.strip.empty?
|
|
36
|
+
|
|
37
|
+
# Precise path matcher: the target must be an intent file inside its own
|
|
38
|
+
# equally-named dir. Never matches sibling lifecycle files (spec.md, etc.) or
|
|
39
|
+
# unrelated store files.
|
|
40
|
+
abs = File.expand_path(path)
|
|
41
|
+
dir = File.dirname(abs)
|
|
42
|
+
is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
|
|
43
|
+
File.basename(abs) == "#{File.basename(dir)}.md"
|
|
44
|
+
exit 0 unless is_intent_file
|
|
45
|
+
|
|
46
|
+
content = payload.dig("tool_input", "content") || payload.dig("tool_params", "content")
|
|
47
|
+
if content.nil?
|
|
48
|
+
$stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed content; " \
|
|
49
|
+
"refusing to allow an unvalidated intent write."
|
|
50
|
+
exit 2
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
result = IntentValidator.validate_content(content)
|
|
54
|
+
exit 0 if result[:ok]
|
|
55
|
+
|
|
56
|
+
$stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
|
|
57
|
+
result[:errors].each { |e| $stderr.puts " #{e}" }
|
|
58
|
+
$stderr.puts "Create intents via new-intent / plastic-creating-intent; do not hand-author them."
|
|
59
|
+
exit 2
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -10,6 +10,22 @@ require "digest"
|
|
|
10
10
|
module Bridge
|
|
11
11
|
STAGES = %w[what why how exec done].freeze
|
|
12
12
|
|
|
13
|
+
# Placeholder sentinel (intent 60b). A scaffolded lifecycle file
|
|
14
|
+
# (spec.md/plan.md/checklist.md/outcome.md) carries this exact string as its
|
|
15
|
+
# first line until an agent fills the file and deletes the sentinel. The
|
|
16
|
+
# sentinel is the "stage not reached yet" marker, so stage detection treats a
|
|
17
|
+
# sentinel-marked file as absent (see stage_file_present?). The intent file
|
|
18
|
+
# (<id>--<slug>.md) is never sentineled; it is born complete.
|
|
19
|
+
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
20
|
+
|
|
21
|
+
# Stale-bridge purge window (intent 67). The bridge file is ephemeral
|
|
22
|
+
# live-session gate state, NOT a continuation source: an intent is resumed from
|
|
23
|
+
# its savepoint.md ledger, never from a /tmp bridge. So any bridge older than
|
|
24
|
+
# this window is dead weight and safe to purge, regardless of arm state. No
|
|
25
|
+
# real session stays live for two days, so a 48h cutoff never removes a bridge
|
|
26
|
+
# an active run depends on.
|
|
27
|
+
PURGE_AGE_SECONDS = 48 * 3600 # 48 hours
|
|
28
|
+
|
|
13
29
|
def self.intent_file(intent_dir)
|
|
14
30
|
dir_name = File.basename(intent_dir)
|
|
15
31
|
"#{intent_dir}/#{dir_name}.md"
|
|
@@ -106,6 +122,40 @@ module Bridge
|
|
|
106
122
|
pool.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
107
123
|
end
|
|
108
124
|
|
|
125
|
+
# --- Stale-bridge purge (intent 67) ---------------------------------------
|
|
126
|
+
#
|
|
127
|
+
# Remove stale tmp/plastic-*.json bridge files so discover_bridge's per-fire
|
|
128
|
+
# scan stays bounded. Best-effort and non-raising: returns the array of removed
|
|
129
|
+
# paths. Continuation does not depend on these files (an intent resumes from its
|
|
130
|
+
# savepoint.md ledger), so the only safety rule is age: a bridge older than
|
|
131
|
+
# max_age_seconds is purged regardless of arm state, while anything newer is kept
|
|
132
|
+
# (it may be a live run). The current session's own bridge is never purged
|
|
133
|
+
# (preserves the disarm_auto contract that it stays readable). Wired into
|
|
134
|
+
# arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
|
|
135
|
+
# clean.
|
|
136
|
+
def self.purge_stale_bridges(session:, now: Time.now, max_age_seconds: PURGE_AGE_SECONDS,
|
|
137
|
+
tmp: tmp_dir)
|
|
138
|
+
current = path(session, tmp: tmp)
|
|
139
|
+
removed = []
|
|
140
|
+
Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
|
|
141
|
+
next if f == current
|
|
142
|
+
begin
|
|
143
|
+
next if (now - File.mtime(f)) < max_age_seconds
|
|
144
|
+
File.delete(f)
|
|
145
|
+
removed << f
|
|
146
|
+
rescue Errno::ENOENT
|
|
147
|
+
# Raced with another job that already removed it; count as purged.
|
|
148
|
+
removed << f
|
|
149
|
+
rescue => e
|
|
150
|
+
$stderr.puts "plastic: purge skipped #{f}: #{e.message}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
removed
|
|
154
|
+
rescue => e
|
|
155
|
+
$stderr.puts "plastic: purge_stale_bridges failed: #{e.message}"
|
|
156
|
+
removed || []
|
|
157
|
+
end
|
|
158
|
+
|
|
109
159
|
def self.read(session, tmp: tmp_dir)
|
|
110
160
|
p = path(session, tmp: tmp)
|
|
111
161
|
return nil unless File.exist?(p)
|
|
@@ -126,14 +176,28 @@ module Bridge
|
|
|
126
176
|
raise e
|
|
127
177
|
end
|
|
128
178
|
|
|
179
|
+
# True iff a lifecycle file is PRESENT AND REAL: it exists and its first line is
|
|
180
|
+
# not the placeholder sentinel. Reads only the file head (never the whole file)
|
|
181
|
+
# so the dashboard stays fast across many intents. Exact first-line match only,
|
|
182
|
+
# so a real file that merely contains an HTML comment later is unaffected, and a
|
|
183
|
+
# partially-edited sentinel reads as real rather than sticking as a placeholder.
|
|
184
|
+
def self.stage_file_present?(path)
|
|
185
|
+
return false unless File.exist?(path)
|
|
186
|
+
first = File.open(path, &:gets)
|
|
187
|
+
return true if first.nil? # empty file: present, not a sentinel
|
|
188
|
+
first.chomp != PLACEHOLDER_SENTINEL
|
|
189
|
+
rescue StandardError
|
|
190
|
+
File.exist?(path)
|
|
191
|
+
end
|
|
192
|
+
|
|
129
193
|
def self.derive_stage(intent_dir)
|
|
130
|
-
return "done" if
|
|
131
|
-
if
|
|
194
|
+
return "done" if stage_file_present?("#{intent_dir}/outcome.md")
|
|
195
|
+
if stage_file_present?("#{intent_dir}/plan.md") &&
|
|
132
196
|
File.directory?("#{intent_dir}/actions") &&
|
|
133
|
-
|
|
197
|
+
stage_file_present?("#{intent_dir}/checklist.md")
|
|
134
198
|
return "exec"
|
|
135
199
|
end
|
|
136
|
-
return "how" if
|
|
200
|
+
return "how" if stage_file_present?("#{intent_dir}/spec.md")
|
|
137
201
|
return "why" if File.exist?(intent_file(intent_dir))
|
|
138
202
|
"what"
|
|
139
203
|
end
|
|
@@ -141,8 +205,9 @@ module Bridge
|
|
|
141
205
|
def self.has_files(intent_dir)
|
|
142
206
|
files = []
|
|
143
207
|
ifile = File.basename(intent_file(intent_dir))
|
|
144
|
-
|
|
145
|
-
|
|
208
|
+
files << ifile if File.exist?("#{intent_dir}/#{ifile}")
|
|
209
|
+
["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
210
|
+
files << f if stage_file_present?("#{intent_dir}/#{f}")
|
|
146
211
|
end
|
|
147
212
|
files << "actions/" if File.directory?("#{intent_dir}/actions")
|
|
148
213
|
files
|
|
@@ -194,8 +259,12 @@ module Bridge
|
|
|
194
259
|
# Append a milestone line for file_path if (and only if) it is a milestone
|
|
195
260
|
# not already recorded. Returns true when a line was written, false otherwise.
|
|
196
261
|
def self.append_savepoint(intent_dir, file_path, now: Time.now)
|
|
197
|
-
|
|
262
|
+
basename = File.basename(file_path)
|
|
263
|
+
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
198
264
|
return false unless milestone
|
|
265
|
+
# A sentinel-marked lifecycle file logs NO milestone (the stage is not real
|
|
266
|
+
# yet). The intent file is never sentineled, so it still logs its What line.
|
|
267
|
+
return false unless stage_file_present?(File.join(intent_dir, basename))
|
|
199
268
|
return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
|
|
200
269
|
|
|
201
270
|
line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
|
|
@@ -212,7 +281,7 @@ module Bridge
|
|
|
212
281
|
]
|
|
213
282
|
lines = ordered.filter_map do |basename|
|
|
214
283
|
path = File.join(intent_dir, basename)
|
|
215
|
-
next unless
|
|
284
|
+
next unless stage_file_present?(path)
|
|
216
285
|
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
217
286
|
next unless milestone
|
|
218
287
|
"#{File.mtime(path).utc.iso8601} #{stage} #{milestone}\n"
|
|
@@ -269,16 +338,16 @@ module Bridge
|
|
|
269
338
|
return "Cannot start Why — What is incomplete (#{File.basename(ifile)} missing or no ## Intent)"
|
|
270
339
|
end
|
|
271
340
|
when "plan.md"
|
|
272
|
-
unless
|
|
341
|
+
unless stage_file_present?("#{intent_dir}/spec.md")
|
|
273
342
|
return "Cannot start How — Why is incomplete (spec.md missing)"
|
|
274
343
|
end
|
|
275
344
|
when "checklist.md"
|
|
276
|
-
unless
|
|
345
|
+
unless stage_file_present?("#{intent_dir}/plan.md") && File.directory?("#{intent_dir}/actions")
|
|
277
346
|
return "Cannot complete How — plan.md or actions/ missing"
|
|
278
347
|
end
|
|
279
348
|
when "outcome.md"
|
|
280
349
|
checklist = "#{intent_dir}/checklist.md"
|
|
281
|
-
if
|
|
350
|
+
if stage_file_present?(checklist)
|
|
282
351
|
content = File.read(checklist)
|
|
283
352
|
unchecked = content.scan(/^- \[ \]/).length
|
|
284
353
|
if unchecked > 0
|
|
@@ -323,6 +392,7 @@ module Bridge
|
|
|
323
392
|
data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
|
|
324
393
|
data["build"]["auto"] = true
|
|
325
394
|
write(key, data)
|
|
395
|
+
purge_stale_bridges(session: key)
|
|
326
396
|
data
|
|
327
397
|
end
|
|
328
398
|
|
|
@@ -333,6 +403,7 @@ module Bridge
|
|
|
333
403
|
data["build"] ||= {}
|
|
334
404
|
data["build"]["auto"] = false
|
|
335
405
|
write(session, data)
|
|
406
|
+
purge_stale_bridges(session: session)
|
|
336
407
|
data
|
|
337
408
|
end
|
|
338
409
|
|
|
@@ -355,8 +426,8 @@ module Bridge
|
|
|
355
426
|
# "How reached" = the plan triplet exists. Gate by artifact presence, not the
|
|
356
427
|
# stage label (derive_stage returns "how" as soon as spec.md exists, before any
|
|
357
428
|
# plan). Code edits stay blocked until plan.md + checklist.md are both present.
|
|
358
|
-
reached_how =
|
|
359
|
-
|
|
429
|
+
reached_how = stage_file_present?("#{intent_dir_abs}/plan.md") &&
|
|
430
|
+
stage_file_present?("#{intent_dir_abs}/checklist.md")
|
|
360
431
|
return nil if reached_how
|
|
361
432
|
|
|
362
433
|
file_abs = File.expand_path(file_path.to_s)
|
|
@@ -166,11 +166,15 @@ class InstallerCore
|
|
|
166
166
|
|
|
167
167
|
FileUtils.mkdir_p(plastic_home)
|
|
168
168
|
FileUtils.mkdir_p(File.join(plastic_home, "scripts", "lib"))
|
|
169
|
+
FileUtils.mkdir_p(File.join(plastic_home, "templates"))
|
|
169
170
|
|
|
170
171
|
core_files.each do |src, dest|
|
|
171
172
|
src_path = File.join(package_root, src)
|
|
172
173
|
dest_path = File.join(plastic_home, dest)
|
|
173
|
-
|
|
174
|
+
next unless File.exist?(src_path)
|
|
175
|
+
|
|
176
|
+
FileUtils.mkdir_p(File.dirname(dest_path))
|
|
177
|
+
FileUtils.cp(src_path, dest_path)
|
|
174
178
|
end
|
|
175
179
|
|
|
176
180
|
File.write(File.join(plastic_home, "VERSION"), "#{version}\n")
|
|
@@ -210,6 +214,13 @@ class InstallerCore
|
|
|
210
214
|
"scripts/qmd-sync" => "scripts/qmd-sync",
|
|
211
215
|
"scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
|
|
212
216
|
"scripts/validate-intent" => "scripts/validate-intent",
|
|
217
|
+
"scripts/new-intent" => "scripts/new-intent",
|
|
218
|
+
"scripts/hook-create-gate" => "scripts/hook-create-gate",
|
|
219
|
+
"templates/intent.md" => "templates/intent.md",
|
|
220
|
+
"templates/spec.md" => "templates/spec.md",
|
|
221
|
+
"templates/plan.md" => "templates/plan.md",
|
|
222
|
+
"templates/checklist.md" => "templates/checklist.md",
|
|
223
|
+
"templates/outcome.md" => "templates/outcome.md",
|
|
213
224
|
"scripts/spawn-preamble" => "scripts/spawn-preamble",
|
|
214
225
|
"scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
|
|
215
226
|
"scripts/provision-project-store" => "scripts/provision-project-store",
|
|
@@ -527,12 +538,25 @@ class InstallerCore
|
|
|
527
538
|
{ "type" => "command", "command" => "#{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
|
|
528
539
|
],
|
|
529
540
|
},
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
541
|
+
# PreToolUse carries TWO plastic groups with distinct matchers: the
|
|
542
|
+
# code-gate (Write|Edit|NotebookEdit) and the create-gate (Write only, intent
|
|
543
|
+
# 60b). A single group cannot carry two matchers, so this event maps to a
|
|
544
|
+
# LIST of groups; the merge loop appends each (idempotent because the purge
|
|
545
|
+
# pass removes all prior plastic groups first).
|
|
546
|
+
"PreToolUse" => [
|
|
547
|
+
{
|
|
548
|
+
"matcher" => "Write|Edit|NotebookEdit",
|
|
549
|
+
"hooks" => [
|
|
550
|
+
{ "type" => "command", "command" => "#{hook_dir}/plastic-code-gate", "statusMessage" => "Checking lifecycle gate..." },
|
|
551
|
+
],
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
"matcher" => "Write",
|
|
555
|
+
"hooks" => [
|
|
556
|
+
{ "type" => "command", "command" => "#{hook_dir}/plastic-create-gate", "statusMessage" => "Checking create gate..." },
|
|
557
|
+
],
|
|
558
|
+
},
|
|
559
|
+
],
|
|
536
560
|
"PostToolUse" => {
|
|
537
561
|
"matcher" => "Write|Edit",
|
|
538
562
|
"hooks" => [
|
|
@@ -552,13 +576,23 @@ class InstallerCore
|
|
|
552
576
|
|
|
553
577
|
plastic_hooks.each do |event, group|
|
|
554
578
|
hooks[event] ||= []
|
|
555
|
-
existing = hooks[event].find { |g| g.is_a?(Hash) && g["hooks"].is_a?(Array) && g["hooks"].any? { |h| h["command"].to_s.include?("plastic-") } }
|
|
556
579
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
580
|
+
# An event may map to a LIST of plastic groups (PreToolUse carries the
|
|
581
|
+
# code-gate AND the create-gate). The purge pass above already removed all
|
|
582
|
+
# prior plastic groups, so appending each desired group fresh is idempotent
|
|
583
|
+
# across re-runs and never collapses two matchers into one group.
|
|
584
|
+
groups = group.is_a?(Array) ? group : [group]
|
|
585
|
+
groups.each do |g|
|
|
586
|
+
existing = hooks[event].find do |h|
|
|
587
|
+
h.is_a?(Hash) && h["matcher"] == g["matcher"] &&
|
|
588
|
+
h["hooks"].is_a?(Array) && h["hooks"].any? { |x| x["command"].to_s.include?("plastic-") }
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
if existing
|
|
592
|
+
existing["hooks"] = g["hooks"]
|
|
593
|
+
else
|
|
594
|
+
hooks[event] << g
|
|
595
|
+
end
|
|
562
596
|
end
|
|
563
597
|
end
|
|
564
598
|
|
|
@@ -27,6 +27,12 @@ module IntentValidator
|
|
|
27
27
|
# Fields whose value must be a well-formed array of valid id strings.
|
|
28
28
|
ARRAY_ID_FIELDS = %w[sources chain].freeze
|
|
29
29
|
|
|
30
|
+
# Sanctioned top-level intent sections, in order (intent 60b). The only
|
|
31
|
+
# sanctioned `###` subsection is `### Decisions`, which is OPTIONAL (added after
|
|
32
|
+
# brainstorming) and therefore never flagged as missing. This is the single
|
|
33
|
+
# definition shared by the create gate, the validate-intent CLI, and doctor.
|
|
34
|
+
SANCTIONED_SECTIONS = ["## Intent", "## Context", "## Outcome", "## Insights", "## Links"].freeze
|
|
35
|
+
|
|
30
36
|
# Folgezettel id form: digits then an optional lowercase-letter/digit suffix
|
|
31
37
|
# (for example "14", "14a", "4a1"). Mirrors scripts/folgezettel-id.
|
|
32
38
|
ID_PATTERN = /\A([a-z0-9-]+:)?\d+[a-z0-9]*\z/
|
|
@@ -42,8 +48,15 @@ module IntentValidator
|
|
|
42
48
|
def parse_frontmatter(path)
|
|
43
49
|
return nil unless File.exist?(path)
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
parse_frontmatter_text(File.read(path))
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# PURE: parse YAML frontmatter from a content STRING (no file IO). Returns the
|
|
57
|
+
# parsed Hash, {} for an empty block, or nil when there is no parseable block.
|
|
58
|
+
def parse_frontmatter_text(content)
|
|
59
|
+
return nil unless content.is_a?(String) && content.start_with?("---")
|
|
47
60
|
|
|
48
61
|
parts = content.split("---", 3)
|
|
49
62
|
return nil if parts.length < 3
|
|
@@ -53,6 +66,32 @@ module IntentValidator
|
|
|
53
66
|
nil
|
|
54
67
|
end
|
|
55
68
|
|
|
69
|
+
# PURE: strip the leading YAML frontmatter block from a content STRING,
|
|
70
|
+
# returning the body text (everything after the closing `---`). When there is
|
|
71
|
+
# no frontmatter block, the whole content is the body.
|
|
72
|
+
def body_of(content)
|
|
73
|
+
return "" unless content.is_a?(String)
|
|
74
|
+
return content unless content.start_with?("---")
|
|
75
|
+
|
|
76
|
+
parts = content.split("---", 3)
|
|
77
|
+
parts.length < 3 ? content : parts[2]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# PURE: given the intent file body text, return sanctioned-section findings.
|
|
81
|
+
# Flags any unknown top-level `## ` heading and any missing sanctioned section.
|
|
82
|
+
# Ignores `### ` subsections entirely (Decisions is optional and lives under
|
|
83
|
+
# Context). Returns { ok:, missing: [section names], unknown: [heading strings] }.
|
|
84
|
+
def validate_sections(body)
|
|
85
|
+
headings = body.to_s.lines.filter_map do |l|
|
|
86
|
+
s = l.strip
|
|
87
|
+
s if s.start_with?("## ") && !s.start_with?("### ")
|
|
88
|
+
end
|
|
89
|
+
present = headings & SANCTIONED_SECTIONS
|
|
90
|
+
missing = SANCTIONED_SECTIONS - present
|
|
91
|
+
unknown = headings - SANCTIONED_SECTIONS
|
|
92
|
+
{ ok: missing.empty? && unknown.empty?, missing: missing, unknown: unknown }
|
|
93
|
+
end
|
|
94
|
+
|
|
56
95
|
# PURE: given a parsed frontmatter Hash (or nil), return
|
|
57
96
|
# { ok: Boolean, missing: [field names], errors: [human strings] }.
|
|
58
97
|
def validate_frontmatter(fm)
|
|
@@ -80,11 +119,38 @@ module IntentValidator
|
|
|
80
119
|
{ ok: missing.empty? && errors.empty?, missing: missing, errors: errors }
|
|
81
120
|
end
|
|
82
121
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
122
|
+
# PURE: combine the frontmatter result with section-structure findings for a
|
|
123
|
+
# content STRING. Returns the frontmatter result hash extended with
|
|
124
|
+
# :section_missing, :section_unknown, and folded section errors; :ok is the AND
|
|
125
|
+
# of frontmatter and sections. Lets the create gate validate proposed content
|
|
126
|
+
# (no file on disk) with the same definition as the CLI and doctor.
|
|
127
|
+
def validate_content(content)
|
|
128
|
+
fm_result = validate_frontmatter(parse_frontmatter_text(content))
|
|
129
|
+
sections = validate_sections(body_of(content))
|
|
130
|
+
merge_sections(fm_result, sections)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Fold section findings into a frontmatter result hash (shared by validate and
|
|
134
|
+
# validate_content). Does not mutate the input.
|
|
135
|
+
def merge_sections(fm_result, sections)
|
|
136
|
+
errors = fm_result[:errors].dup
|
|
137
|
+
sections[:unknown].each { |h| errors << "unknown section: #{h}" }
|
|
138
|
+
sections[:missing].each { |s| errors << "missing required section: #{s}" }
|
|
139
|
+
{
|
|
140
|
+
ok: fm_result[:ok] && sections[:ok],
|
|
141
|
+
missing: fm_result[:missing],
|
|
142
|
+
errors: errors,
|
|
143
|
+
section_missing: sections[:missing],
|
|
144
|
+
section_unknown: sections[:unknown],
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Resolve an intent directory's primary md file and validate its frontmatter
|
|
149
|
+
# AND its sanctioned section structure. `plastic_home` is accepted for
|
|
150
|
+
# house-style parity (injectable) even though validation reads the dir directly.
|
|
86
151
|
def validate(intent_dir, plastic_home: File.join(Dir.home, ".plastic"))
|
|
87
152
|
md_path = File.join(intent_dir, "#{File.basename(intent_dir)}.md")
|
|
88
|
-
|
|
153
|
+
content = File.exist?(md_path) ? File.read(md_path) : nil
|
|
154
|
+
validate_content(content)
|
|
89
155
|
end
|
|
90
156
|
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# new-intent (intent 60b) - the one-call scaffolding contract.
|
|
6
|
+
#
|
|
7
|
+
# A single invocation scaffolds a COMPLETE intent: it allocates the Folgezettel
|
|
8
|
+
# id (root vs branch by --parent), creates the directory tree (<id>--<slug>/ plus
|
|
9
|
+
# actions/ and resources/), renders the born-complete intent file from
|
|
10
|
+
# templates/intent.md, writes sentinel placeholder lifecycle files (spec.md,
|
|
11
|
+
# plan.md, checklist.md, outcome.md, each carrying <!-- plastic:placeholder -->
|
|
12
|
+
# as its first line), wires reciprocal file links, and self-validates with
|
|
13
|
+
# IntentValidator (exit non-zero if not born complete).
|
|
14
|
+
#
|
|
15
|
+
# It does NOT touch INDEX.md, git, or project creation: those remain skill/agent
|
|
16
|
+
# responsibilities (see plastic-creating-intent).
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# new-intent --store <store_path> --intent "<one-line>" --slug <slug> \
|
|
20
|
+
# [--parent <id>] [--author <name>] [--sources id,id] \
|
|
21
|
+
# [--tags tag,tag] [--templates <dir>]
|
|
22
|
+
#
|
|
23
|
+
# Exit codes: 0 (born complete), 1 (scaffold failed self-validation or bad input).
|
|
24
|
+
|
|
25
|
+
require "fileutils"
|
|
26
|
+
require "date"
|
|
27
|
+
require_relative "lib/bridge"
|
|
28
|
+
require_relative "lib/intent_validator"
|
|
29
|
+
|
|
30
|
+
# --- Explicit flag parsing (no eval, no global injection) ------------------
|
|
31
|
+
|
|
32
|
+
def parse_args(argv)
|
|
33
|
+
opts = {
|
|
34
|
+
store: nil, intent: nil, slug: nil, parent: nil,
|
|
35
|
+
author: "claude-code", sources: [], tags: [], templates: nil
|
|
36
|
+
}
|
|
37
|
+
i = 0
|
|
38
|
+
while i < argv.length
|
|
39
|
+
arg = argv[i]
|
|
40
|
+
case arg
|
|
41
|
+
when "--store" then opts[:store] = argv[i += 1]
|
|
42
|
+
when "--intent" then opts[:intent] = argv[i += 1]
|
|
43
|
+
when "--slug" then opts[:slug] = argv[i += 1]
|
|
44
|
+
when "--parent" then opts[:parent] = argv[i += 1]
|
|
45
|
+
when "--author" then opts[:author] = argv[i += 1]
|
|
46
|
+
when "--sources" then opts[:sources] = split_list(argv[i += 1])
|
|
47
|
+
when "--tags" then opts[:tags] = split_list(argv[i += 1])
|
|
48
|
+
when "--templates" then opts[:templates] = argv[i += 1]
|
|
49
|
+
else
|
|
50
|
+
abort "new-intent: unknown argument #{arg.inspect}"
|
|
51
|
+
end
|
|
52
|
+
i += 1
|
|
53
|
+
end
|
|
54
|
+
opts
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def split_list(value)
|
|
58
|
+
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def expand(path)
|
|
62
|
+
File.expand_path(path.to_s.sub(/\A~/, Dir.home))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Default templates dir: sibling of this script's dir. Works in-repo
|
|
66
|
+
# (<repo>/scripts/new-intent -> <repo>/templates) and installed
|
|
67
|
+
# (~/.plastic/scripts/new-intent -> ~/.plastic/templates when present).
|
|
68
|
+
def default_templates_dir
|
|
69
|
+
File.expand_path("../templates", __dir__)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def render_tokens(text, tokens)
|
|
73
|
+
tokens.reduce(text) { |acc, (k, v)| acc.gsub("{{#{k}}}", v.to_s) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Append a wikilink line under the file's `## Links` section, idempotently.
|
|
77
|
+
def append_link(file_path, link_line)
|
|
78
|
+
return unless File.exist?(file_path)
|
|
79
|
+
content = File.read(file_path)
|
|
80
|
+
return if content.include?(link_line)
|
|
81
|
+
|
|
82
|
+
lines = content.lines
|
|
83
|
+
idx = lines.index { |l| l.strip == "## Links" }
|
|
84
|
+
return unless idx
|
|
85
|
+
|
|
86
|
+
insert_at = lines.length
|
|
87
|
+
((idx + 1)...lines.length).each do |j|
|
|
88
|
+
if lines[j].strip.start_with?("## ")
|
|
89
|
+
insert_at = j
|
|
90
|
+
break
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
lines.insert(insert_at, "#{link_line}\n")
|
|
94
|
+
File.write(file_path, lines.join)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def main(argv)
|
|
98
|
+
opts = parse_args(argv)
|
|
99
|
+
abort "new-intent: --store is required" if opts[:store].nil? || opts[:store].empty?
|
|
100
|
+
abort "new-intent: --intent is required" if opts[:intent].nil? || opts[:intent].empty?
|
|
101
|
+
abort "new-intent: --slug is required" if opts[:slug].nil? || opts[:slug].empty?
|
|
102
|
+
|
|
103
|
+
store = expand(opts[:store])
|
|
104
|
+
abort "new-intent: store dir does not exist: #{store}" unless Dir.exist?(store)
|
|
105
|
+
|
|
106
|
+
templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
|
|
107
|
+
abort "new-intent: templates dir not found: #{templates}" unless Dir.exist?(templates)
|
|
108
|
+
|
|
109
|
+
# 1. Allocate id via the existing folgezettel-id logic (root vs branch).
|
|
110
|
+
folg = File.expand_path("folgezettel-id", __dir__)
|
|
111
|
+
cmd = [folg, store]
|
|
112
|
+
cmd << opts[:parent] if opts[:parent] && !opts[:parent].empty?
|
|
113
|
+
id = `#{cmd.map { |c| "'#{c}'" }.join(" ")}`.strip
|
|
114
|
+
abort "new-intent: id allocation failed" if id.empty?
|
|
115
|
+
|
|
116
|
+
slug = opts[:slug]
|
|
117
|
+
intent_dir = File.join(store, "#{id}--#{slug}")
|
|
118
|
+
abort "new-intent: #{intent_dir} already exists" if File.exist?(intent_dir)
|
|
119
|
+
|
|
120
|
+
# 2. Create dirs.
|
|
121
|
+
FileUtils.mkdir_p(File.join(intent_dir, "actions"))
|
|
122
|
+
FileUtils.mkdir_p(File.join(intent_dir, "resources"))
|
|
123
|
+
|
|
124
|
+
# 3. Render the born-complete intent file from templates/intent.md.
|
|
125
|
+
sources = opts[:sources]
|
|
126
|
+
sources = sources | [opts[:parent]] if opts[:parent] && !opts[:parent].empty? && !sources.include?(opts[:parent])
|
|
127
|
+
sources_str = sources.map { |s| "\"#{s}\"" }.join(", ")
|
|
128
|
+
tags_str = opts[:tags].map { |t| "\"#{t}\"" }.join(", ")
|
|
129
|
+
|
|
130
|
+
intent_template = File.read(File.join(templates, "intent.md"))
|
|
131
|
+
intent_body = render_tokens(intent_template, {
|
|
132
|
+
"ID" => id,
|
|
133
|
+
"INTENT" => opts[:intent],
|
|
134
|
+
"SOURCES" => sources_str,
|
|
135
|
+
"DATE" => Date.today.iso8601,
|
|
136
|
+
"AUTHOR" => opts[:author],
|
|
137
|
+
"TAGS" => tags_str,
|
|
138
|
+
"DESCRIPTION" => opts[:intent],
|
|
139
|
+
})
|
|
140
|
+
intent_file = File.join(intent_dir, "#{id}--#{slug}.md")
|
|
141
|
+
File.write(intent_file, intent_body)
|
|
142
|
+
|
|
143
|
+
# 4. Reciprocal links: forward link to parent + back-reference in the parent.
|
|
144
|
+
if opts[:parent] && !opts[:parent].empty?
|
|
145
|
+
parent_id = opts[:parent]
|
|
146
|
+
append_link(intent_file, "- [[#{parent_id}]]")
|
|
147
|
+
parent_dir = Dir.glob(File.join(store, "#{parent_id}--*")).find { |d| File.directory?(d) }
|
|
148
|
+
if parent_dir
|
|
149
|
+
parent_file = File.join(parent_dir, "#{File.basename(parent_dir)}.md")
|
|
150
|
+
append_link(parent_file, "- [[#{id}]]")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# 5. Sentinel placeholders for each lifecycle file. The sentinel is the FIRST
|
|
155
|
+
# line; the rendered template body follows so the file is a usable starting
|
|
156
|
+
# point once an agent deletes the sentinel.
|
|
157
|
+
%w[spec.md plan.md checklist.md outcome.md].each do |name|
|
|
158
|
+
template_path = File.join(templates, name)
|
|
159
|
+
body = File.exist?(template_path) ? File.read(template_path) : ""
|
|
160
|
+
body = render_tokens(body, { "INTENT_NAME" => opts[:intent] })
|
|
161
|
+
File.write(File.join(intent_dir, name), "#{Bridge::PLACEHOLDER_SENTINEL}\n#{body}")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# 6. Self-validate (frontmatter + sanctioned sections).
|
|
165
|
+
result = IntentValidator.validate(intent_dir)
|
|
166
|
+
unless result[:ok]
|
|
167
|
+
warn "new-intent: scaffolded intent is NOT born complete:"
|
|
168
|
+
result[:missing].each { |f| warn " missing field: #{f}" }
|
|
169
|
+
result[:errors].each { |e| warn " #{e}" }
|
|
170
|
+
exit 1
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
puts intent_dir
|
|
174
|
+
exit 0
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
main(ARGV) if $PROGRAM_NAME == __FILE__
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -194,6 +194,8 @@ During initial project creation, all decisions are non-destructive by definition
|
|
|
194
194
|
```bash
|
|
195
195
|
ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_SESSION_ID"])'
|
|
196
196
|
```
|
|
197
|
+
Disarming also purges stale bridge files from the temp directory automatically (it keeps the
|
|
198
|
+
current bridge and any live run), so no manual `/tmp` cleanup is needed.
|
|
197
199
|
10. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
|
|
198
200
|
|
|
199
201
|
## Error Handling
|
|
@@ -41,63 +41,60 @@ When creating a tactical intent in a project store:
|
|
|
41
41
|
- **Global:** `~/.plastic/store/`
|
|
42
42
|
- **Project:** `~/.plastic/projects/{slug}/store/`
|
|
43
43
|
|
|
44
|
-
### 2.
|
|
44
|
+
### 2. Decide Branch vs Root
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Decide this BEFORE scaffolding, because it sets whether you pass `--parent`.
|
|
47
|
+
Having a "parent" in mind does NOT automatically mean branch. Choose by meaning:
|
|
47
48
|
|
|
48
|
-
**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
**Project store:**
|
|
54
|
-
```bash
|
|
55
|
-
"${CLAUDE_PLUGIN_ROOT}/scripts/folgezettel-id" "~/.plastic/projects/{slug}/store"
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
**Branch intent (has parent, either store):**
|
|
59
|
-
```bash
|
|
60
|
-
"${CLAUDE_PLUGIN_ROOT}/scripts/folgezettel-id" "<STORE>" "<parent_id>"
|
|
61
|
-
```
|
|
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.
|
|
49
|
+
- **Branch (`14a`, `14b`)**: a sub-task, refinement, or direct continuation. It only
|
|
50
|
+
makes sense as part of the parent's work. Pass `--parent <parent_id>`.
|
|
51
|
+
- **Root (`15`, `16`)**: an independent thought, even if inspired by another intent.
|
|
52
|
+
Capture the inspiration in `--sources`, not in the id. Omit `--parent`.
|
|
70
53
|
- **Rule of thumb:** if the intent could exist without its parent, make it a root and
|
|
71
|
-
set
|
|
54
|
+
set `--sources`. Only branch when it genuinely cannot stand alone.
|
|
72
55
|
|
|
73
56
|
### 3. Determine Intent Properties
|
|
74
57
|
|
|
75
58
|
Ask or infer from context:
|
|
76
59
|
- **intent**: one-line description
|
|
60
|
+
- **slug**: short hyphenated handle for the directory name
|
|
77
61
|
- **author**: `human` | `claude-code` | other agent name
|
|
78
|
-
- **sources**:
|
|
79
|
-
|
|
62
|
+
- **sources**: Folgezettel ids that influenced this intent (e.g., `4a1`). For a
|
|
63
|
+
project intent, include the governing intent's id.
|
|
80
64
|
- **tags**: freeform list (use `project-<name>` for project membership)
|
|
81
65
|
|
|
82
|
-
|
|
66
|
+
`chain` starts empty and is populated later when this intent spawns others.
|
|
67
|
+
Place the intent in `## Active` or `## Future` in INDEX.md (status is
|
|
68
|
+
convention-derived, not a frontmatter field).
|
|
83
69
|
|
|
84
|
-
### 4.
|
|
70
|
+
### 4. Scaffold via new-intent (single call)
|
|
71
|
+
|
|
72
|
+
Delegate id allocation, directory and file creation, the born-complete intent
|
|
73
|
+
file, the sentinel placeholder lifecycle files, the reciprocal file links, and
|
|
74
|
+
self-validation to one `new-intent` invocation. Do NOT hand-author any of these
|
|
75
|
+
files.
|
|
85
76
|
|
|
86
77
|
```bash
|
|
87
|
-
|
|
78
|
+
"${CLAUDE_PLUGIN_ROOT}/scripts/new-intent" \
|
|
79
|
+
--store "<STORE>" --intent "<one-line>" --slug "<slug>" \
|
|
80
|
+
[--parent "<parent_id>"] [--author "<author>"] \
|
|
81
|
+
[--sources "id,id"] [--tags "project-<slug>,tag"]
|
|
88
82
|
```
|
|
89
83
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
`new-intent` allocates the Folgezettel id (root, or a branch of `--parent`),
|
|
85
|
+
creates `<STORE>/<id>--<slug>/` plus `actions/` and `resources/`, renders the
|
|
86
|
+
born-complete `<id>--<slug>.md` from the intent template, writes the sentinel
|
|
87
|
+
placeholder `spec.md`/`plan.md`/`checklist.md`/`outcome.md` (each marked
|
|
88
|
+
`<!-- plastic:placeholder -->` so no stage detector reads them as reached), wires
|
|
89
|
+
the reciprocal `[[id]]` links, and self-validates (frontmatter plus the sanctioned
|
|
90
|
+
`##` sections). It prints the created directory path and exits 0.
|
|
93
91
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
```bash
|
|
97
|
-
"${CLAUDE_PLUGIN_ROOT}/scripts/validate-intent" "<STORE>/ID--slug"
|
|
98
|
-
```
|
|
92
|
+
It does NOT touch INDEX.md, git, or project creation: those stay in this skill
|
|
93
|
+
(steps 6 to 9 below).
|
|
99
94
|
|
|
100
|
-
If
|
|
95
|
+
If `new-intent` exits non-zero, read the stderr report and fix the inputs (slug,
|
|
96
|
+
intent, sources). Do not commit or announce an intent that did not scaffold
|
|
97
|
+
cleanly, and do not work around the failure by hand-writing the files.
|
|
101
98
|
|
|
102
99
|
### 6. If Implementation Intent Spawns a Project
|
|
103
100
|
|
|
@@ -138,12 +135,12 @@ Add to `## Active` (or `## Future`) and appropriate cluster.
|
|
|
138
135
|
### 8. Auto-commit
|
|
139
136
|
|
|
140
137
|
```bash
|
|
141
|
-
cd <store-root> && git add . && git commit -m "feat: create intent ID
|
|
138
|
+
cd <store-root> && git add . && git commit -m "feat: create intent ID - [name]"
|
|
142
139
|
```
|
|
143
140
|
|
|
144
141
|
### 9. Announce
|
|
145
142
|
|
|
146
|
-
"Created intent ID
|
|
143
|
+
"Created intent ID - [name]. Placed in: [Active|Future]. Store: [global|project:<slug>|local]."
|
|
147
144
|
|
|
148
145
|
## References
|
|
149
146
|
|
|
@@ -64,11 +64,10 @@ State is derived from what exists, not from what's declared.
|
|
|
64
64
|
## Creating an Intent — Full Steps
|
|
65
65
|
|
|
66
66
|
1. Determine the target store: `~/.plastic/store/` for global intents (default), `~/.plastic/projects/{slug}/store/` for project intents
|
|
67
|
-
2.
|
|
68
|
-
3.
|
|
69
|
-
4.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
A fleeting intent can skip `## Context` — just `## Intent` and empty sections.
|
|
67
|
+
2. Decide branch vs root (this sets whether you pass `--parent`)
|
|
68
|
+
3. Scaffold with one call: `"${CLAUDE_PLUGIN_ROOT}/scripts/new-intent" --store <store> --intent "<one-line>" --slug <slug> [--parent <id>] [--sources id,id] [--tags ...]`. This allocates the id, creates the directory plus `actions/` and `resources/`, renders the born-complete intent file (frontmatter plus `## Intent`, `## Context`, `## Outcome`, `## Insights`, `## Links`), writes the sentinel placeholder lifecycle files, wires the reciprocal links, and self-validates.
|
|
69
|
+
4. Update the appropriate `INDEX.md` — add to Active section and appropriate cluster
|
|
70
|
+
|
|
71
|
+
The intent file is born complete with all five sanctioned `##` sections; the lifecycle files (`spec.md`/`plan.md`/`checklist.md`/`outcome.md`) are sentinel placeholders that read as "stage not reached" until an agent fills them and deletes the `<!-- plastic:placeholder -->` first line.
|
|
72
|
+
|
|
73
|
+
Always scaffold through `new-intent` (or this skill). Never hand-author intent files: the write-time create gate blocks an incomplete or malformed intent file, and hand-authoring is the bypass this contract is designed to remove.
|