@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.
@@ -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.36",
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,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 File.exist?("#{intent_dir}/outcome.md")
131
- 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") &&
132
196
  File.directory?("#{intent_dir}/actions") &&
133
- File.exist?("#{intent_dir}/checklist.md")
197
+ stage_file_present?("#{intent_dir}/checklist.md")
134
198
  return "exec"
135
199
  end
136
- return "how" if File.exist?("#{intent_dir}/spec.md")
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
- [ifile, "spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
145
- 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}")
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
- stage, milestone = savepoint_milestone(intent_dir, File.basename(file_path))
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 File.exist?(path)
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 File.exist?("#{intent_dir}/spec.md")
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 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")
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 File.exist?(checklist)
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 = File.exist?("#{intent_dir_abs}/plan.md") &&
359
- 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")
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
- 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__
@@ -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. 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.