@zalom/plastic 1.9.0 → 1.10.0
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/PLASTIC.md +23 -8
- package/README.md +15 -4
- package/agents/plastic-enforcer.md +3 -2
- package/agents/plastic-intent-discovery.md +7 -0
- package/bin/plastic.js +17 -8
- package/hooks/auto-arm +2 -2
- package/hooks/bash-gate +1 -1
- package/hooks/check-update +1 -1
- package/hooks/continue +2 -2
- package/hooks/edit-gates +1 -1
- package/hooks/future-intent-check +2 -2
- package/hooks/gate-check +3 -3
- package/hooks/power-tools +1 -1
- package/hooks/session-start +1 -1
- package/package.json +1 -1
- package/scripts/codex-hook +50 -106
- package/scripts/doctor.rb +132 -1080
- package/scripts/exec-worktree +103 -0
- package/scripts/hash-intent +1 -1
- package/scripts/hook-bash-gate +19 -0
- package/scripts/hook-code-gate +4 -1
- package/scripts/hook-continue +2 -2
- package/scripts/hook-create-gate +6 -3
- package/scripts/hook-gate-check +17 -0
- package/scripts/hook-links-gate +4 -1
- package/scripts/hook-lock-gate +7 -3
- package/scripts/hook-savepoint-pre +4 -1
- package/scripts/hook-session-start +21 -15
- package/scripts/lib/apply_patch_envelope.rb +46 -13
- package/scripts/lib/bridge.rb +83 -15
- package/scripts/lib/codex_edit_gates.rb +138 -0
- package/scripts/lib/doctor_core.rb +1087 -0
- package/scripts/lib/edit_gates.rb +61 -5
- package/scripts/lib/exec_worktree.rb +325 -0
- package/scripts/lib/harness_text.rb +57 -0
- package/scripts/lib/hook_registry.rb +32 -28
- package/scripts/lib/installer_core.rb +67 -7
- package/scripts/lib/lock.rb +196 -47
- package/scripts/lib/ruby_probe.rb +60 -0
- package/scripts/lib/scaffold_intent.rb +392 -0
- package/scripts/lib/spec_header.rb +83 -0
- package/scripts/lib/start_intent.rb +296 -0
- package/scripts/lib/verify_intent.rb +262 -0
- package/scripts/lib/worktree.rb +15 -1
- package/scripts/link-suggest +1 -1
- package/scripts/maintenance-run +5 -5
- package/scripts/migrate-to-global +2 -2
- package/scripts/restore-intent-v1 +1 -1
- package/scripts/scaffold-intent +120 -0
- package/scripts/start-intent +89 -0
- package/scripts/verify-intent +73 -0
- package/skills/agent-advisor/SKILL.md +5 -5
- package/skills/auto/SKILL.md +42 -32
- package/skills/auto/references/agent-architecture.md +1 -1
- package/skills/auto/references/agent-report-contract.md +1 -1
- package/skills/auto/references/human-report-contract.md +22 -3
- package/skills/auto/references/tiers.md +24 -2
- package/skills/conventions/references/completion-and-done.md +3 -0
- package/skills/conventions/references/gates-and-enforcement.md +28 -12
- package/skills/conventions/references/locks-and-worktrees.md +3 -3
- package/skills/conventions/references/tiers-and-dispatch.md +7 -6
- package/skills/dashboard/SKILL.md +1 -1
- package/skills/doctor/SKILL.md +6 -5
- package/skills/doctor/references/gates-stuck-detection.md +13 -8
- package/skills/doctor/report.md +1 -1
- package/skills/install/SKILL.md +1 -1
- package/skills/intent-brainstorming/SKILL.md +0 -2
- package/skills/intent-creating/SKILL.md +6 -6
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-discovering/SKILL.md +10 -3
- package/skills/intent-ending/SKILL.md +8 -7
- package/skills/intent-executing/SKILL.md +27 -19
- package/skills/intent-grilling/SKILL.md +5 -3
- package/skills/intent-planning/SKILL.md +7 -3
- package/skills/intent-researching/SKILL.md +0 -2
- package/skills/intent-starting/SKILL.md +10 -2
- package/skills/project-creating/SKILL.md +0 -2
- package/skills/project-creating/references/project-scaffolding.md +1 -1
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/promotion-and-tagging.md +14 -8
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/skill-creating/SKILL.md +5 -2
- package/skills/store-indexing/SKILL.md +8 -5
- package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
- package/skills/tutorial/references/track-1-guided.md +2 -2
- package/skills/tutorial/references/track-2-auto.md +9 -6
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
- package/skills/uninstall/SKILL.md +6 -9
- package/templates/agents.md +12 -12
- package/templates/config.yml +6 -7
- package/templates/index.md +6 -3
- package/templates/spec.md +1 -1
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "worktree"
|
|
6
|
+
require_relative "bridge"
|
|
7
|
+
require_relative "spec_header"
|
|
8
|
+
require_relative "intent_validator"
|
|
9
|
+
|
|
10
|
+
# ScaffoldIntent - all logic for `scripts/scaffold-intent` (intent 213). Three
|
|
11
|
+
# subcommands (spec|checklist|outcome), each writes one lifecycle artifact by copying or
|
|
12
|
+
# mechanically deriving it from an already-committed source: the intent file's
|
|
13
|
+
# `### Decisions` list, spec.md's `## Acceptance Criteria` list, or a `git diff --stat`
|
|
14
|
+
# plus an optional supplied test-summary file. No subcommand interprets or invents prose;
|
|
15
|
+
# a field this module cannot derive mechanically is left as the template's own stub text
|
|
16
|
+
# instead of being guessed at.
|
|
17
|
+
#
|
|
18
|
+
# `scaffold-intent` does not scaffold `actions/` in any form (intent 133a, D11): actions
|
|
19
|
+
# require judgment and stay entirely with the planner. No method here creates the
|
|
20
|
+
# directory, writes a `.gitkeep`, or writes a sentinel-only `ACTION_*.md`.
|
|
21
|
+
#
|
|
22
|
+
# Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` or
|
|
23
|
+
# `ENV` directly (only via the ambient `Dir.home` default, matching Bridge/Worktree
|
|
24
|
+
# convention). A git seam is injected as `runner:`, defaulting to
|
|
25
|
+
# `Worktree::ShellRunner.new`, so tests drive this in process with a fake runner and
|
|
26
|
+
# never touch real git. Every method returns a value; `scripts/scaffold-intent` maps the
|
|
27
|
+
# returned result to an exit code.
|
|
28
|
+
module ScaffoldIntent
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
SPEC_SECTIONS = [
|
|
32
|
+
"## Problem", "## Goals", "## Non-Goals", "## Approach",
|
|
33
|
+
"## Alternatives Considered", "## Decisions",
|
|
34
|
+
"## Acceptance Criteria", "## Open Questions",
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
SETTLED_PLACEHOLDER_COMMENT =
|
|
38
|
+
"<!-- Settled: yes (<reason>) optional, add this line only when the design is settled -->\n"
|
|
39
|
+
|
|
40
|
+
# --- path resolution (pure) --------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def expand(path)
|
|
43
|
+
File.expand_path(path.to_s.sub(/\A~/, Dir.home))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolve the single "<store>/<id>--*" directory. Returns [dir, nil] on success, or
|
|
47
|
+
# [nil, message] on a usage failure (no match, or more than one match).
|
|
48
|
+
def resolve_intent_dir(store, id)
|
|
49
|
+
matches = Dir.glob(File.join(store, "#{id}--*")).select { |d| File.directory?(d) }
|
|
50
|
+
return [nil, "no intent directory matches #{id}--* under #{store}"] if matches.empty?
|
|
51
|
+
if matches.length > 1
|
|
52
|
+
return [nil, "ambiguous id #{id.inspect}: #{matches.length} matching directories under #{store}"]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
[matches.first, nil]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The templates dir, resolved the same way from this file's own directory
|
|
59
|
+
# (scripts/lib) as `scripts/scaffold-intent` resolves it from its own directory
|
|
60
|
+
# (scripts/): two levels up. A repo checkout has templates/ at the repo root; an
|
|
61
|
+
# installed copy has it at <plastic_home>/templates, which is also two levels up
|
|
62
|
+
# from <plastic_home>/scripts/lib. The explicit `<plastic_home>/templates` fallback
|
|
63
|
+
# covers the case where that computed path does not exist. Returns nil when neither
|
|
64
|
+
# exists.
|
|
65
|
+
def resolve_templates_dir(home: Dir.home)
|
|
66
|
+
primary = File.expand_path("../../templates", __dir__)
|
|
67
|
+
return primary if Dir.exist?(primary)
|
|
68
|
+
|
|
69
|
+
fallback = File.expand_path(File.join(home, ".plastic", "templates"))
|
|
70
|
+
return fallback if Dir.exist?(fallback)
|
|
71
|
+
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def templates_missing_message(home: Dir.home)
|
|
76
|
+
primary = File.expand_path("../../templates", __dir__)
|
|
77
|
+
fallback = File.expand_path(File.join(home, ".plastic", "templates"))
|
|
78
|
+
"no templates directory found; tried #{primary} and #{fallback}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# --- result builders (pure) ---------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def ok_result(path)
|
|
84
|
+
{ status: :ok, code: 0, message: nil, path: path }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def refuse_result(path)
|
|
88
|
+
{ status: :refused, code: 2, path: path,
|
|
89
|
+
message: "#{path} already has real content; pass --force to overwrite it deliberately" }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def error_result(message)
|
|
93
|
+
{ status: :error, code: 3, message: message, path: nil }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# True iff `target` exists, carries real (non-sentinel) content, and `force` was not
|
|
97
|
+
# passed: the caller must refuse to write and leave the file untouched.
|
|
98
|
+
def refuse_without_force?(target, force)
|
|
99
|
+
File.exist?(target) && Bridge.stage_file_present?(target) && !force
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# --- generic section helpers (pure) --------------------------------------------
|
|
103
|
+
|
|
104
|
+
# Split `text` into { "## Heading" => body_text } by top-level `## ` headings (never
|
|
105
|
+
# `### `). Each body runs from the line after its heading to the line before the next
|
|
106
|
+
# `## ` heading (or EOF), copied verbatim including any trailing blank line.
|
|
107
|
+
def sections_from(text)
|
|
108
|
+
sections = {}
|
|
109
|
+
current = nil
|
|
110
|
+
buf = []
|
|
111
|
+
text.each_line do |line|
|
|
112
|
+
if line.start_with?("## ")
|
|
113
|
+
sections[current] = buf.join if current
|
|
114
|
+
current = line.rstrip
|
|
115
|
+
buf = []
|
|
116
|
+
elsif current
|
|
117
|
+
buf << line
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
sections[current] = buf.join if current
|
|
121
|
+
sections
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Replace the body of `heading` in `text` with `new_body_lines` (an Array of String
|
|
125
|
+
# fragments), leaving every other line untouched. A no-op (returns `text` unchanged)
|
|
126
|
+
# when `heading` is not found.
|
|
127
|
+
def replace_section_body(text, heading, new_body_lines)
|
|
128
|
+
lines = text.lines
|
|
129
|
+
idx = lines.index { |l| l.rstrip == heading }
|
|
130
|
+
return text if idx.nil?
|
|
131
|
+
|
|
132
|
+
stop = idx + 1
|
|
133
|
+
stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
|
|
134
|
+
(lines[0..idx] + new_body_lines + lines[stop..]).join
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# --- `### Decisions` extraction from the intent file (pure) --------------------
|
|
138
|
+
|
|
139
|
+
# Byte-for-byte body of the first `### Decisions` heading in `intent_file_content`.
|
|
140
|
+
# Returns [body, nil] on success, or [nil, message] when the heading is absent or its
|
|
141
|
+
# body has no non-blank line.
|
|
142
|
+
def extract_decisions(intent_file_content)
|
|
143
|
+
lines = intent_file_content.lines
|
|
144
|
+
idx = lines.index { |l| l.rstrip == "### Decisions" }
|
|
145
|
+
return [nil, "the intent file has no ### Decisions list to copy; the Why stage is not finished"] if idx.nil?
|
|
146
|
+
|
|
147
|
+
stop = idx + 1
|
|
148
|
+
stop += 1 while stop < lines.length && !(lines[stop].start_with?("## ") || lines[stop].start_with?("### "))
|
|
149
|
+
body_lines = lines[(idx + 1)...stop]
|
|
150
|
+
body_lines = strip_blank_edges(body_lines)
|
|
151
|
+
|
|
152
|
+
if body_lines.empty?
|
|
153
|
+
return [nil, "the intent file's ### Decisions list has no content to copy; the Why stage is not finished"]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
[body_lines.join, nil]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# --- `## Acceptance Criteria` extraction from spec.md (pure) --------------------
|
|
160
|
+
|
|
161
|
+
# Byte-for-byte body of `## Acceptance Criteria` in `spec_content`. Returns
|
|
162
|
+
# [body, nil] on success, or [nil, message] when the heading is absent or its body
|
|
163
|
+
# holds no `- [ ]` line.
|
|
164
|
+
def extract_acceptance_criteria(spec_content)
|
|
165
|
+
lines = spec_content.lines
|
|
166
|
+
idx = lines.index { |l| l.rstrip == "## Acceptance Criteria" }
|
|
167
|
+
return [nil, "spec.md has no ## Acceptance Criteria section to copy"] if idx.nil?
|
|
168
|
+
|
|
169
|
+
stop = idx + 1
|
|
170
|
+
stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
|
|
171
|
+
body_lines = strip_blank_edges(lines[(idx + 1)...stop])
|
|
172
|
+
|
|
173
|
+
unless body_lines.any? { |l| l =~ /^\s*- \[ \]/ }
|
|
174
|
+
return [nil, "spec.md's ## Acceptance Criteria has no checklist items (no line matching '- [ ]')"]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
[body_lines.join, nil]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def strip_blank_edges(lines)
|
|
181
|
+
lines = lines.drop_while { |l| l.strip.empty? }
|
|
182
|
+
lines.reverse.drop_while { |l| l.strip.empty? }.reverse
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# --- spec subcommand ------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def scaffold_spec(intent_dir:, force:, templates_dir: nil, home: Dir.home)
|
|
188
|
+
target = File.join(intent_dir, "spec.md")
|
|
189
|
+
return refuse_result(target) if refuse_without_force?(target, force)
|
|
190
|
+
|
|
191
|
+
intent_file = Bridge.intent_file(intent_dir)
|
|
192
|
+
return error_result("the intent file is missing at #{intent_file}") unless File.exist?(intent_file)
|
|
193
|
+
|
|
194
|
+
intent_content = File.read(intent_file)
|
|
195
|
+
fm = IntentValidator.parse_frontmatter_text(intent_content)
|
|
196
|
+
intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
|
|
197
|
+
if Bridge.blank?(intent_name)
|
|
198
|
+
return error_result("the intent file at #{intent_file} has no frontmatter intent name")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
decisions_body, decisions_err = extract_decisions(intent_content)
|
|
202
|
+
return error_result(decisions_err) if decisions_body.nil?
|
|
203
|
+
|
|
204
|
+
tdir = templates_dir || resolve_templates_dir(home: home)
|
|
205
|
+
return error_result(templates_missing_message(home: home)) if tdir.nil?
|
|
206
|
+
|
|
207
|
+
spec_template_path = File.join(tdir, "spec.md")
|
|
208
|
+
return error_result("spec.md template not found at #{spec_template_path}") unless File.exist?(spec_template_path)
|
|
209
|
+
|
|
210
|
+
written = build_spec_content(intent_name: intent_name, decisions_body: decisions_body,
|
|
211
|
+
template_text: File.read(spec_template_path))
|
|
212
|
+
|
|
213
|
+
FileUtils.mkdir_p(intent_dir)
|
|
214
|
+
File.write(target, written)
|
|
215
|
+
ok_result(target)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def build_spec_content(intent_name:, decisions_body:, template_text:)
|
|
219
|
+
template_sections = sections_from(template_text)
|
|
220
|
+
|
|
221
|
+
out = []
|
|
222
|
+
out << SpecHeader.render(tier: nil, settled_reason: nil)
|
|
223
|
+
out << SETTLED_PLACEHOLDER_COMMENT
|
|
224
|
+
out << "\n"
|
|
225
|
+
out << "# Spec: #{intent_name}\n"
|
|
226
|
+
out << "\n"
|
|
227
|
+
|
|
228
|
+
SPEC_SECTIONS.each do |heading|
|
|
229
|
+
out << "#{heading}\n"
|
|
230
|
+
if heading == "## Decisions"
|
|
231
|
+
out << decisions_body
|
|
232
|
+
out << "\n"
|
|
233
|
+
else
|
|
234
|
+
out << (template_sections[heading] || "")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
out.join
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# --- checklist subcommand --------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def scaffold_checklist(intent_dir:, force:, templates_dir: nil, home: Dir.home)
|
|
244
|
+
target = File.join(intent_dir, "checklist.md")
|
|
245
|
+
return refuse_result(target) if refuse_without_force?(target, force)
|
|
246
|
+
|
|
247
|
+
spec_path = File.join(intent_dir, "spec.md")
|
|
248
|
+
unless File.exist?(spec_path) && Bridge.stage_file_present?(spec_path)
|
|
249
|
+
return error_result("spec.md is missing or still the scaffold placeholder at #{spec_path}")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
ac_body, ac_err = extract_acceptance_criteria(File.read(spec_path))
|
|
253
|
+
return error_result(ac_err) if ac_body.nil?
|
|
254
|
+
|
|
255
|
+
intent_file = Bridge.intent_file(intent_dir)
|
|
256
|
+
fm = IntentValidator.parse_frontmatter(intent_file)
|
|
257
|
+
intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
|
|
258
|
+
if Bridge.blank?(intent_name)
|
|
259
|
+
return error_result("the intent file at #{intent_file} has no frontmatter intent name")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
tdir = templates_dir || resolve_templates_dir(home: home)
|
|
263
|
+
return error_result(templates_missing_message(home: home)) if tdir.nil?
|
|
264
|
+
|
|
265
|
+
checklist_template_path = File.join(tdir, "checklist.md")
|
|
266
|
+
unless File.exist?(checklist_template_path)
|
|
267
|
+
return error_result("checklist.md template not found at #{checklist_template_path}")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
template_text = File.read(checklist_template_path).sub("{{INTENT_NAME}}", intent_name)
|
|
271
|
+
written = replace_section_body(template_text, "## In Progress", [ac_body, "\n"])
|
|
272
|
+
|
|
273
|
+
FileUtils.mkdir_p(intent_dir)
|
|
274
|
+
File.write(target, written)
|
|
275
|
+
ok_result(target)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# --- repo / base-branch resolution (shared with ACTION_3) -----------------------
|
|
279
|
+
|
|
280
|
+
# The provisioned code worktree for this intent when it exists on disk, else the git
|
|
281
|
+
# toplevel of the current working directory. Returns nil when neither resolves.
|
|
282
|
+
def resolve_repo_dir(store:, id:, intent_dir:, home: Dir.home, runner: Worktree::ShellRunner.new)
|
|
283
|
+
wt_home = Worktree.home_from_store(store) || home
|
|
284
|
+
slug = Worktree.slug_for_store(store, home: wt_home)
|
|
285
|
+
intent_slug = File.basename(intent_dir).split("--", 2).last
|
|
286
|
+
paths = Worktree.paths(slug: slug, intent_id: id, intent_slug: intent_slug, home: wt_home)
|
|
287
|
+
code = paths["code"]
|
|
288
|
+
return code if code && Dir.exist?(code)
|
|
289
|
+
|
|
290
|
+
res = runner.run("-C", Dir.pwd, "rev-parse", "--show-toplevel")
|
|
291
|
+
return nil unless res.success?
|
|
292
|
+
|
|
293
|
+
top = res.stdout.to_s.strip
|
|
294
|
+
top.empty? ? nil : top
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Standard git base-branch detection, first success wins: origin/HEAD, then `main`,
|
|
298
|
+
# then `master`. Returns nil when none resolve.
|
|
299
|
+
def detect_base_branch(repo, runner: Worktree::ShellRunner.new)
|
|
300
|
+
res = runner.run("-C", repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
|
|
301
|
+
if res.success?
|
|
302
|
+
ref = res.stdout.to_s.strip
|
|
303
|
+
return ref.sub(%r{\Aorigin/}, "") unless ref.empty?
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
return "main" if runner.run("-C", repo, "rev-parse", "--verify", "--quiet", "main").success?
|
|
307
|
+
return "master" if runner.run("-C", repo, "rev-parse", "--verify", "--quiet", "master").success?
|
|
308
|
+
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# [stdout, nil] on success, [nil, stderr] on failure. Three-dot range so the diff is
|
|
313
|
+
# against the merge base, not the tip of the base branch.
|
|
314
|
+
def diffstat(repo, base, runner: Worktree::ShellRunner.new)
|
|
315
|
+
res = runner.run("-C", repo, "diff", "--stat", "#{base}...HEAD")
|
|
316
|
+
return [nil, res.stderr.to_s.strip] unless res.success?
|
|
317
|
+
|
|
318
|
+
[res.stdout.to_s, nil]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# --- outcome subcommand -----------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def scaffold_outcome(intent_dir:, force:, store:, id:, test_summary: nil,
|
|
324
|
+
home: Dir.home, runner: Worktree::ShellRunner.new, templates_dir: nil)
|
|
325
|
+
target = File.join(intent_dir, "outcome.md")
|
|
326
|
+
return refuse_result(target) if refuse_without_force?(target, force)
|
|
327
|
+
|
|
328
|
+
intent_file = Bridge.intent_file(intent_dir)
|
|
329
|
+
return error_result("the intent file is missing at #{intent_file}") unless File.exist?(intent_file)
|
|
330
|
+
|
|
331
|
+
fm = IntentValidator.parse_frontmatter(intent_file)
|
|
332
|
+
intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
|
|
333
|
+
if Bridge.blank?(intent_name)
|
|
334
|
+
return error_result("the intent file at #{intent_file} has no frontmatter intent name")
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
tdir = templates_dir || resolve_templates_dir(home: home)
|
|
338
|
+
return error_result(templates_missing_message(home: home)) if tdir.nil?
|
|
339
|
+
|
|
340
|
+
outcome_template_path = File.join(tdir, "outcome.md")
|
|
341
|
+
unless File.exist?(outcome_template_path)
|
|
342
|
+
return error_result("outcome.md template not found at #{outcome_template_path}")
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
verification_body = build_verification_body(store: store, id: id, intent_dir: intent_dir,
|
|
346
|
+
home: home, runner: runner, test_summary: test_summary)
|
|
347
|
+
|
|
348
|
+
content = File.read(outcome_template_path).sub("# Outcome: <intent name>", "# Outcome: #{intent_name}")
|
|
349
|
+
written = replace_section_body(content, "## Verification", [verification_body, "\n"])
|
|
350
|
+
|
|
351
|
+
FileUtils.mkdir_p(intent_dir)
|
|
352
|
+
File.write(target, written)
|
|
353
|
+
ok_result(target)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def build_verification_body(store:, id:, intent_dir:, home:, runner:, test_summary:)
|
|
357
|
+
repo = resolve_repo_dir(store: store, id: id, intent_dir: intent_dir, home: home, runner: runner)
|
|
358
|
+
|
|
359
|
+
out = []
|
|
360
|
+
if repo.nil?
|
|
361
|
+
out << "Diffstat unavailable: no repo could be resolved for this intent\n"
|
|
362
|
+
else
|
|
363
|
+
base = detect_base_branch(repo, runner: runner)
|
|
364
|
+
if base.nil?
|
|
365
|
+
out << "Diffstat unavailable: no base branch could be detected (no origin/HEAD, main, or master)\n"
|
|
366
|
+
else
|
|
367
|
+
stat, err = diffstat(repo, base, runner: runner)
|
|
368
|
+
if stat.nil?
|
|
369
|
+
out << "Diffstat unavailable: #{err}\n"
|
|
370
|
+
else
|
|
371
|
+
out << "Diffstat against #{base}:\n"
|
|
372
|
+
out << "```\n"
|
|
373
|
+
out << stat
|
|
374
|
+
out << "\n" unless stat.end_with?("\n")
|
|
375
|
+
out << "```\n"
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
unless Bridge.blank?(test_summary)
|
|
381
|
+
out << "\n"
|
|
382
|
+
out << "Test summary from #{test_summary}:\n"
|
|
383
|
+
out << "```\n"
|
|
384
|
+
content = File.read(test_summary)
|
|
385
|
+
out << content
|
|
386
|
+
out << "\n" unless content.end_with?("\n")
|
|
387
|
+
out << "```\n"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
out.join
|
|
391
|
+
end
|
|
392
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SpecHeader - the single implementation that parses the `Tier:` and `Settled:` lines out
|
|
5
|
+
# of a spec.md's header block (intent 213). No script, skill, or hook re-implements this
|
|
6
|
+
# grammar anywhere else; Bridge.savepoint_tier delegates here instead of carrying its own
|
|
7
|
+
# copy of the Tier regex.
|
|
8
|
+
#
|
|
9
|
+
# Grammar (both lines live above the `# ` level-1 heading):
|
|
10
|
+
#
|
|
11
|
+
# Tier: L
|
|
12
|
+
# Settled: yes (design fixed by the 2026-07-18 Fable advisor verdict)
|
|
13
|
+
#
|
|
14
|
+
# `Tier:` is exactly one of S, M, L; anything else is unparseable and yields nil. An ABSENT
|
|
15
|
+
# `Settled:` line means not settled, there is no `Settled: no` variant. The parenthesised
|
|
16
|
+
# reason is REQUIRED for a line to count as settled: a bare `Settled: yes` with no reason
|
|
17
|
+
# does NOT parse as settled. D2 calls Settled a ONE-WAY DOOR (leniency accepted now can
|
|
18
|
+
# never be tightened later), so this stays strict from the start rather than being loosened
|
|
19
|
+
# once and then needing a breaking change to fix.
|
|
20
|
+
module SpecHeader
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Mirrors Bridge::PLACEHOLDER_SENTINEL (scripts/lib/bridge.rb:22). Not required in from
|
|
24
|
+
# bridge.rb to avoid a require cycle: bridge.rb requires spec_header.rb, so spec_header.rb
|
|
25
|
+
# must have zero require_relative dependencies of its own.
|
|
26
|
+
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
27
|
+
|
|
28
|
+
HEADER_BLOCK_MAX_LINES = 10
|
|
29
|
+
|
|
30
|
+
TIER_RE = /\ATier:\s*(S|M|L)\z/
|
|
31
|
+
SETTLED_RE = /\ASettled:\s*yes\s*\((.*)\)\z/
|
|
32
|
+
|
|
33
|
+
# PURE. Parse a spec.md's raw text. Returns a Hash with symbol keys, always the same
|
|
34
|
+
# three keys: tier ("S"|"M"|"L"|nil), settled (true|false), settled_reason (String|nil).
|
|
35
|
+
def parse(text)
|
|
36
|
+
result = { tier: nil, settled: false, settled_reason: nil }
|
|
37
|
+
return result if text.nil?
|
|
38
|
+
|
|
39
|
+
lines = text.each_line.first(HEADER_BLOCK_MAX_LINES)
|
|
40
|
+
lines.each do |raw|
|
|
41
|
+
line = raw.chomp.strip
|
|
42
|
+
next if line.empty?
|
|
43
|
+
next if line == PLACEHOLDER_SENTINEL
|
|
44
|
+
break if line.start_with?("# ")
|
|
45
|
+
|
|
46
|
+
if (m = line.match(TIER_RE))
|
|
47
|
+
result[:tier] = m[1]
|
|
48
|
+
elsif (m = line.match(SETTLED_RE))
|
|
49
|
+
result[:settled] = true
|
|
50
|
+
result[:settled_reason] = m[1]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Read at most the header block off disk and parse it. Returns the same Hash shape as
|
|
58
|
+
# `parse`, with every value nil/false when the path does not exist or cannot be read.
|
|
59
|
+
def parse_file(path)
|
|
60
|
+
lines = []
|
|
61
|
+
File.open(path) do |f|
|
|
62
|
+
HEADER_BLOCK_MAX_LINES.times do
|
|
63
|
+
line = f.gets
|
|
64
|
+
break if line.nil?
|
|
65
|
+
lines << line
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
parse(lines.join)
|
|
69
|
+
rescue StandardError
|
|
70
|
+
{ tier: nil, settled: false, settled_reason: nil }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Render the two header lines for a spec.md. `tier` is "S"|"M"|"L" or nil, `settled_reason`
|
|
74
|
+
# is a String or nil. Returns a String ending in one newline. A nil tier renders the
|
|
75
|
+
# placeholder `Tier: S|M|L`; a nil reason renders no Settled line at all (absent means not
|
|
76
|
+
# settled).
|
|
77
|
+
def render(tier: nil, settled_reason: nil)
|
|
78
|
+
lines = []
|
|
79
|
+
lines << "Tier: #{tier || 'S|M|L'}"
|
|
80
|
+
lines << "Settled: yes (#{settled_reason})" if settled_reason
|
|
81
|
+
"#{lines.join("\n")}\n"
|
|
82
|
+
end
|
|
83
|
+
end
|