@zalom/plastic 1.9.0 → 1.11.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 +16 -5
- 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 +25 -8
- 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 +10 -9
- 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
- package/skills/humanizer/SKILL.md +0 -40
- package/skills/humanizer/evals/evals.json +0 -70
- package/skills/humanizer/references/always-on-snippet.md +0 -9
- package/skills/humanizer/references/examples.md +0 -48
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "bridge"
|
|
5
|
+
require_relative "lock"
|
|
6
|
+
require_relative "intent_validator"
|
|
7
|
+
require_relative "spec_header"
|
|
8
|
+
require_relative "outcome_guard"
|
|
9
|
+
require_relative "scaffold_intent"
|
|
10
|
+
|
|
11
|
+
# StartIntent - all logic for `scripts/start-intent` (intent 213, group 2). Boards a
|
|
12
|
+
# session onto an intent: arm the delivery lock, then print a read-only resume-station
|
|
13
|
+
# report. This is the highest-risk surface in this action set because it composes lock
|
|
14
|
+
# arbitration; the rule for that surface is compose the existing primitives, add nothing
|
|
15
|
+
# (spec D6).
|
|
16
|
+
#
|
|
17
|
+
# NEVER REIMPLEMENTS LOCK ARBITRATION. Arming is `Bridge.arm_auto` or `Bridge.arm_guided`
|
|
18
|
+
# and nothing else acquires anything: no new arming logic, no retry loop, no takeover path.
|
|
19
|
+
# This module NEVER calls the Lock module's release or takeover operations. That unguarded
|
|
20
|
+
# read-modify-write gap is parked intent 254's territory; start-intent reports and refuses,
|
|
21
|
+
# it never repairs a lock. Read-only lock inspection (`Lock.path`, `Lock.read`,
|
|
22
|
+
# `Lock.fresh?`, `Lock.authorized?`) is expected and used below.
|
|
23
|
+
#
|
|
24
|
+
# DETERMINISM CRITERION: every value this module prints is read from already-committed
|
|
25
|
+
# files or from `Bridge.arm_*`'s own return value. No model inference, no free-text
|
|
26
|
+
# generation.
|
|
27
|
+
#
|
|
28
|
+
# Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` or `ENV`
|
|
29
|
+
# directly. Seams: `armer:` (a lambda dispatching on mode to the two real Bridge methods),
|
|
30
|
+
# plus session/harness/thread values passed in as arguments rather than read from ENV here.
|
|
31
|
+
# Reading ENV for harness detection happens in the CLI (`scripts/start-intent`) and is
|
|
32
|
+
# passed down.
|
|
33
|
+
module StartIntent
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
EXIT_OK = 0
|
|
37
|
+
EXIT_USAGE = 1
|
|
38
|
+
EXIT_HELD = 3
|
|
39
|
+
EXIT_UNRESOLVED = 4
|
|
40
|
+
|
|
41
|
+
# --- Step 0: resolve inputs -------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
# `name:` for the arm call, deterministically: the intent file frontmatter's `intent:`
|
|
44
|
+
# field, or the directory basename when the frontmatter is unreadable or blank. Never
|
|
45
|
+
# invents a name.
|
|
46
|
+
def resolve_name(intent_dir)
|
|
47
|
+
fm = IntentValidator.parse_frontmatter(Bridge.intent_file(intent_dir))
|
|
48
|
+
name = fm.is_a?(Hash) ? fm["intent"] : nil
|
|
49
|
+
Bridge.blank?(name) ? File.basename(intent_dir) : name
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Session resolution order: explicit --session, else CLAUDE_CODE_SESSION_ID (passed in
|
|
53
|
+
# as env_session, never read from ENV here), else nil. Deliberately NO fallback to the
|
|
54
|
+
# lock's own recorded owner (unlike end-intent's resolve_end_session): arming as an
|
|
55
|
+
# owner you cannot prove you are is wrong.
|
|
56
|
+
def resolve_session(explicit, env_session)
|
|
57
|
+
return explicit.to_s.strip unless Bridge.blank?(explicit)
|
|
58
|
+
return env_session.to_s.strip unless Bridge.blank?(env_session)
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Harness/thread detection, mirroring skills/auto/SKILL.md's arm-the-lifecycle-gate
|
|
63
|
+
# snippet exactly: a non-empty CODEX_THREAD_ID means codex (thread = that id); else a
|
|
64
|
+
# non-empty CLAUDE_CODE_SESSION_ID means claude (thread = nil); else both nil. Pure:
|
|
65
|
+
# takes the raw ENV strings as arguments, never reads ENV itself.
|
|
66
|
+
def resolve_harness(codex_thread_id, claude_session_id)
|
|
67
|
+
codex = codex_thread_id.to_s.strip
|
|
68
|
+
claude = claude_session_id.to_s.strip
|
|
69
|
+
return { harness: "codex", thread: codex } unless codex.empty?
|
|
70
|
+
return { harness: "claude", thread: nil } unless claude.empty?
|
|
71
|
+
{ harness: nil, thread: nil }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# --- Step 1: pre-flight lock read (read-only, before arming) ---------------------
|
|
75
|
+
#
|
|
76
|
+
# Mirrors the READ-ONLY half of scripts/end-intent's preflight_lock_verdict, minus its
|
|
77
|
+
# takeover branch (start-intent never takes over). Returns [verdict, lock_or_nil]:
|
|
78
|
+
# :proceed no lock, or this session already owns/delegates it
|
|
79
|
+
# :corrupt a lock file exists but Lock.read cannot parse it (exit 4, unconditional;
|
|
80
|
+
# unlike end-intent, freshness is never consulted for a corrupt lock,
|
|
81
|
+
# because there is no repair path here to reach for a stale one)
|
|
82
|
+
# :no_session a lock file exists and no session identity resolved from any source
|
|
83
|
+
# (exit 4)
|
|
84
|
+
# :held a FRESH foreign lock (exit 4... exit 3, before ever calling the armer)
|
|
85
|
+
# :stale a STALE foreign lock: fall through to arming, which arbitrates it (and
|
|
86
|
+
# raises Bridge::LockHeldError with the reclaim hint) itself
|
|
87
|
+
def preflight_verdict(intent_dir, session)
|
|
88
|
+
return [:proceed, nil] unless File.exist?(Lock.path(intent_dir))
|
|
89
|
+
|
|
90
|
+
lock = Lock.read(intent_dir)
|
|
91
|
+
return [:corrupt, nil] if lock.nil?
|
|
92
|
+
return [:no_session, lock] if Bridge.blank?(session)
|
|
93
|
+
return [:proceed, lock] if Lock.authorized?(lock, session)
|
|
94
|
+
return [:held, lock] if Lock.fresh?(intent_dir)
|
|
95
|
+
[:stale, lock]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def corrupt_message(intent_dir)
|
|
99
|
+
"start-intent: the delivery lock at #{Lock.path(intent_dir)} exists but its content " \
|
|
100
|
+
"will not parse (corrupt); refusing to arm since another session may still be " \
|
|
101
|
+
"heartbeating it. Run /plastic-doctor check the lock status, or plastic-lock fix " \
|
|
102
|
+
"once it is confirmed safe."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def no_session_message(intent_dir)
|
|
106
|
+
"start-intent: a delivery lock exists at #{Lock.path(intent_dir)} but no session " \
|
|
107
|
+
"identity could be resolved (--session or CLAUDE_CODE_SESSION_ID are both blank); " \
|
|
108
|
+
"refusing rather than guessing. Pass --session explicitly."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def held_message(intent_dir, lock, harness:)
|
|
112
|
+
owner = lock.is_a?(Hash) ? lock["owner_session"] : nil
|
|
113
|
+
"start-intent: delivery lock for #{File.basename(intent_dir)} is held by a live " \
|
|
114
|
+
"session (#{owner}); run #{Bridge.skill_ref('plastic-doctor', harness: harness)} " \
|
|
115
|
+
"check the lock status"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# --- Step 2: arm -------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
# Default arm seam: dispatches on mode to the two real Bridge methods. Nothing else
|
|
121
|
+
# acquires anything.
|
|
122
|
+
def default_armer
|
|
123
|
+
lambda do |mode, session:, intent_id:, intent_dir:, store:, name:, harness:, agent:,
|
|
124
|
+
model:, thread:|
|
|
125
|
+
case mode
|
|
126
|
+
when "auto"
|
|
127
|
+
Bridge.arm_auto(session, intent_id: intent_id, intent_dir: intent_dir, store: store,
|
|
128
|
+
name: name, harness: harness, agent: agent, model: model, thread: thread)
|
|
129
|
+
when "guided"
|
|
130
|
+
Bridge.arm_guided(session, intent_id: intent_id, intent_dir: intent_dir, store: store,
|
|
131
|
+
name: name, harness: harness, agent: agent, model: model, thread: thread)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# --- Step 3: the resume-station report (read-only, after arming) -----------------
|
|
137
|
+
|
|
138
|
+
# Count-only helper for the printed "actions/: <n> real action file(s)" line. The
|
|
139
|
+
# STATION gate (whether How is delivered) is decided exclusively by
|
|
140
|
+
# Bridge.has_real_action?, called separately in build_report; this exists only to render
|
|
141
|
+
# a number and must never be used for any pass/fail decision.
|
|
142
|
+
def real_action_count(intent_dir)
|
|
143
|
+
Dir.glob("#{intent_dir}/actions/*.md").count do |f|
|
|
144
|
+
File.file?(f) && File.size(f) > 0 && Bridge.stage_file_present?(f)
|
|
145
|
+
end
|
|
146
|
+
rescue StandardError
|
|
147
|
+
0
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Classify outcome.md into one of three states, per the spec's outcome.md nuance:
|
|
151
|
+
# `scaffold-intent outcome` writes a real (non-sentinel) outcome.md while leaving the
|
|
152
|
+
# frontmatter disposition at the template placeholder `delivered|abandoned`, so a naive
|
|
153
|
+
# sentinel check would misreport that as Done. Returns [state, reason]:
|
|
154
|
+
# "not_started" the sentinel is still present (or the file is absent), reason nil
|
|
155
|
+
# "authored" sentinel gone AND OutcomeGuard.reason(intent_dir, disposition) is nil
|
|
156
|
+
# for EITHER "delivered" or "abandoned" (BLOCKING 3 fix: a fully-authored
|
|
157
|
+
# abandoned outcome.md is authored too; checking "delivered" only used to
|
|
158
|
+
# misreport it as "scaffolded, not authored" and point a resuming session
|
|
159
|
+
# back into Exec)
|
|
160
|
+
# "scaffolded" sentinel gone AND OutcomeGuard.reason(...) returns a reason string for
|
|
161
|
+
# BOTH dispositions
|
|
162
|
+
# OutcomeGuard.reason is the single existing definition of "is outcome.md real for this
|
|
163
|
+
# close"; this never re-derives that definition.
|
|
164
|
+
def classify_outcome(intent_dir)
|
|
165
|
+
outcome_path = File.join(intent_dir, "outcome.md")
|
|
166
|
+
return ["not_started", nil] unless Bridge.stage_file_present?(outcome_path)
|
|
167
|
+
|
|
168
|
+
delivered_reason = OutcomeGuard.reason(intent_dir, "delivered")
|
|
169
|
+
return ["authored", nil] if delivered_reason.nil?
|
|
170
|
+
|
|
171
|
+
abandoned_reason = OutcomeGuard.reason(intent_dir, "abandoned")
|
|
172
|
+
return ["authored", nil] if abandoned_reason.nil?
|
|
173
|
+
|
|
174
|
+
# Neither disposition's guard passed: report the "delivered" reason, since it is the
|
|
175
|
+
# more common close path and OutcomeGuard.reason's message already names whatever
|
|
176
|
+
# disposition is actually recorded in the frontmatter (or that the file is missing or
|
|
177
|
+
# still the scaffold placeholder), so it stays informative either way.
|
|
178
|
+
["scaffolded", delivered_reason]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def settled_text(header)
|
|
182
|
+
return "no" unless header[:settled]
|
|
183
|
+
header[:settled_reason] ? "yes (#{header[:settled_reason]})" : "yes"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Build the resume-station report. Every value comes from already-committed files
|
|
187
|
+
# (Bridge.stage_file_present?, Bridge.has_real_action?, SpecHeader, OutcomeGuard) or from
|
|
188
|
+
# `data`, the Hash Bridge.arm_* itself returned. No model inference, no free text beyond
|
|
189
|
+
# the fixed shape below.
|
|
190
|
+
def build_report(intent_dir:, mode:, data:)
|
|
191
|
+
spec_path = File.join(intent_dir, "spec.md")
|
|
192
|
+
plan_path = File.join(intent_dir, "plan.md")
|
|
193
|
+
checklist_path = File.join(intent_dir, "checklist.md")
|
|
194
|
+
|
|
195
|
+
spec_delivered = Bridge.stage_file_present?(spec_path)
|
|
196
|
+
plan_delivered = Bridge.stage_file_present?(plan_path)
|
|
197
|
+
checklist_delivered = Bridge.stage_file_present?(checklist_path)
|
|
198
|
+
has_action = Bridge.has_real_action?(intent_dir)
|
|
199
|
+
how_triple = plan_delivered && checklist_delivered && has_action
|
|
200
|
+
|
|
201
|
+
outcome_state, outcome_reason = classify_outcome(intent_dir)
|
|
202
|
+
|
|
203
|
+
resume_at =
|
|
204
|
+
if !spec_delivered
|
|
205
|
+
"Why"
|
|
206
|
+
elsif !how_triple
|
|
207
|
+
"How"
|
|
208
|
+
elsif outcome_state != "authored"
|
|
209
|
+
"Exec"
|
|
210
|
+
else
|
|
211
|
+
"Done"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
header = SpecHeader.parse_file(spec_path)
|
|
215
|
+
tier = header[:tier] || "unknown"
|
|
216
|
+
|
|
217
|
+
worktree_code = data.dig("worktree", "code")
|
|
218
|
+
worktree_line = Bridge.blank?(worktree_code) ? "not provisioned" : worktree_code
|
|
219
|
+
|
|
220
|
+
outcome_line =
|
|
221
|
+
case outcome_state
|
|
222
|
+
when "authored" then "authored"
|
|
223
|
+
when "scaffolded" then "scaffolded, not authored (#{outcome_reason})"
|
|
224
|
+
else "not started"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
lines = []
|
|
228
|
+
lines << "start-intent: #{File.basename(intent_dir)}"
|
|
229
|
+
lines << " mode: #{mode}"
|
|
230
|
+
lines << " session: #{data['session']}"
|
|
231
|
+
lines << " lock: held by #{data.dig('lock', 'owner_session')} (ours)"
|
|
232
|
+
lines << " worktree: #{worktree_line}"
|
|
233
|
+
lines << " tier: #{tier}"
|
|
234
|
+
lines << " settled: #{settled_text(header)}"
|
|
235
|
+
lines << " spec.md: #{spec_delivered ? 'delivered' : 'not started'}"
|
|
236
|
+
lines << " plan.md: #{plan_delivered ? 'delivered' : 'not started'}"
|
|
237
|
+
lines << " checklist.md: #{checklist_delivered ? 'delivered' : 'not started'}"
|
|
238
|
+
lines << " actions/: #{real_action_count(intent_dir)} real action file(s)"
|
|
239
|
+
lines << " outcome.md: #{outcome_line}"
|
|
240
|
+
lines << " resume at: #{resume_at}"
|
|
241
|
+
lines.join("\n")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# --- entry point --------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def usage_result(message)
|
|
247
|
+
{ exit_code: EXIT_USAGE, stderr: [message], stdout: [] }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def deny_result(code, message)
|
|
251
|
+
{ exit_code: code, stderr: [message], stdout: [] }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def run(store:, id:, mode:, session:, env_session:, harness:, thread:, armer: default_armer)
|
|
255
|
+
return usage_result("--store is required") if Bridge.blank?(store)
|
|
256
|
+
return usage_result("--id is required") if Bridge.blank?(id)
|
|
257
|
+
return usage_result("--mode is required") if Bridge.blank?(mode)
|
|
258
|
+
unless %w[auto guided].include?(mode)
|
|
259
|
+
return usage_result("--mode must be one of auto, guided (got #{mode.inspect})")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
store_abs = ScaffoldIntent.expand(store)
|
|
263
|
+
return usage_result("store dir does not exist: #{store_abs}") unless Dir.exist?(store_abs)
|
|
264
|
+
|
|
265
|
+
intent_dir, resolve_err = ScaffoldIntent.resolve_intent_dir(store_abs, id)
|
|
266
|
+
return usage_result(resolve_err) if intent_dir.nil?
|
|
267
|
+
|
|
268
|
+
name = resolve_name(intent_dir)
|
|
269
|
+
key_session = resolve_session(session, env_session)
|
|
270
|
+
|
|
271
|
+
verdict, lock = preflight_verdict(intent_dir, key_session)
|
|
272
|
+
case verdict
|
|
273
|
+
when :corrupt
|
|
274
|
+
return deny_result(EXIT_UNRESOLVED, corrupt_message(intent_dir))
|
|
275
|
+
when :no_session
|
|
276
|
+
return deny_result(EXIT_UNRESOLVED, no_session_message(intent_dir))
|
|
277
|
+
when :held
|
|
278
|
+
return deny_result(EXIT_HELD, held_message(intent_dir, lock, harness: harness))
|
|
279
|
+
end
|
|
280
|
+
# :proceed and :stale both fall through to arming; Bridge.arm_* arbitrates :stale
|
|
281
|
+
# itself (and raises Bridge::LockHeldError with the reclaim hint) via its own
|
|
282
|
+
# up-to-date Lock.acquire call.
|
|
283
|
+
|
|
284
|
+
data =
|
|
285
|
+
begin
|
|
286
|
+
armer.call(mode, session: key_session, intent_id: id, intent_dir: intent_dir,
|
|
287
|
+
store: store_abs, name: name, harness: harness, agent: nil, model: nil,
|
|
288
|
+
thread: thread)
|
|
289
|
+
rescue Bridge::LockHeldError => e
|
|
290
|
+
return deny_result(EXIT_HELD, e.message)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
report = build_report(intent_dir: intent_dir, mode: mode, data: data)
|
|
294
|
+
{ exit_code: EXIT_OK, stderr: [], stdout: [report] }
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "worktree"
|
|
5
|
+
require_relative "scaffold_intent"
|
|
6
|
+
|
|
7
|
+
# VerifyIntent - all logic for `scripts/verify-intent` (intent 213). Bundles the checks that
|
|
8
|
+
# need no project-specific knowledge into one verdict: the per-intent doctor scan
|
|
9
|
+
# (`Doctor#run_intent_check`), a net-new added-line em-dash diff guard (the first standing
|
|
10
|
+
# implementation of this check; the only prior automated em-dash check,
|
|
11
|
+
# `test/skill_command_lint_test.rb`, asserts against two FIXED file sets and cannot scan a
|
|
12
|
+
# diff), a diffstat, and an optional caller-supplied `--suite` command folded into the same
|
|
13
|
+
# verdict.
|
|
14
|
+
#
|
|
15
|
+
# Repo resolution and base-branch detection are NOT re-implemented here: `resolve_repo_dir`,
|
|
16
|
+
# `detect_base_branch`, and `diffstat` are `ScaffoldIntent`'s own shared git seam helpers
|
|
17
|
+
# (built for `scripts/scaffold-intent`, intent 213 ACTION_2); this module calls them so
|
|
18
|
+
# there is exactly one implementation of each in the repo.
|
|
19
|
+
#
|
|
20
|
+
# Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV`. The public
|
|
21
|
+
# entry point (`run`) returns a verdict hash; `scripts/verify-intent` maps it to stdout lines
|
|
22
|
+
# plus an exit code. Seams: `git_runner:` (defaults to `Worktree::ShellRunner.new`),
|
|
23
|
+
# `doctor:` (a lambda, defaults to a real `Doctor#run_intent_check` call), `suite_runner:` (a
|
|
24
|
+
# lambda, defaults to a real subprocess with `RUBYOPT` cleared).
|
|
25
|
+
module VerifyIntent
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Built from its codepoint, never typed as a literal byte, so this file's own source stays
|
|
29
|
+
# free of the character it is written to detect (matching scripts/end-intent's own
|
|
30
|
+
# EM_DASH constant, built the same way).
|
|
31
|
+
EM_DASH = "\u2014"
|
|
32
|
+
|
|
33
|
+
EXIT_OK = 0
|
|
34
|
+
EXIT_USAGE = 1
|
|
35
|
+
EXIT_DOCTOR = 2
|
|
36
|
+
EXIT_EMDASH = 3
|
|
37
|
+
EXIT_SUITE = 4
|
|
38
|
+
|
|
39
|
+
# --- entry point ---------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def run(store:, id:, base: nil, suite: nil, home: Dir.home,
|
|
42
|
+
git_runner: Worktree::ShellRunner.new, doctor: default_doctor, suite_runner: default_suite_runner)
|
|
43
|
+
return usage_result("--store is required") if Worktree.blank?(store)
|
|
44
|
+
return usage_result("--id is required") if Worktree.blank?(id)
|
|
45
|
+
|
|
46
|
+
intent_dir, resolve_err = ScaffoldIntent.resolve_intent_dir(store, id)
|
|
47
|
+
return usage_result(resolve_err) if intent_dir.nil?
|
|
48
|
+
|
|
49
|
+
gate_home, scope = resolve_plastic_home_and_scope(store)
|
|
50
|
+
|
|
51
|
+
lines = []
|
|
52
|
+
checks = {}
|
|
53
|
+
codes = []
|
|
54
|
+
|
|
55
|
+
doctor_lines, doctor_code, doctor_check = run_doctor_check(home: gate_home, scope: scope, id: id, doctor: doctor)
|
|
56
|
+
lines.concat(doctor_lines)
|
|
57
|
+
checks[:doctor] = doctor_check
|
|
58
|
+
codes << doctor_code if doctor_code
|
|
59
|
+
|
|
60
|
+
repo = ScaffoldIntent.resolve_repo_dir(store: store, id: id, intent_dir: intent_dir, home: home, runner: git_runner)
|
|
61
|
+
base_ref = base
|
|
62
|
+
base_ref ||= repo ? ScaffoldIntent.detect_base_branch(repo, runner: git_runner) : nil
|
|
63
|
+
|
|
64
|
+
emdash_lines, emdash_code, emdash_check = run_emdash_check(repo: repo, base: base_ref, git_runner: git_runner)
|
|
65
|
+
lines.concat(emdash_lines)
|
|
66
|
+
checks[:emdash] = emdash_check
|
|
67
|
+
codes << emdash_code if emdash_code
|
|
68
|
+
|
|
69
|
+
diffstat_lines, diffstat_check = run_diffstat_check(repo: repo, base: base_ref, git_runner: git_runner)
|
|
70
|
+
lines.concat(diffstat_lines)
|
|
71
|
+
checks[:diffstat] = diffstat_check
|
|
72
|
+
|
|
73
|
+
if Worktree.blank?(suite)
|
|
74
|
+
checks[:suite] = { status: "skipped" }
|
|
75
|
+
else
|
|
76
|
+
suite_lines, suite_code, suite_check = run_suite_check(suite: suite, repo: repo, suite_runner: suite_runner)
|
|
77
|
+
lines.concat(suite_lines)
|
|
78
|
+
checks[:suite] = suite_check
|
|
79
|
+
codes << suite_code if suite_code
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{ exit_code: codes.compact.min || EXIT_OK, lines: lines, checks: checks }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def usage_result(message)
|
|
86
|
+
{ exit_code: EXIT_USAGE, lines: ["verify-intent: #{message}"], checks: {} }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# --- store/scope resolution (mirrors scripts/end-intent:161-170) ---------------
|
|
90
|
+
|
|
91
|
+
# `store` is `<plastic_home>/store` or `<plastic_home>/projects/<slug>/store`. The two
|
|
92
|
+
# shapes are told apart by whether store's grandparent is literally named "projects" (the
|
|
93
|
+
# StoreDiscovery convention), never by hardcoding ".plastic" as a name.
|
|
94
|
+
def resolve_plastic_home_and_scope(store)
|
|
95
|
+
parent = File.dirname(store)
|
|
96
|
+
grandparent = File.dirname(parent)
|
|
97
|
+
if File.basename(grandparent) == "projects"
|
|
98
|
+
[File.dirname(grandparent), "project:#{File.basename(parent)}"]
|
|
99
|
+
else
|
|
100
|
+
[parent, "global"]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- check 1: doctor, scoped to the intent --------------------------------------
|
|
105
|
+
|
|
106
|
+
def default_doctor
|
|
107
|
+
lambda do |home:, scope:, id:|
|
|
108
|
+
require_relative "../doctor"
|
|
109
|
+
Doctor.new(plastic_home: home, runner: Doctor.default_runner).run_intent_check(id, store: scope)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Fail-open on any crash (a broken diagnostic must never wedge a verify): the check is
|
|
114
|
+
# skipped, not failed.
|
|
115
|
+
def run_doctor_check(home:, scope:, id:, doctor:)
|
|
116
|
+
verdict = doctor.call(home: home, scope: scope, id: id)
|
|
117
|
+
case verdict[:status]
|
|
118
|
+
when "fail"
|
|
119
|
+
failing = Array(verdict[:checks]).select { |c| c[:status] == "fail" }
|
|
120
|
+
lines = failing.map { |c| "doctor: FAIL #{c[:name]}: #{c[:message]}" }
|
|
121
|
+
lines = ["doctor: FAIL"] if lines.empty?
|
|
122
|
+
[lines, EXIT_DOCTOR, { status: "fail", verdict: verdict }]
|
|
123
|
+
when "warn"
|
|
124
|
+
warning = Array(verdict[:checks]).select { |c| c[:status] == "warn" }
|
|
125
|
+
lines = warning.map { |c| "doctor: WARN #{c[:name]}: #{c[:message]}" }
|
|
126
|
+
lines = ["doctor: WARN"] if lines.empty?
|
|
127
|
+
[lines, nil, { status: "warn", verdict: verdict }]
|
|
128
|
+
else
|
|
129
|
+
[["doctor: pass"], nil, { status: "pass", verdict: verdict }]
|
|
130
|
+
end
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
[["doctor crashed (#{e.message}); proceeding without it"], nil, { status: "crashed", error: e.message }]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# --- check 2: the added-line em-dash diff guard (net-new, D9) -------------------
|
|
136
|
+
|
|
137
|
+
# Store and intent files are exempt from the no-em-dash rule (AGENTS.md states this
|
|
138
|
+
# explicitly), and INDEX.md is REQUIRED to carry a real em dash on write
|
|
139
|
+
# (scripts/end-intent:82-88). No per-file allow-list beyond these three shapes.
|
|
140
|
+
def exempt_path?(path)
|
|
141
|
+
return true if path.nil?
|
|
142
|
+
return true if path.start_with?("store/")
|
|
143
|
+
return true if path.split("/").include?(".plastic")
|
|
144
|
+
return true if path == "INDEX.md" || path.end_with?("/INDEX.md")
|
|
145
|
+
return true if path.start_with?("test/fixtures/")
|
|
146
|
+
|
|
147
|
+
false
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
HUNK_HEADER_RE = /\A@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
|
|
151
|
+
|
|
152
|
+
# Scan ONLY the added lines of a unified diff (never a removed line, never a context
|
|
153
|
+
# line, never the `+++ ` file header line itself). An added line starts with exactly one
|
|
154
|
+
# `+` and is not the file header; per the spec this is decided with
|
|
155
|
+
# `l.start_with?("+") && !l.start_with?("+++")`. Tracks the current file from the most
|
|
156
|
+
# recent `+++ b/<path>` header and the current new-file line number from the most recent
|
|
157
|
+
# `@@ -a,b +c,d @@` hunk header, incrementing on every context or added line (never on a
|
|
158
|
+
# removed line, which does not exist in the new file).
|
|
159
|
+
def em_dash_violations(diff_text)
|
|
160
|
+
violations = []
|
|
161
|
+
current_file = nil
|
|
162
|
+
current_line = nil
|
|
163
|
+
exempt = false
|
|
164
|
+
|
|
165
|
+
diff_text.to_s.each_line do |line|
|
|
166
|
+
if line.start_with?("+++")
|
|
167
|
+
current_file = line.sub(/\A\+\+\+\s*/, "").strip.sub(%r{\Ab/}, "")
|
|
168
|
+
exempt = exempt_path?(current_file)
|
|
169
|
+
next
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if (m = line.match(HUNK_HEADER_RE))
|
|
173
|
+
current_line = m[1].to_i
|
|
174
|
+
next
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if line.start_with?("+")
|
|
178
|
+
text = line[1..].to_s.chomp
|
|
179
|
+
violations << { file: current_file, line: current_line, text: text } if !exempt && text.include?(EM_DASH)
|
|
180
|
+
current_line += 1 if current_line
|
|
181
|
+
elsif line.start_with?("-")
|
|
182
|
+
# A removed line does not exist in the new file: never scanned, never counted.
|
|
183
|
+
elsif line.start_with?(" ")
|
|
184
|
+
current_line += 1 if current_line
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
violations
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def run_emdash_check(repo:, base:, git_runner:)
|
|
192
|
+
if repo.nil?
|
|
193
|
+
return [["em-dash guard skipped: no repo could be resolved for this intent"], nil,
|
|
194
|
+
{ status: "skipped", reason: "no repo" }]
|
|
195
|
+
end
|
|
196
|
+
if base.nil?
|
|
197
|
+
return [["em-dash guard skipped: no base branch could be detected (no origin/HEAD, main, or master)"], nil,
|
|
198
|
+
{ status: "skipped", reason: "no base" }]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
res = git_runner.run("-C", repo, "diff", "#{base}...HEAD")
|
|
202
|
+
unless res.success?
|
|
203
|
+
reason = res.stderr.to_s.strip
|
|
204
|
+
return [["em-dash guard skipped: #{reason}"], nil, { status: "skipped", reason: reason }]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
violations = em_dash_violations(res.stdout)
|
|
208
|
+
if violations.empty?
|
|
209
|
+
[["em-dash guard: pass (0 violations)"], nil, { status: "pass", violations: [] }]
|
|
210
|
+
else
|
|
211
|
+
lines = ["em-dash guard: #{violations.size} violation(s) found"]
|
|
212
|
+
lines.concat(violations.map { |v| "#{v[:file]}:#{v[:line]}: #{v[:text]}" })
|
|
213
|
+
[lines, EXIT_EMDASH, { status: "fail", violations: violations }]
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# --- check 3: the diffstat -------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def run_diffstat_check(repo:, base:, git_runner:)
|
|
220
|
+
if repo.nil?
|
|
221
|
+
return [["diffstat unavailable: no repo could be resolved for this intent"], { status: "skipped" }]
|
|
222
|
+
end
|
|
223
|
+
if base.nil?
|
|
224
|
+
return [["diffstat unavailable: no base branch could be detected (no origin/HEAD, main, or master)"],
|
|
225
|
+
{ status: "skipped" }]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
stat, err = ScaffoldIntent.diffstat(repo, base, runner: git_runner)
|
|
229
|
+
if stat.nil?
|
|
230
|
+
[["diffstat unavailable: #{err}"], { status: "skipped" }]
|
|
231
|
+
else
|
|
232
|
+
lines = ["diffstat against #{base}:"]
|
|
233
|
+
lines.concat(stat.each_line.map(&:chomp))
|
|
234
|
+
[lines, { status: "pass", stat: stat }]
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# --- check 4: the optional suite --------------------------------------------------
|
|
239
|
+
|
|
240
|
+
# The supplied command is very likely a ruby command, so it is spawned with RUBYOPT
|
|
241
|
+
# cleared, matching the pattern at scripts/maintenance-run:133.
|
|
242
|
+
def default_suite_runner
|
|
243
|
+
lambda do |command, dir|
|
|
244
|
+
require "open3"
|
|
245
|
+
out, status = Open3.capture2e({ "RUBYOPT" => nil }, command, chdir: dir || Dir.pwd)
|
|
246
|
+
[out, status.exitstatus]
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def run_suite_check(suite:, repo:, suite_runner:)
|
|
251
|
+
output, status = suite_runner.call(suite, repo)
|
|
252
|
+
lines = ["suite: #{suite}"]
|
|
253
|
+
lines.concat(output.to_s.each_line.map(&:chomp))
|
|
254
|
+
lines << "suite: exit #{status}"
|
|
255
|
+
|
|
256
|
+
if status.to_i.zero?
|
|
257
|
+
[lines, nil, { status: "pass", exit_status: status }]
|
|
258
|
+
else
|
|
259
|
+
[lines, EXIT_SUITE, { status: "fail", exit_status: status }]
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
package/scripts/lib/worktree.rb
CHANGED
|
@@ -166,7 +166,21 @@ module Worktree
|
|
|
166
166
|
# fails open on provisioned: false (non-git / global-only).
|
|
167
167
|
block["provisioned"] = code_ok
|
|
168
168
|
|
|
169
|
-
|
|
169
|
+
# Intent 230: only OVERWRITE the worktree block when the code worktree was
|
|
170
|
+
# actually added. On failure, keep a pointer the caller already had rather
|
|
171
|
+
# than erasing it -- clearing it is what sent code edits into the shared
|
|
172
|
+
# checkout. A pointer whose directory is gone is worse than none, so it is
|
|
173
|
+
# replaced by the fresh unprovisioned block; the guard must not fail harder
|
|
174
|
+
# than the bug it guards.
|
|
175
|
+
if code_ok
|
|
176
|
+
bridge_data["worktree"] = block
|
|
177
|
+
else
|
|
178
|
+
existing = bridge_data["worktree"]
|
|
179
|
+
keep = existing.is_a?(Hash) && !blank?(existing["code"]) &&
|
|
180
|
+
Dir.exist?(existing["code"].to_s)
|
|
181
|
+
bridge_data["worktree"] = block unless keep
|
|
182
|
+
end
|
|
183
|
+
|
|
170
184
|
bridge_data
|
|
171
185
|
end
|
|
172
186
|
|
package/scripts/link-suggest
CHANGED
|
@@ -195,7 +195,7 @@ module LinkSuggestCLI
|
|
|
195
195
|
def qmd_search_ids(query, nodes)
|
|
196
196
|
return [] if query.to_s.strip.empty?
|
|
197
197
|
|
|
198
|
-
out = `ruby #{File.join(Dir.home, ".plastic", "scripts", "qmd-sync")} search #{shell_quote(query)} 2>/dev/null`
|
|
198
|
+
out = `env -u RUBYOPT ruby #{File.join(Dir.home, ".plastic", "scripts", "qmd-sync")} search #{shell_quote(query)} 2>/dev/null`
|
|
199
199
|
return [] if out.nil? || out.empty?
|
|
200
200
|
|
|
201
201
|
out.scan(/\b([0-9]+[a-z0-9]*)\b/).flatten.uniq.select { |id| nodes.key?(id) }
|
package/scripts/maintenance-run
CHANGED
|
@@ -130,7 +130,7 @@ def run_project_links(home, intent, store, apply)
|
|
|
130
130
|
base_args += ["--store", store] if store
|
|
131
131
|
|
|
132
132
|
unless apply
|
|
133
|
-
system(RbConfig.ruby, tool_path, *base_args, "--dry-run")
|
|
133
|
+
system({"RUBYOPT" => nil}, RbConfig.ruby, tool_path, *base_args, "--dry-run")
|
|
134
134
|
exit($?.exitstatus)
|
|
135
135
|
end
|
|
136
136
|
|
|
@@ -139,7 +139,7 @@ def run_project_links(home, intent, store, apply)
|
|
|
139
139
|
repo_dir: home, branch_name: "maintenance/project-links-#{intent}-#{stamp}",
|
|
140
140
|
commit_message: "chore: maintenance - project-links --intent #{intent}"
|
|
141
141
|
) do
|
|
142
|
-
ok = system(RbConfig.ruby, tool_path, *base_args)
|
|
142
|
+
ok = system({"RUBYOPT" => nil}, RbConfig.ruby, tool_path, *base_args)
|
|
143
143
|
raise "project-links failed for #{intent}" unless ok
|
|
144
144
|
end
|
|
145
145
|
rescue MaintenanceGit::DirtyWorkingTree, MaintenanceGit::NotAGitRepo => e
|
|
@@ -171,7 +171,7 @@ def run_rebuild_graph(home, apply)
|
|
|
171
171
|
repo_dir: home, branch_name: "maintenance/rebuild-graph-#{stamp}",
|
|
172
172
|
commit_message: "chore: maintenance - rebuild-graph"
|
|
173
173
|
) do
|
|
174
|
-
ok = system(RbConfig.ruby, tool_path, "--plastic-home", home)
|
|
174
|
+
ok = system({"RUBYOPT" => nil}, RbConfig.ruby, tool_path, "--plastic-home", home)
|
|
175
175
|
raise "rebuild-graph failed" unless ok
|
|
176
176
|
end
|
|
177
177
|
rescue MaintenanceGit::DirtyWorkingTree, MaintenanceGit::NotAGitRepo => e
|
|
@@ -200,7 +200,7 @@ def run_restore_intent_v1(home, id, at, store, apply, skip_links)
|
|
|
200
200
|
args << "--skip-links" if skip_links
|
|
201
201
|
|
|
202
202
|
unless apply
|
|
203
|
-
system(RbConfig.ruby, *args)
|
|
203
|
+
system({"RUBYOPT" => nil}, RbConfig.ruby, *args)
|
|
204
204
|
exit($?.exitstatus)
|
|
205
205
|
end
|
|
206
206
|
|
|
@@ -209,7 +209,7 @@ def run_restore_intent_v1(home, id, at, store, apply, skip_links)
|
|
|
209
209
|
repo_dir: home, branch_name: "maintenance/restore-intent-v1-#{id}-#{stamp}",
|
|
210
210
|
commit_message: "chore: maintenance - restore-intent-v1 #{id}"
|
|
211
211
|
) do
|
|
212
|
-
ok = system(RbConfig.ruby, *args, "--apply")
|
|
212
|
+
ok = system({"RUBYOPT" => nil}, RbConfig.ruby, *args, "--apply")
|
|
213
213
|
raise "restore-intent-v1 failed for #{id}" unless ok
|
|
214
214
|
end
|
|
215
215
|
rescue MaintenanceGit::DirtyWorkingTree, MaintenanceGit::NotAGitRepo => e
|
|
@@ -44,7 +44,7 @@ fi
|
|
|
44
44
|
|
|
45
45
|
# Rewrite relative links to wikilinks
|
|
46
46
|
echo " Rewriting links to [[ID]] format..."
|
|
47
|
-
ruby <<'RUBY'
|
|
47
|
+
env -u RUBYOPT ruby <<'RUBY'
|
|
48
48
|
# Find intent files (named {ID}.md, the primary file in each intent directory)
|
|
49
49
|
Dir.glob(File.expand_path("~/.plastic/store/*/")).each do |dir|
|
|
50
50
|
id = File.basename(dir).split("--").first
|
|
@@ -68,7 +68,7 @@ RUBY
|
|
|
68
68
|
# Register project in projects.yml
|
|
69
69
|
echo " Registering project..."
|
|
70
70
|
REMOTE=$(cd "$PROJECT_PATH" && git remote get-url origin 2>/dev/null || echo "null")
|
|
71
|
-
ruby -r yaml <<RUBY
|
|
71
|
+
env -u RUBYOPT ruby -r yaml <<RUBY
|
|
72
72
|
path = File.expand_path("$GLOBAL_ROOT/projects.yml")
|
|
73
73
|
data = YAML.safe_load(File.read(path)) rescue {}
|
|
74
74
|
data["projects"] ||= {}
|
|
@@ -271,7 +271,7 @@ class RestoreIntentV1CLI
|
|
|
271
271
|
|
|
272
272
|
def reproject_links
|
|
273
273
|
project_links = File.expand_path("project-links", __dir__)
|
|
274
|
-
system(RbConfig.ruby, project_links, "--plastic-home", plastic_home)
|
|
274
|
+
system({"RUBYOPT" => nil}, RbConfig.ruby, project_links, "--plastic-home", plastic_home)
|
|
275
275
|
return if $?.success?
|
|
276
276
|
|
|
277
277
|
warn "restore-intent-v1: ## Links may now be stale (project-links reprojection failed). " \
|