@zalom/plastic 1.0.2 → 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 +37 -7
- package/README.md +47 -0
- package/agents/plastic-enforcer.md +3 -3
- package/agents/plastic-intent-curator.md +9 -10
- package/agents/plastic-intent-discovery.md +1 -0
- package/agents/plastic-spec-specialist.md +9 -6
- package/bin/plastic.js +2 -2
- package/package.json +1 -1
- package/scripts/dashboard.rb +2 -2
- package/scripts/doctor.rb +55 -7
- 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 +46 -13
- package/scripts/lib/dashboard_banner.rb +1 -1
- package/scripts/lib/installer_core.rb +8 -6
- package/scripts/lib/lock.rb +3 -3
- 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 +48 -64
- package/skills/auto/evals/evals.json +4 -4
- package/skills/auto/references/agent-architecture.md +2 -2
- package/skills/auto/references/agent-report-contract.md +3 -0
- package/skills/dashboard/SKILL.md +3 -2
- package/skills/doctor/SKILL.md +4 -3
- package/skills/humanizer/SKILL.md +1 -0
- package/skills/install/SKILL.md +2 -1
- package/skills/{brainstorming → intent-brainstorming}/SKILL.md +44 -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 +4 -3
- package/skills/{continuing → intent-continuing}/evals/evals.json +24 -24
- package/skills/{creating-intent → intent-creating}/SKILL.md +4 -3
- package/skills/{creating-intent → intent-creating}/evals/evals.json +1 -1
- package/skills/{intent-discovery → intent-discovering}/SKILL.md +3 -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 +76 -15
- package/skills/intent-executing/evals/evals.json +66 -0
- package/skills/{brainstorming-grill-me → intent-grilling}/SKILL.md +5 -4
- package/skills/{linking-intents → intent-linking}/SKILL.md +2 -1
- package/skills/{linking-intents → intent-linking}/evals/evals.json +1 -1
- package/skills/{lock → intent-locking}/SKILL.md +2 -1
- package/skills/{writing-plans → intent-planning}/SKILL.md +49 -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 +5 -2
- package/skills/{research → intent-researching}/evals/evals.json +1 -1
- package/skills/{savepoint → intent-savepoint}/SKILL.md +2 -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 +3 -2
- package/skills/intent-starting/evals/evals.json +2 -2
- package/skills/{creating-project → project-creating}/SKILL.md +3 -2
- package/skills/releasing/SKILL.md +19 -14
- package/skills/roadmap/SKILL.md +1 -0
- package/skills/{versions → rollback}/SKILL.md +31 -20
- package/skills/{creating-skills → skill-creating}/SKILL.md +3 -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/{creating-skills → skill-creating}/references/skills.md +1 -0
- package/skills/{evaluating-skills → skill-evaluating}/SKILL.md +2 -1
- package/skills/{evaluating-skills → skill-evaluating}/evals/evals.json +3 -3
- package/skills/{intent-curator → store-curating}/SKILL.md +7 -7
- package/skills/{intent-curator → store-curating}/evals/evals.json +1 -1
- package/skills/{managing-index → store-indexing}/SKILL.md +7 -7
- package/skills/{managing-index → store-indexing}/evals/evals.json +1 -1
- package/skills/{add-project-store → store-provisioning}/SKILL.md +3 -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/uninstall/SKILL.md +1 -0
- package/skills/update/SKILL.md +2 -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}/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
|
|
|
@@ -741,17 +773,18 @@ module Bridge
|
|
|
741
773
|
data["lock"] = lock_cache(lock_data)
|
|
742
774
|
when :held
|
|
743
775
|
raise LockHeldError, "delivery lock for intent #{intent_id} is held by " \
|
|
744
|
-
"session #{lock_data && lock_data['owner_session']}; run /plastic-
|
|
776
|
+
"session #{lock_data && lock_data['owner_session']}; run /plastic-doctor " \
|
|
777
|
+
"check the lock status"
|
|
745
778
|
when :stale
|
|
746
779
|
raise LockHeldError, "delivery lock for intent #{intent_id} is stale " \
|
|
747
|
-
"(owner #{lock_data && lock_data['owner_session']}); run /plastic-
|
|
748
|
-
"reclaim to take it over with an audit"
|
|
780
|
+
"(owner #{lock_data && lock_data['owner_session']}); run /plastic-doctor " \
|
|
781
|
+
"reclaim the lock to take it over with an audit"
|
|
749
782
|
when :excluded
|
|
750
783
|
raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
|
|
751
|
-
"intent #{intent_id}; run /plastic-lock status"
|
|
784
|
+
"intent #{intent_id}; run /plastic-doctor check the lock status"
|
|
752
785
|
when :corrupt
|
|
753
786
|
raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
|
|
754
|
-
"run /plastic-
|
|
787
|
+
"run /plastic-doctor fix the lock"
|
|
755
788
|
end
|
|
756
789
|
|
|
757
790
|
# Provision the per-intent worktrees (mandatory code worktree for project
|
|
@@ -861,7 +894,7 @@ module Bridge
|
|
|
861
894
|
end
|
|
862
895
|
return { "status" => "stale", "owner" => lock["owner_session"],
|
|
863
896
|
"actions" => actions, "session" => key,
|
|
864
|
-
"hint" => "run /plastic-
|
|
897
|
+
"hint" => "run /plastic-doctor reclaim the lock to take over with an audit" }
|
|
865
898
|
end
|
|
866
899
|
|
|
867
900
|
if lock
|
|
@@ -917,7 +950,7 @@ module Bridge
|
|
|
917
950
|
|
|
918
951
|
id = intent_info["id"]
|
|
919
952
|
"intent #{id} has not reached How — write plan.md + checklist.md before " \
|
|
920
|
-
"editing project code. Run plastic-auto or plastic-
|
|
953
|
+
"editing project code. Run plastic-auto or plastic-intent-planning first. " \
|
|
921
954
|
"(blocked edit: #{file_abs})"
|
|
922
955
|
end
|
|
923
956
|
|
|
@@ -1002,16 +1035,16 @@ module Bridge
|
|
|
1002
1035
|
"#{lock['owner_session']}. Back off; if you are the owner's " \
|
|
1003
1036
|
"subagent, the owner must run: plastic-lock delegate " \
|
|
1004
1037
|
"--intent-dir #{target_dir} --session <your-session-id>. " \
|
|
1005
|
-
"Inspect with /plastic-lock status"
|
|
1038
|
+
"Inspect with /plastic-doctor check the lock status"
|
|
1006
1039
|
end
|
|
1007
1040
|
return solo_allow(id, "stale delivery lock") if solo
|
|
1008
1041
|
return "intent #{id} has a stale delivery lock (owner " \
|
|
1009
|
-
"#{lock['owner_session']}); run /plastic-
|
|
1010
|
-
"it over, or /plastic-
|
|
1042
|
+
"#{lock['owner_session']}); run /plastic-doctor reclaim the lock to " \
|
|
1043
|
+
"take it over, or /plastic-doctor fix the lock"
|
|
1011
1044
|
end
|
|
1012
1045
|
if Lock.corrupt?(target_dir)
|
|
1013
1046
|
return solo_allow(id, "unreadable delivery.lock") if solo
|
|
1014
|
-
return "delivery.lock for intent #{id} is unreadable; run /plastic-
|
|
1047
|
+
return "delivery.lock for intent #{id} is unreadable; run /plastic-doctor fix the lock"
|
|
1015
1048
|
end
|
|
1016
1049
|
return solo_allow(id, "no delivery lock") if solo
|
|
1017
1050
|
"no delivery lock held for intent #{id}; run /plastic-intent-starting " \
|
|
@@ -20,7 +20,7 @@ module DashboardBanner
|
|
|
20
20
|
return nil unless counts.is_a?(Hash)
|
|
21
21
|
active = counts["active"].to_i
|
|
22
22
|
future = counts["future"].to_i
|
|
23
|
-
line = "Plastic: #{active} active · #{future} next ·
|
|
23
|
+
line = "Plastic: #{active} active · #{future} next · say \"show the dashboard\" to see the board"
|
|
24
24
|
nbt = next_big_thing_id(payload)
|
|
25
25
|
line += " · next big thing: #{nbt}" if nbt
|
|
26
26
|
line
|
|
@@ -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)
|
package/scripts/lib/lock.rb
CHANGED
|
@@ -368,8 +368,8 @@ module Claim
|
|
|
368
368
|
holder = data && data["owner_session"]
|
|
369
369
|
since = data && data["acquired_at"]
|
|
370
370
|
"artifact #{artifact} is claimed by #{holder} since #{since}; another writer holds " \
|
|
371
|
-
"it. Back off or run /plastic-lock status. If you are a distinct
|
|
372
|
-
"owner must register you: plastic-lock delegate --intent-dir
|
|
373
|
-
"--session <your-session-id>"
|
|
371
|
+
"it. Back off or run /plastic-doctor check the lock status. If you are a distinct " \
|
|
372
|
+
"delegate, the owner must register you: plastic-lock delegate --intent-dir " \
|
|
373
|
+
"#{intent_dir} --session <your-session-id>"
|
|
374
374
|
end
|
|
375
375
|
end
|
|
@@ -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> \
|