@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.
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
3
+ exec ruby "$SCRIPT_DIR/../scripts/hook-create-gate"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-alpha.37",
3
+ "version": "1.0.0-alpha.38",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 has.("outcome.md") then "completed"
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: has.("spec.md"),
138
- plan: has.("plan.md"),
139
- checklist: has.("checklist.md"),
140
- outcome: has.("outcome.md"),
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: has.("checklist.md") && checklist_partially_done?(File.join(dir, "checklist.md")),
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
@@ -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 File.exist?("#{intent_dir}/outcome.md")
173
- if File.exist?("#{intent_dir}/plan.md") &&
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
- File.exist?("#{intent_dir}/checklist.md")
197
+ stage_file_present?("#{intent_dir}/checklist.md")
176
198
  return "exec"
177
199
  end
178
- return "how" if File.exist?("#{intent_dir}/spec.md")
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
- [ifile, "spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
187
- files << f if File.exist?("#{intent_dir}/#{f}")
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
- stage, milestone = savepoint_milestone(intent_dir, File.basename(file_path))
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 File.exist?(path)
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 File.exist?("#{intent_dir}/spec.md")
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 File.exist?("#{intent_dir}/plan.md") && File.directory?("#{intent_dir}/actions")
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 File.exist?(checklist)
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 = File.exist?("#{intent_dir_abs}/plan.md") &&
403
- File.exist?("#{intent_dir_abs}/checklist.md")
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
- FileUtils.cp(src_path, dest_path) if File.exist?(src_path)
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
- "PreToolUse" => {
531
- "matcher" => "Write|Edit|NotebookEdit",
532
- "hooks" => [
533
- { "type" => "command", "command" => "#{hook_dir}/plastic-code-gate", "statusMessage" => "Checking lifecycle gate..." },
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
- if existing
558
- existing["matcher"] = group["matcher"]
559
- existing["hooks"] = group["hooks"]
560
- else
561
- hooks[event] << group
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
- content = File.read(path)
46
- return nil unless content.start_with?("---")
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
- # Resolve an intent directory's primary md file and validate its frontmatter.
84
- # `plastic_home` is accepted for house-style parity (injectable) even though
85
- # validation reads the intent dir directly.
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
- validate_frontmatter(parse_frontmatter(md_path))
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. Determine Folgezettel ID
44
+ ### 2. Decide Branch vs Root
45
45
 
46
- IDs are scoped to the store they live in. Use the correct store path:
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
- **Global store:**
49
- ```bash
50
- "${CLAUDE_PLUGIN_ROOT}/scripts/folgezettel-id" "~/.plastic/store"
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 `sources`. Only branch when it genuinely cannot stand alone.
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**: array of Folgezettel IDs that influenced this intent (e.g., `["4a1"]`)
79
- - **chain**: starts empty `[]`, populated when this intent spawns others
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
- Place in `## Active` or `## Future` in INDEX.md (status is convention-derived, not a frontmatter field).
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. Create Directory and Files
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
- mkdir -p <STORE>/ID--slug
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
- Write `{ID}--{slug}.md` using the intent template. For project intents, add the governing intent's ID to `sources` and add `[[global:ID]]` backlink in `## Links`.
91
-
92
- ### 5. Self-Verify the Written Intent
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
- Before announcing or committing, verify the just-written intent is born complete:
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 it exits non-zero, read the stderr report, inject the missing field(s) into the `{ID}--{slug}.md` frontmatter (for example add `chain: []`) WITHOUT disturbing other frontmatter keys, and re-run `validate-intent` until it exits 0. Only then proceed to the remaining steps (project spawn, INDEX.md, commit, announce).
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 [name]"
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 [name]. Placed in: [Active|Future]. Store: [global|project:<slug>|local]."
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. Determine the Folgezettel ID: If root (no parent), find highest root number +1. If branch, run `"${CLAUDE_PLUGIN_ROOT}/scripts/folgezettel-id" <parent_id> <store_path>`
68
- 3. Create the intent directory in the chosen store (e.g., `ID--three-to-five-words`)
69
- 4. Create `{ID}--{slug}.md` with frontmatter (id, intent, sources, chain, created, author, tags)
70
- 5. Write `## Intent` — the What
71
- 6. Add remaining sections: `## Context`, `## Outcome`, `## Insights`, `## Links`
72
- 7. Update the appropriate `INDEX.md` — add to Active section and appropriate cluster
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.