@zalom/plastic 1.0.0-alpha.37 → 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 +40 -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/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,14 @@ 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
|
+
|
|
13
21
|
# Stale-bridge purge window (intent 67). The bridge file is ephemeral
|
|
14
22
|
# live-session gate state, NOT a continuation source: an intent is resumed from
|
|
15
23
|
# its savepoint.md ledger, never from a /tmp bridge. So any bridge older than
|
|
@@ -168,14 +176,28 @@ module Bridge
|
|
|
168
176
|
raise e
|
|
169
177
|
end
|
|
170
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
|
+
|
|
171
193
|
def self.derive_stage(intent_dir)
|
|
172
|
-
return "done" if
|
|
173
|
-
if
|
|
194
|
+
return "done" if stage_file_present?("#{intent_dir}/outcome.md")
|
|
195
|
+
if stage_file_present?("#{intent_dir}/plan.md") &&
|
|
174
196
|
File.directory?("#{intent_dir}/actions") &&
|
|
175
|
-
|
|
197
|
+
stage_file_present?("#{intent_dir}/checklist.md")
|
|
176
198
|
return "exec"
|
|
177
199
|
end
|
|
178
|
-
return "how" if
|
|
200
|
+
return "how" if stage_file_present?("#{intent_dir}/spec.md")
|
|
179
201
|
return "why" if File.exist?(intent_file(intent_dir))
|
|
180
202
|
"what"
|
|
181
203
|
end
|
|
@@ -183,8 +205,9 @@ module Bridge
|
|
|
183
205
|
def self.has_files(intent_dir)
|
|
184
206
|
files = []
|
|
185
207
|
ifile = File.basename(intent_file(intent_dir))
|
|
186
|
-
|
|
187
|
-
|
|
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}")
|
|
188
211
|
end
|
|
189
212
|
files << "actions/" if File.directory?("#{intent_dir}/actions")
|
|
190
213
|
files
|
|
@@ -236,8 +259,12 @@ module Bridge
|
|
|
236
259
|
# Append a milestone line for file_path if (and only if) it is a milestone
|
|
237
260
|
# not already recorded. Returns true when a line was written, false otherwise.
|
|
238
261
|
def self.append_savepoint(intent_dir, file_path, now: Time.now)
|
|
239
|
-
|
|
262
|
+
basename = File.basename(file_path)
|
|
263
|
+
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
240
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))
|
|
241
268
|
return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
|
|
242
269
|
|
|
243
270
|
line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
|
|
@@ -254,7 +281,7 @@ module Bridge
|
|
|
254
281
|
]
|
|
255
282
|
lines = ordered.filter_map do |basename|
|
|
256
283
|
path = File.join(intent_dir, basename)
|
|
257
|
-
next unless
|
|
284
|
+
next unless stage_file_present?(path)
|
|
258
285
|
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
259
286
|
next unless milestone
|
|
260
287
|
"#{File.mtime(path).utc.iso8601} #{stage} #{milestone}\n"
|
|
@@ -311,16 +338,16 @@ module Bridge
|
|
|
311
338
|
return "Cannot start Why — What is incomplete (#{File.basename(ifile)} missing or no ## Intent)"
|
|
312
339
|
end
|
|
313
340
|
when "plan.md"
|
|
314
|
-
unless
|
|
341
|
+
unless stage_file_present?("#{intent_dir}/spec.md")
|
|
315
342
|
return "Cannot start How — Why is incomplete (spec.md missing)"
|
|
316
343
|
end
|
|
317
344
|
when "checklist.md"
|
|
318
|
-
unless
|
|
345
|
+
unless stage_file_present?("#{intent_dir}/plan.md") && File.directory?("#{intent_dir}/actions")
|
|
319
346
|
return "Cannot complete How — plan.md or actions/ missing"
|
|
320
347
|
end
|
|
321
348
|
when "outcome.md"
|
|
322
349
|
checklist = "#{intent_dir}/checklist.md"
|
|
323
|
-
if
|
|
350
|
+
if stage_file_present?(checklist)
|
|
324
351
|
content = File.read(checklist)
|
|
325
352
|
unchecked = content.scan(/^- \[ \]/).length
|
|
326
353
|
if unchecked > 0
|
|
@@ -399,8 +426,8 @@ module Bridge
|
|
|
399
426
|
# "How reached" = the plan triplet exists. Gate by artifact presence, not the
|
|
400
427
|
# stage label (derive_stage returns "how" as soon as spec.md exists, before any
|
|
401
428
|
# plan). Code edits stay blocked until plan.md + checklist.md are both present.
|
|
402
|
-
reached_how =
|
|
403
|
-
|
|
429
|
+
reached_how = stage_file_present?("#{intent_dir_abs}/plan.md") &&
|
|
430
|
+
stage_file_present?("#{intent_dir_abs}/checklist.md")
|
|
404
431
|
return nil if reached_how
|
|
405
432
|
|
|
406
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__
|
|
@@ -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.
|