@zalom/plastic 1.0.3 → 1.1.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-reference.md +11 -11
- package/PLASTIC.md +7 -7
- package/README.md +47 -0
- package/agents/plastic-enforcer.md +3 -3
- package/agents/plastic-intent-curator.md +9 -10
- package/agents/plastic-spec-specialist.md +9 -6
- package/bin/plastic.js +2 -2
- package/package.json +1 -1
- package/scripts/dashboard.rb +1 -1
- package/scripts/doctor.rb +52 -4
- package/scripts/end-intent +330 -0
- package/scripts/hook-code-gate +1 -1
- package/scripts/hook-continue +1 -1
- package/scripts/hook-create-gate +2 -2
- package/scripts/hook-session-start +1 -1
- package/scripts/lib/bridge.rb +35 -3
- package/scripts/lib/installer_core.rb +8 -6
- package/scripts/lib/store_provisioning.rb +1 -1
- package/scripts/new-intent +1 -1
- package/scripts/{versions.rb → rollback.rb} +33 -41
- package/scripts/update.rb +1 -1
- package/skills/_active-intent-gate.md +1 -1
- package/skills/_decision-tables.md +50 -0
- package/skills/auto/SKILL.md +47 -64
- package/skills/auto/evals/evals.json +4 -4
- package/skills/auto/references/agent-architecture.md +2 -2
- package/skills/dashboard/SKILL.md +2 -2
- package/skills/doctor/SKILL.md +3 -3
- package/skills/install/SKILL.md +1 -1
- package/skills/{brainstorming → intent-brainstorming}/SKILL.md +43 -45
- package/skills/intent-brainstorming/evals/evals.json +67 -0
- package/skills/{brainstorming → intent-brainstorming}/references/design-principles.md +12 -17
- package/skills/{continuing → intent-continuing}/SKILL.md +3 -3
- package/skills/{continuing → intent-continuing}/evals/evals.json +24 -24
- package/skills/{creating-intent → intent-creating}/SKILL.md +3 -3
- package/skills/{creating-intent → intent-creating}/evals/evals.json +1 -1
- package/skills/{intent-discovery → intent-discovering}/SKILL.md +1 -1
- package/skills/intent-ending/SKILL.md +151 -0
- package/skills/intent-ending/evals/evals.json +74 -0
- package/skills/{executing-plan → intent-executing}/SKILL.md +75 -15
- package/skills/intent-executing/evals/evals.json +66 -0
- package/skills/{brainstorming-grill-me → intent-grilling}/SKILL.md +4 -4
- package/skills/{linking-intents → intent-linking}/SKILL.md +1 -1
- package/skills/{linking-intents → intent-linking}/evals/evals.json +1 -1
- package/skills/{lock → intent-locking}/SKILL.md +1 -1
- package/skills/{writing-plans → intent-planning}/SKILL.md +48 -20
- package/skills/intent-planning/evals/evals.json +66 -0
- package/skills/{writing-plans → intent-planning}/references/plan-format.md +31 -7
- package/skills/{research → intent-researching}/SKILL.md +2 -2
- package/skills/{research → intent-researching}/evals/evals.json +1 -1
- package/skills/{savepoint → intent-savepoint}/SKILL.md +1 -1
- package/skills/intent-speccing/SKILL.md +69 -0
- package/skills/intent-speccing/evals/evals.json +126 -0
- package/skills/intent-speccing/references/per-section-fill-rules.md +92 -0
- package/skills/intent-speccing/references/self-verify-checklist.md +38 -0
- package/skills/intent-starting/SKILL.md +2 -2
- package/skills/intent-starting/evals/evals.json +2 -2
- package/skills/{creating-project → project-creating}/SKILL.md +2 -2
- package/skills/releasing/SKILL.md +18 -14
- package/skills/{versions → rollback}/SKILL.md +30 -20
- package/skills/{creating-skills → skill-creating}/SKILL.md +2 -2
- package/skills/{creating-skills → skill-creating}/evals/evals.json +5 -5
- package/skills/{creating-skills → skill-creating}/references/progressive-disclosure.md +2 -2
- package/skills/{evaluating-skills → skill-evaluating}/SKILL.md +1 -1
- package/skills/{evaluating-skills → skill-evaluating}/evals/evals.json +3 -3
- package/skills/{intent-curator → store-curating}/SKILL.md +6 -7
- package/skills/{intent-curator → store-curating}/evals/evals.json +1 -1
- package/skills/{managing-index → store-indexing}/SKILL.md +6 -7
- package/skills/{managing-index → store-indexing}/evals/evals.json +1 -1
- package/skills/{add-project-store → store-provisioning}/SKILL.md +2 -2
- package/skills/tutorial/SKILL.md +65 -0
- package/skills/tutorial/evals/evals.json +186 -0
- package/skills/tutorial/references/track-1-guided.md +123 -0
- package/skills/tutorial/references/track-2-auto.md +95 -0
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +110 -0
- package/skills/update/SKILL.md +1 -1
- package/templates/agents.md +7 -7
- package/skills/brainstorming/evals/evals.json +0 -22
- /package/skills/{continuing → intent-continuing}/references/context-management.md +0 -0
- /package/skills/{creating-intent → intent-creating}/references/lifecycle.md +0 -0
- /package/skills/{creating-intent → intent-creating}/references/wikilinks.md +0 -0
- /package/skills/{executing-plan → intent-executing}/code-quality-reviewer-prompt.md +0 -0
- /package/skills/{executing-plan → intent-executing}/implementer-prompt.md +0 -0
- /package/skills/{executing-plan → intent-executing}/spec-reviewer-prompt.md +0 -0
- /package/skills/{linking-intents → intent-linking}/references/zettelkasten.md +0 -0
- /package/skills/{savepoint → intent-savepoint}/references/context-management.md +0 -0
- /package/skills/{creating-project → project-creating}/references/hubs-projects.md +0 -0
- /package/skills/{creating-project → project-creating}/references/project-scaffolding.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/agents.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/evals.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/hooks.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/scripts.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/skills.md +0 -0
- /package/skills/{creating-skills → skill-creating}/scripts/scaffold.rb +0 -0
- /package/skills/{evaluating-skills → skill-evaluating}/assets/eval-template.json +0 -0
- /package/skills/{evaluating-skills → skill-evaluating}/references/convention-checks.md +0 -0
- /package/skills/{evaluating-skills → skill-evaluating}/references/eval-methodology.md +0 -0
- /package/skills/{managing-index → store-indexing}/references/zettelkasten-linking.md +0 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# end-intent (intent 161) - the mechanical core of the single owned Done
|
|
6
|
+
# procedure (D2 steps 1-4). Pure with respect to worktrees, locks, and QMD:
|
|
7
|
+
# those stay in the plastic-intent-ending skill body (steps 0 and 5-7), never
|
|
8
|
+
# duplicated here. Pairs new-intent's shape: one callable script instead of
|
|
9
|
+
# raw one-liners repeated in skill prose (the failure mode this intent fixes).
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# end-intent --store <store_path> --id <intent_id> --disposition delivered|abandoned \
|
|
13
|
+
# [--index <path>] [--outcome-summary <text>] [--index-note <text>] \
|
|
14
|
+
# [--no-commit] [--dry-run]
|
|
15
|
+
#
|
|
16
|
+
# Steps performed (in order), all idempotent:
|
|
17
|
+
# 1. Guard outcome.md (exit 2, authors nothing, on any failure), then stamp
|
|
18
|
+
# the intent file's `## Outcome` section with --outcome-summary if given.
|
|
19
|
+
# 2. Move the intent's INDEX.md line from `## Active` into `## Completed`
|
|
20
|
+
# (delivered) or `## Abandoned` (abandoned), dated today, appending
|
|
21
|
+
# --index-note (if given) after the date so the terminal entry stays
|
|
22
|
+
# the store's existing rich Completed/Abandoned shape rather than
|
|
23
|
+
# thinning to bare id+date.
|
|
24
|
+
# 3. Append the savepoint `Done` bookend (Bridge.append_terminal_savepoint).
|
|
25
|
+
# 4. Commit the store repo, unless --no-commit.
|
|
26
|
+
#
|
|
27
|
+
# Exit codes: 0 (ok), 1 (usage/resolution failure), 2 (outcome.md guard refused).
|
|
28
|
+
|
|
29
|
+
require "fileutils"
|
|
30
|
+
require "date"
|
|
31
|
+
require "open3"
|
|
32
|
+
require_relative "lib/bridge"
|
|
33
|
+
require_relative "lib/intent_validator"
|
|
34
|
+
|
|
35
|
+
DISPOSITIONS = %w[delivered abandoned].freeze
|
|
36
|
+
|
|
37
|
+
# The store-line separator this script must reproduce is a real em dash
|
|
38
|
+
# (U+2014), the existing INDEX.md convention (store files are exempt from the
|
|
39
|
+
# no-em-dash shipped-file rule). Built from the codepoint, not a literal byte
|
|
40
|
+
# in this source file, so the script's own source stays em-dash free.
|
|
41
|
+
EM_DASH = "\u2014"
|
|
42
|
+
|
|
43
|
+
# --- Explicit flag parsing (no eval, no global injection) -------------------
|
|
44
|
+
|
|
45
|
+
def parse_args(argv)
|
|
46
|
+
opts = {
|
|
47
|
+
store: nil, id: nil, disposition: nil, index: nil,
|
|
48
|
+
outcome_summary: nil, index_note: nil, no_commit: false, dry_run: false,
|
|
49
|
+
}
|
|
50
|
+
i = 0
|
|
51
|
+
while i < argv.length
|
|
52
|
+
arg = argv[i]
|
|
53
|
+
case arg
|
|
54
|
+
when "--store" then opts[:store] = argv[i += 1]
|
|
55
|
+
when "--id" then opts[:id] = argv[i += 1]
|
|
56
|
+
when "--disposition" then opts[:disposition] = argv[i += 1]
|
|
57
|
+
when "--index" then opts[:index] = argv[i += 1]
|
|
58
|
+
when "--outcome-summary" then opts[:outcome_summary] = argv[i += 1]
|
|
59
|
+
when "--index-note" then opts[:index_note] = argv[i += 1]
|
|
60
|
+
when "--no-commit" then opts[:no_commit] = true
|
|
61
|
+
when "--dry-run" then opts[:dry_run] = true
|
|
62
|
+
else
|
|
63
|
+
usage_abort("unknown argument #{arg.inspect}")
|
|
64
|
+
end
|
|
65
|
+
i += 1
|
|
66
|
+
end
|
|
67
|
+
opts
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def usage
|
|
71
|
+
"usage: end-intent --store <store_path> --id <intent_id> " \
|
|
72
|
+
"--disposition delivered|abandoned [--index <path>] " \
|
|
73
|
+
"[--outcome-summary <text>] [--index-note <text>] [--no-commit] [--dry-run]"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def usage_abort(message)
|
|
77
|
+
warn "end-intent: #{message}"
|
|
78
|
+
warn usage
|
|
79
|
+
exit 1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def expand(path)
|
|
83
|
+
File.expand_path(path.to_s.sub(/\A~/, Dir.home))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Resolve the single "<store>/<id>--*" directory, or abort (usage error: exit 1).
|
|
87
|
+
def resolve_intent_dir(store, id)
|
|
88
|
+
matches = Dir.glob(File.join(store, "#{id}--*")).select { |d| File.directory?(d) }
|
|
89
|
+
usage_abort("no intent directory matches #{id}--* under #{store}") if matches.empty?
|
|
90
|
+
if matches.length > 1
|
|
91
|
+
usage_abort("ambiguous id #{id.inspect}: #{matches.length} matching directories under #{store}")
|
|
92
|
+
end
|
|
93
|
+
matches.first
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# --- outcome.md guard (D2 step 1a) ------------------------------------------
|
|
97
|
+
#
|
|
98
|
+
# Returns nil when the guard PASSES, or a reason String to refuse with exit 2.
|
|
99
|
+
# Read-only: authors nothing regardless of outcome. Refuses a missing file, a
|
|
100
|
+
# still-placeholder file (first line is the sentinel), or a disposition that
|
|
101
|
+
# does not exactly equal the requested one (this is what naturally refuses a
|
|
102
|
+
# freshly scaffolded outcome.md, whose disposition is the literal
|
|
103
|
+
# "delivered|abandoned" string, not one of the two real values).
|
|
104
|
+
def outcome_guard_reason(intent_dir, disposition)
|
|
105
|
+
path = File.join(intent_dir, "outcome.md")
|
|
106
|
+
return "outcome.md is missing at #{path}" unless File.exist?(path)
|
|
107
|
+
|
|
108
|
+
content = File.read(path)
|
|
109
|
+
first_line = content.each_line.first.to_s.chomp
|
|
110
|
+
if first_line == Bridge::PLACEHOLDER_SENTINEL
|
|
111
|
+
return "outcome.md is still the scaffold placeholder (first line is the sentinel)"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
fm = IntentValidator.parse_frontmatter_text(content)
|
|
115
|
+
actual = fm.is_a?(Hash) ? fm["disposition"] : nil
|
|
116
|
+
unless DISPOSITIONS.include?(actual) && actual == disposition
|
|
117
|
+
return "outcome.md frontmatter disposition is #{actual.inspect}, expected #{disposition.inspect}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --- intent-file `## Outcome` summary stamp (D2 step 1b) --------------------
|
|
124
|
+
#
|
|
125
|
+
# Replace the body of the intent file's `## Outcome` section with `summary`,
|
|
126
|
+
# leaving every other section and the frontmatter untouched. No-op (returns
|
|
127
|
+
# content unchanged) when summary is blank or the section is not found.
|
|
128
|
+
def stamp_outcome_summary(content, summary)
|
|
129
|
+
return content if summary.nil? || summary.to_s.strip.empty?
|
|
130
|
+
|
|
131
|
+
lines = content.lines
|
|
132
|
+
start = lines.index { |l| l.rstrip == "## Outcome" }
|
|
133
|
+
return content if start.nil?
|
|
134
|
+
|
|
135
|
+
stop = start + 1
|
|
136
|
+
stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
|
|
137
|
+
|
|
138
|
+
body = "#{summary.to_s.strip}\n"
|
|
139
|
+
replacement = (stop < lines.length) ? [body, "\n"] : [body]
|
|
140
|
+
(lines[0..start] + replacement + lines[stop..]).join
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# --- INDEX.md terminal move (D2 step 2) -------------------------------------
|
|
144
|
+
|
|
145
|
+
ACTIVE_LINE_RE = /\A- \[(\S+)\s+#{Regexp.escape(EM_DASH)}\s+(.*?)\]\(([^)]+)\)/.freeze
|
|
146
|
+
|
|
147
|
+
def index_target_heading(disposition)
|
|
148
|
+
disposition == "delivered" ? "## Completed" : "## Abandoned"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Index of the first `## ` heading at or after heading_idx + 1, or lines.length
|
|
152
|
+
# at EOF (the section's exclusive end bound).
|
|
153
|
+
def section_stop(lines, heading_idx)
|
|
154
|
+
stop = heading_idx + 1
|
|
155
|
+
stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
|
|
156
|
+
stop
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def active_line_id(line)
|
|
160
|
+
m = line.match(ACTIVE_LINE_RE)
|
|
161
|
+
m && m[1]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def section_contains_id?(lines, heading_idx, id)
|
|
165
|
+
stop = section_stop(lines, heading_idx)
|
|
166
|
+
(heading_idx + 1...stop).any? { |i| active_line_id(lines[i]) == id }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Remove the entry at entry_idx from the `## Active` section. When no real
|
|
170
|
+
# entries remain, install the canonical "_(none)_" placeholder (the same
|
|
171
|
+
# empty-state convention INDEX.md already uses).
|
|
172
|
+
def remove_active_entry(lines, active_start, entry_idx)
|
|
173
|
+
new_lines = lines.dup
|
|
174
|
+
new_lines.delete_at(entry_idx)
|
|
175
|
+
stop = section_stop(new_lines, active_start)
|
|
176
|
+
remaining = (active_start + 1...stop).reject { |i| new_lines[i].strip.empty? }
|
|
177
|
+
if remaining.empty?
|
|
178
|
+
# Keep the blank-line separator before the next heading (if any); only
|
|
179
|
+
# collapse the section body to the placeholder, never the spacing.
|
|
180
|
+
placeholder = (stop < new_lines.length) ? ["_(none)_\n", "\n"] : ["_(none)_\n"]
|
|
181
|
+
new_lines[(active_start + 1)...stop] = placeholder
|
|
182
|
+
end
|
|
183
|
+
new_lines
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Insert new_entry into the target terminal section: replace the "_(none)_"
|
|
187
|
+
# placeholder if that is the ONLY non-blank content the section holds (a
|
|
188
|
+
# trailing blank line before the next heading is common and must not
|
|
189
|
+
# defeat this check), otherwise prepend (newest-first, matching the
|
|
190
|
+
# existing Completed/Abandoned convention).
|
|
191
|
+
def insert_terminal_entry(lines, target_start, new_entry)
|
|
192
|
+
new_lines = lines.dup
|
|
193
|
+
stop = section_stop(new_lines, target_start)
|
|
194
|
+
body_range = (target_start + 1...stop)
|
|
195
|
+
placeholder_idx = body_range.find { |i| new_lines[i].strip == "_(none)_" }
|
|
196
|
+
non_blank = body_range.reject { |i| new_lines[i].strip.empty? }
|
|
197
|
+
if placeholder_idx && non_blank == [placeholder_idx]
|
|
198
|
+
new_lines[placeholder_idx] = new_entry
|
|
199
|
+
else
|
|
200
|
+
new_lines.insert(target_start + 1, new_entry)
|
|
201
|
+
end
|
|
202
|
+
new_lines
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Move the intent's `## Active` line into the terminal section. Returns the
|
|
206
|
+
# new INDEX.md content, or the original content unchanged when there is
|
|
207
|
+
# nothing to do (idempotent: a second run finds no Active entry and the
|
|
208
|
+
# terminal section already carries it, so it is a clean no-op). `index_note`
|
|
209
|
+
# (D2 "rich entry", review F3), when given, is appended verbatim after the
|
|
210
|
+
# date with a single leading space: content authoring (mode/tier, what
|
|
211
|
+
# shipped, suite result, and so on) stays with the caller, this only places
|
|
212
|
+
# the text where the store's existing Completed/Abandoned lines carry it.
|
|
213
|
+
def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
|
|
214
|
+
target_heading = index_target_heading(disposition)
|
|
215
|
+
lines = content.lines
|
|
216
|
+
|
|
217
|
+
active_start = lines.index { |l| l.rstrip == "## Active" }
|
|
218
|
+
target_start = lines.index { |l| l.rstrip == target_heading }
|
|
219
|
+
return content if active_start.nil? || target_start.nil?
|
|
220
|
+
|
|
221
|
+
already_in_target = section_contains_id?(lines, target_start, id)
|
|
222
|
+
active_stop = section_stop(lines, active_start)
|
|
223
|
+
entry_idx = (active_start + 1...active_stop).find { |i| active_line_id(lines[i]) == id }
|
|
224
|
+
|
|
225
|
+
return content if entry_idx.nil? # already moved (or never present): no-op either way
|
|
226
|
+
|
|
227
|
+
m = lines[entry_idx].match(ACTIVE_LINE_RE)
|
|
228
|
+
title, link = m[2], m[3]
|
|
229
|
+
note_suffix = (index_note && !index_note.to_s.strip.empty?) ? " #{index_note.to_s.strip}" : ""
|
|
230
|
+
new_entry = "- [#{id} #{EM_DASH} #{title}](#{link}) #{EM_DASH} #{today}#{note_suffix}\n"
|
|
231
|
+
|
|
232
|
+
lines = remove_active_entry(lines, active_start, entry_idx)
|
|
233
|
+
target_start = lines.index { |l| l.rstrip == target_heading }
|
|
234
|
+
lines = insert_terminal_entry(lines, target_start, new_entry) unless already_in_target
|
|
235
|
+
|
|
236
|
+
lines.join
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# --- store auto-commit (D2 step 4) ------------------------------------------
|
|
240
|
+
|
|
241
|
+
def git_toplevel(dir)
|
|
242
|
+
out, _err, status = Open3.capture3("git", "-C", dir, "rev-parse", "--show-toplevel")
|
|
243
|
+
return nil unless status.success?
|
|
244
|
+
top = out.strip
|
|
245
|
+
top.empty? ? nil : top
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Best-effort store commit: never raises, never blocks the mechanical close.
|
|
249
|
+
# Returns true when a commit was created, false otherwise (not a git repo,
|
|
250
|
+
# nothing to commit, or the commit failed). Pins a local committer identity so
|
|
251
|
+
# the commit succeeds even with no ambient git config (hermetic).
|
|
252
|
+
def store_commit(store, id, disposition)
|
|
253
|
+
root = git_toplevel(store)
|
|
254
|
+
return false if root.nil?
|
|
255
|
+
|
|
256
|
+
Open3.capture3("git", "-C", root, "add", "-A")
|
|
257
|
+
_out, _err, status = Open3.capture3(
|
|
258
|
+
"git", "-C", root,
|
|
259
|
+
"-c", "user.name=Plastic", "-c", "user.email=plastic@localhost",
|
|
260
|
+
"commit", "--quiet", "-m", "chore: complete intent #{id} (#{disposition})"
|
|
261
|
+
)
|
|
262
|
+
status.success?
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# --- main --------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def main(argv)
|
|
268
|
+
opts = parse_args(argv)
|
|
269
|
+
usage_abort("--store is required") if opts[:store].nil? || opts[:store].empty?
|
|
270
|
+
usage_abort("--id is required") if opts[:id].nil? || opts[:id].empty?
|
|
271
|
+
unless DISPOSITIONS.include?(opts[:disposition])
|
|
272
|
+
usage_abort("--disposition must be one of #{DISPOSITIONS.join('|')}")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
store = expand(opts[:store])
|
|
276
|
+
usage_abort("store dir does not exist: #{store}") unless Dir.exist?(store)
|
|
277
|
+
|
|
278
|
+
id = opts[:id]
|
|
279
|
+
disposition = opts[:disposition]
|
|
280
|
+
intent_dir = resolve_intent_dir(store, id)
|
|
281
|
+
index_path = opts[:index] ? expand(opts[:index]) : File.join(File.dirname(store), "INDEX.md")
|
|
282
|
+
today = Date.today.iso8601
|
|
283
|
+
|
|
284
|
+
reason = outcome_guard_reason(intent_dir, disposition)
|
|
285
|
+
if reason
|
|
286
|
+
warn "end-intent: #{reason}"
|
|
287
|
+
exit 2
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
if opts[:dry_run]
|
|
291
|
+
puts "end-intent: dry run for #{File.basename(intent_dir)} (#{disposition})"
|
|
292
|
+
puts " would stamp ## Outcome: #{opts[:outcome_summary].inspect}" if opts[:outcome_summary]
|
|
293
|
+
puts " would move INDEX.md entry from ## Active to #{index_target_heading(disposition)} (#{index_path})"
|
|
294
|
+
puts " would append index note: #{opts[:index_note].inspect}" if opts[:index_note]
|
|
295
|
+
puts " would append savepoint bookend: Done #{disposition}"
|
|
296
|
+
puts(opts[:no_commit] ? " would skip store commit (--no-commit)" : " would commit the store repo")
|
|
297
|
+
exit 0
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
|
|
301
|
+
if opts[:outcome_summary]
|
|
302
|
+
intent_file = Bridge.intent_file(intent_dir)
|
|
303
|
+
if File.exist?(intent_file)
|
|
304
|
+
original = File.read(intent_file)
|
|
305
|
+
updated = stamp_outcome_summary(original, opts[:outcome_summary])
|
|
306
|
+
File.write(intent_file, updated) if updated != original
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# 2. INDEX.md terminal move.
|
|
311
|
+
if File.exist?(index_path)
|
|
312
|
+
original_index = File.read(index_path)
|
|
313
|
+
updated_index = move_index_to_terminal(original_index, id, disposition, today: today,
|
|
314
|
+
index_note: opts[:index_note])
|
|
315
|
+
File.write(index_path, updated_index) if updated_index != original_index
|
|
316
|
+
else
|
|
317
|
+
warn "end-intent: INDEX.md not found at #{index_path}, skipping the terminal move"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# 3. Savepoint Done bookend (idempotent).
|
|
321
|
+
Bridge.append_terminal_savepoint(intent_dir, disposition)
|
|
322
|
+
|
|
323
|
+
# 4. Store auto-commit, unless --no-commit.
|
|
324
|
+
store_commit(store, id, disposition) unless opts[:no_commit]
|
|
325
|
+
|
|
326
|
+
puts intent_dir
|
|
327
|
+
exit 0
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
main(ARGV) if $PROGRAM_NAME == __FILE__
|
package/scripts/hook-code-gate
CHANGED
|
@@ -39,7 +39,7 @@ if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
# --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
|
|
42
|
-
bridge_data = Bridge.discover_bridge(session: session, cwd: file_path)
|
|
42
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: file_path, edited_path: file_path)
|
|
43
43
|
exit 0 unless bridge_data
|
|
44
44
|
|
|
45
45
|
reason = Bridge.code_gate_decision(bridge_data, file_path) ||
|
package/scripts/hook-continue
CHANGED
|
@@ -19,7 +19,7 @@ cockpit, _err, status = Open3.capture3("ruby", dashboard, "continue")
|
|
|
19
19
|
exit 0 unless status.success? && !cockpit.strip.empty?
|
|
20
20
|
|
|
21
21
|
context = cockpit.rstrip +
|
|
22
|
-
"\n\nFollow the plastic-continuing skill workflow to resume: " \
|
|
22
|
+
"\n\nFollow the plastic-intent-continuing skill workflow to resume: " \
|
|
23
23
|
"if active intents exist, read their state and resume; otherwise offer the " \
|
|
24
24
|
"Value×Effort matrix items above, surfacing any stale (Nd) / ⚑ triage intents."
|
|
25
25
|
|
package/scripts/hook-create-gate
CHANGED
|
@@ -67,7 +67,7 @@ result =
|
|
|
67
67
|
# the validator.
|
|
68
68
|
unless File.exist?(abs)
|
|
69
69
|
block!("PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
|
|
70
|
-
"create intents via new-intent / plastic-creating
|
|
70
|
+
"create intents via new-intent / plastic-intent-creating.")
|
|
71
71
|
end
|
|
72
72
|
current = File.read(abs)
|
|
73
73
|
unless current.include?(old_string)
|
|
@@ -91,5 +91,5 @@ exit 0 if result[:ok]
|
|
|
91
91
|
|
|
92
92
|
$stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
|
|
93
93
|
result[:errors].each { |e| $stderr.puts " #{e}" }
|
|
94
|
-
$stderr.puts "Create intents via new-intent / plastic-creating
|
|
94
|
+
$stderr.puts "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
|
|
95
95
|
exit 2
|
|
@@ -201,7 +201,7 @@ parts = []
|
|
|
201
201
|
|
|
202
202
|
# Boot banner first: every session start surfaces that Plastic loaded, with the
|
|
203
203
|
# version (clean) or the first failing core check (degraded). Owned by the hook
|
|
204
|
-
# so it runs by construction — the plastic-continuing skill no longer does this.
|
|
204
|
+
# so it runs by construction — the plastic-intent-continuing skill no longer does this.
|
|
205
205
|
parts << core_banner
|
|
206
206
|
parts << ""
|
|
207
207
|
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -165,6 +165,18 @@ module Bridge
|
|
|
165
165
|
-1
|
|
166
166
|
end
|
|
167
167
|
|
|
168
|
+
# The provisioned code worktree dir (<repo>/.claude/worktrees/{id}--{slug})
|
|
169
|
+
# that contains file_path, or nil (intent 168). Structural and bridge-free:
|
|
170
|
+
# Worktree.provision builds exactly this layout, so a file under it is
|
|
171
|
+
# "worktree-scoped" and only that intent may gate it. The {id}--{slug} shape
|
|
172
|
+
# (a `--` in the dir name) is required, so a stray .claude/worktrees/README
|
|
173
|
+
# is not worktree-scoped and returns nil.
|
|
174
|
+
def self.enclosing_worktree_dir(file_path)
|
|
175
|
+
return nil if blank?(file_path)
|
|
176
|
+
m = File.expand_path(file_path).match(%r{\A(.*/\.claude/worktrees/[^/]+--[^/]+)(?:/|\z)})
|
|
177
|
+
m && m[1]
|
|
178
|
+
end
|
|
179
|
+
|
|
168
180
|
# Resolve the active bridge: scan tmp for plastic-*.json (both per-intent and
|
|
169
181
|
# legacy-keyed files), keep only valid bridges, filter to the caller's own
|
|
170
182
|
# session when it has one, prefer auto-armed, then disambiguate by cwd tier
|
|
@@ -172,7 +184,7 @@ module Bridge
|
|
|
172
184
|
# path: a session now legitimately owns several bridges (one per concurrent
|
|
173
185
|
# intent), so filename lookup alone cannot pick the right one; cwd must
|
|
174
186
|
# decide (intent 131).
|
|
175
|
-
def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
|
|
187
|
+
def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir, edited_path: nil)
|
|
176
188
|
candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
|
|
177
189
|
parsed = candidates.filter_map do |f|
|
|
178
190
|
data = (JSON.parse(File.read(f)) rescue nil)
|
|
@@ -181,6 +193,26 @@ module Bridge
|
|
|
181
193
|
end
|
|
182
194
|
return nil if parsed.empty?
|
|
183
195
|
|
|
196
|
+
# Worktree-membership-first (intent 168). hook-code-gate passes the edited
|
|
197
|
+
# file as edited_path; when that file lies inside a provisioned code worktree
|
|
198
|
+
# (<repo>/.claude/worktrees/{id}--{slug}), only that worktree's owning intent
|
|
199
|
+
# may gate the write, BEFORE the intent-90 per-session filter below. Resolve
|
|
200
|
+
# the candidate whose worktree.code owns that dir (newest mtime on a tie), or
|
|
201
|
+
# nil when none owns it, so a session-keyed guided bridge can never claim a
|
|
202
|
+
# write located inside a sibling intent's worktree. No edited_path (every
|
|
203
|
+
# other caller) or a non-worktree path skips this and runs the pipeline
|
|
204
|
+
# unchanged (intents 90/52/131 preserved).
|
|
205
|
+
unless blank?(edited_path)
|
|
206
|
+
wt_dir = enclosing_worktree_dir(edited_path)
|
|
207
|
+
if wt_dir
|
|
208
|
+
owners = parsed.select do |c|
|
|
209
|
+
code = c[:data].dig("worktree", "code")
|
|
210
|
+
!blank?(code) && File.expand_path(code) == wt_dir
|
|
211
|
+
end
|
|
212
|
+
return owners.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
184
216
|
has_session = !blank?(session)
|
|
185
217
|
|
|
186
218
|
# Strict per-session ownership (intent 90): when the caller HAS a session, a foreign
|
|
@@ -412,7 +444,7 @@ module Bridge
|
|
|
412
444
|
NEXT_HINTS = {
|
|
413
445
|
"why" => "write spec.md",
|
|
414
446
|
"how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
|
|
415
|
-
"exec" => "How complete. Invoke plastic-auto or plastic-executing
|
|
447
|
+
"exec" => "How complete. Invoke plastic-auto or plastic-intent-executing to execute, or work through the checklist manually.",
|
|
416
448
|
"done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
|
|
417
449
|
}.freeze
|
|
418
450
|
|
|
@@ -918,7 +950,7 @@ module Bridge
|
|
|
918
950
|
|
|
919
951
|
id = intent_info["id"]
|
|
920
952
|
"intent #{id} has not reached How — write plan.md + checklist.md before " \
|
|
921
|
-
"editing project code. Run plastic-auto or plastic-
|
|
953
|
+
"editing project code. Run plastic-auto or plastic-intent-planning first. " \
|
|
922
954
|
"(blocked edit: #{file_abs})"
|
|
923
955
|
end
|
|
924
956
|
|
|
@@ -10,7 +10,7 @@ require_relative "hook_registry"
|
|
|
10
10
|
require_relative "agent_models"
|
|
11
11
|
|
|
12
12
|
# Shared installer machinery, instantiable with injected package root / store / agent
|
|
13
|
-
# map so the verb scripts (install/update/uninstall/
|
|
13
|
+
# map so the verb scripts (install/update/uninstall/rollback) and their tests can run
|
|
14
14
|
# hermetically (no eval, no global-constant rewriting). Mirrors the DI recipe proven in
|
|
15
15
|
# doctor.rb / install.rb (intents 30a, 30a1). Library only — no CLI, no $PROGRAM_NAME guard.
|
|
16
16
|
class InstallerCore
|
|
@@ -54,7 +54,7 @@ class InstallerCore
|
|
|
54
54
|
STABILITY[ch] || 2
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# --- Semver (§11) — parse/compare, shared by update +
|
|
57
|
+
# --- Semver (§11) — parse/compare, shared by update + rollback ---
|
|
58
58
|
|
|
59
59
|
def semver_parse(version)
|
|
60
60
|
m = /\A(\d+)\.(\d+)\.(\d+)(?:-(.+))?\z/.match(version.to_s.strip)
|
|
@@ -286,7 +286,7 @@ class InstallerCore
|
|
|
286
286
|
"scripts/install.rb" => "scripts/install.rb",
|
|
287
287
|
"scripts/update.rb" => "scripts/update.rb",
|
|
288
288
|
"scripts/uninstall.rb" => "scripts/uninstall.rb",
|
|
289
|
-
"scripts/
|
|
289
|
+
"scripts/rollback.rb" => "scripts/rollback.rb",
|
|
290
290
|
"scripts/doctor.rb" => "scripts/doctor.rb",
|
|
291
291
|
"scripts/dashboard.rb" => "scripts/dashboard.rb",
|
|
292
292
|
}
|
|
@@ -471,8 +471,10 @@ class InstallerCore
|
|
|
471
471
|
end
|
|
472
472
|
|
|
473
473
|
# Copy each skills/<name>/ to <skills_root>/plastic-<name>/ (flat, namespaced by
|
|
474
|
-
# directory name
|
|
475
|
-
#
|
|
474
|
+
# directory name -- the only personal-skill namespacing Claude Code supports).
|
|
475
|
+
# Any top-level underscore-prefixed markdown fragment (e.g. `_active-intent-gate.md`,
|
|
476
|
+
# `_decision-tables.md`) is a shared non-skill fragment and relocates to ~/.plastic/
|
|
477
|
+
# instead, so every skill can read it from one shared location.
|
|
476
478
|
def install_skills_flat(skills_source, skills_root)
|
|
477
479
|
installed = []
|
|
478
480
|
FileUtils.mkdir_p(skills_root)
|
|
@@ -481,7 +483,7 @@ class InstallerCore
|
|
|
481
483
|
src = File.join(skills_source, entry)
|
|
482
484
|
if File.directory?(src)
|
|
483
485
|
installed += copy_dir_recursive(src, File.join(skills_root, "plastic-#{entry}"))
|
|
484
|
-
elsif entry
|
|
486
|
+
elsif entry.start_with?("_") && entry.end_with?(".md")
|
|
485
487
|
FileUtils.mkdir_p(plastic_home)
|
|
486
488
|
dest = File.join(plastic_home, entry)
|
|
487
489
|
FileUtils.cp(src, dest)
|
|
@@ -14,7 +14,7 @@ require "yaml"
|
|
|
14
14
|
# `project.yml` (from templates/project.yml). Re-running never clobbers existing
|
|
15
15
|
# files. The logic was migrated here from the orphaned
|
|
16
16
|
# `InstallerCore#bootstrap_project_store` so there is one definition that the
|
|
17
|
-
# `provision-project-store` CLI, the `plastic-
|
|
17
|
+
# `provision-project-store` CLI, the `plastic-store-provisioning` skill, the
|
|
18
18
|
# doctor fix hint, and the project skills all consult.
|
|
19
19
|
#
|
|
20
20
|
# Pure filesystem and dependency-injected: `provision` accepts an injectable
|
package/scripts/new-intent
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
# IntentValidator (exit non-zero if not born complete).
|
|
14
14
|
#
|
|
15
15
|
# It does NOT touch INDEX.md, git, or project creation: those remain skill/agent
|
|
16
|
-
# responsibilities (see plastic-creating
|
|
16
|
+
# responsibilities (see plastic-intent-creating).
|
|
17
17
|
#
|
|
18
18
|
# Usage:
|
|
19
19
|
# new-intent --store <store_path> --intent "<one-line>" --slug <slug> \
|
|
@@ -2,20 +2,24 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
# Plastic — `
|
|
6
|
-
# Usage: ruby scripts/
|
|
5
|
+
# Plastic — `rollback` verb. Runs via `npx @zalom/plastic rollback` (bin/plastic.js) or directly.
|
|
6
|
+
# Usage: ruby scripts/rollback.rb [--version <v> [--downgrade | --upgrade]] [--help]
|
|
7
7
|
#
|
|
8
8
|
# Local time-machine over the append-only versions.json ledger. Navigates ONLY versions you
|
|
9
9
|
# have actually run (the ledger), never npm's full list — fetching brand-new versions is
|
|
10
10
|
# `update`'s job. Lets you roll back to a known-good build after a bad release.
|
|
11
|
-
# - no flag
|
|
12
|
-
# - --
|
|
13
|
-
# - --downgrade --version V
|
|
14
|
-
# - --upgrade
|
|
11
|
+
# - no flag read-only: print the version-history table, never switch
|
|
12
|
+
# - --version V switch to a specific previously-run version (direction derived)
|
|
13
|
+
# - --downgrade --version V synonym of --version V (direction flag is descriptive only)
|
|
14
|
+
# - --upgrade --version V synonym of --version V (direction flag is descriptive only)
|
|
15
|
+
# - --downgrade / --upgrade alone no target given: error, no switch
|
|
16
|
+
#
|
|
17
|
+
# Switching always requires an explicit --version V. A rollback tool whose job is safety
|
|
18
|
+
# during a bad release must never switch versions without a named target.
|
|
15
19
|
|
|
16
20
|
require_relative "lib/installer_core"
|
|
17
21
|
|
|
18
|
-
class
|
|
22
|
+
class Rollback < InstallerCore
|
|
19
23
|
PKG = "@zalom/plastic"
|
|
20
24
|
|
|
21
25
|
def cli(argv = ARGV)
|
|
@@ -32,31 +36,24 @@ class Versions < InstallerCore
|
|
|
32
36
|
|
|
33
37
|
timeline = version_timeline(ledger)
|
|
34
38
|
current = installed_version || timeline.last
|
|
39
|
+
explicit = flag_value(argv, "--version")
|
|
35
40
|
|
|
36
|
-
if
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return no_target("No earlier version in your history.") unless target
|
|
40
|
-
unless timeline.include?(target)
|
|
41
|
-
warn "#{target} is not in your version history. Choose one of: #{timeline.join(", ")}"
|
|
41
|
+
if explicit
|
|
42
|
+
unless timeline.include?(explicit)
|
|
43
|
+
warn "#{explicit} is not in your version history. Choose one of: #{timeline.join(", ")}"
|
|
42
44
|
return 1
|
|
43
45
|
end
|
|
44
|
-
return switch_to(
|
|
45
|
-
elsif argv.include?("--upgrade")
|
|
46
|
-
target = step_target(timeline, current, :forward)
|
|
47
|
-
return no_target("No later version in your history. Use `update` for new releases.") unless target
|
|
48
|
-
return switch_to(target, current)
|
|
46
|
+
return switch_to(explicit, current)
|
|
49
47
|
end
|
|
50
48
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if prev && $stdin.tty?
|
|
56
|
-
print "\nYou recently rolled back. Go back further to #{prev}? [y/N]: "
|
|
57
|
-
return switch_to(prev, current) if ($stdin.gets&.strip || "").downcase.start_with?("y")
|
|
58
|
-
end
|
|
49
|
+
if argv.include?("--downgrade") || argv.include?("--upgrade")
|
|
50
|
+
warn "An explicit target is required: pass --version <v>. Run `rollback` with no " \
|
|
51
|
+
"flags to see your version history and pick a target."
|
|
52
|
+
return 1
|
|
59
53
|
end
|
|
54
|
+
|
|
55
|
+
# No flags at all: read-only. Print the table and stop; never switch, never prompt.
|
|
56
|
+
print_table(ledger, current)
|
|
60
57
|
0
|
|
61
58
|
end
|
|
62
59
|
|
|
@@ -93,11 +90,6 @@ class Versions < InstallerCore
|
|
|
93
90
|
argv[i + 1]
|
|
94
91
|
end
|
|
95
92
|
|
|
96
|
-
def no_target(msg)
|
|
97
|
-
puts msg
|
|
98
|
-
0
|
|
99
|
-
end
|
|
100
|
-
|
|
101
93
|
def switch_to(target, current)
|
|
102
94
|
action = action_for(target, current)
|
|
103
95
|
puts "#{action == "downgrade" ? "\u{23ea}" : "\u{23e9}"} #{current} \u{2192} #{target} (#{action})"
|
|
@@ -119,23 +111,23 @@ class Versions < InstallerCore
|
|
|
119
111
|
def show_help
|
|
120
112
|
puts <<~HELP
|
|
121
113
|
|
|
122
|
-
plastic
|
|
114
|
+
plastic rollback — read installed and available versions, and switch on request
|
|
123
115
|
|
|
124
116
|
Usage:
|
|
125
|
-
npx @zalom/plastic
|
|
117
|
+
npx @zalom/plastic rollback [options]
|
|
126
118
|
|
|
127
119
|
Options:
|
|
128
|
-
(none)
|
|
129
|
-
--
|
|
130
|
-
--downgrade --version V
|
|
131
|
-
--upgrade
|
|
132
|
-
-h, --help
|
|
120
|
+
(none) Show the version-history table (read-only, no switch)
|
|
121
|
+
--version V Switch to version V (direction derived automatically)
|
|
122
|
+
--downgrade --version V Synonym of --version V
|
|
123
|
+
--upgrade --version V Synonym of --version V
|
|
124
|
+
-h, --help Show this help
|
|
133
125
|
|
|
134
|
-
|
|
135
|
-
brand-new release, use `update`.
|
|
126
|
+
Switching always requires an explicit --version V. Navigates only versions you have
|
|
127
|
+
already run (the append-only ledger). To move to a brand-new release, use `update`.
|
|
136
128
|
|
|
137
129
|
HELP
|
|
138
130
|
end
|
|
139
131
|
end
|
|
140
132
|
|
|
141
|
-
exit(
|
|
133
|
+
exit(Rollback.new(package_root: ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__)).cli(ARGV)) if $PROGRAM_NAME == __FILE__
|
package/scripts/update.rb
CHANGED
|
@@ -164,7 +164,7 @@ class Update < InstallerCore
|
|
|
164
164
|
Behaviour:
|
|
165
165
|
No flag advances to the next version on your current channel. Switching toward a
|
|
166
166
|
more stable channel is frictionless; switching toward bleeding edge is confirmed.
|
|
167
|
-
Use `
|
|
167
|
+
Use `rollback` to roll back to a previously-installed version.
|
|
168
168
|
|
|
169
169
|
HELP
|
|
170
170
|
end
|
|
@@ -13,7 +13,7 @@ Before proceeding, resolve the active intent:
|
|
|
13
13
|
- Look under `## Active` for intent entries
|
|
14
14
|
- If exactly one active intent → use it
|
|
15
15
|
- If multiple active intents → ask user which one
|
|
16
|
-
- If no active intent → refuse: "No active intent. Create one first with /plastic-creating
|
|
16
|
+
- If no active intent → refuse: "No active intent. Create one first with /plastic-intent-creating"
|
|
17
17
|
|
|
18
18
|
3. **Resolve paths:**
|
|
19
19
|
- Intent directory: `{store}/store/{id}--{slug}/`
|