@zalom/plastic 1.3.0 → 1.4.1
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 +8 -6
- package/PLASTIC.md +52 -14
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/package.json +1 -1
- package/scripts/codex-hook +122 -8
- package/scripts/dashboard.rb +323 -71
- package/scripts/doctor.rb +393 -58
- package/scripts/end-intent +347 -43
- package/scripts/hook-links-gate +74 -0
- package/scripts/hook-lock-gate +8 -3
- package/scripts/install.rb +51 -6
- package/scripts/lib/bridge.rb +105 -27
- package/scripts/lib/config_asks.rb +110 -0
- package/scripts/lib/graph_rebuild.rb +30 -6
- package/scripts/lib/hook_registry.rb +34 -3
- package/scripts/lib/installer_core.rb +70 -13
- package/scripts/lib/intent_validator.rb +38 -10
- package/scripts/lib/links_gate.rb +140 -0
- package/scripts/lib/links_projection.rb +71 -12
- package/scripts/lib/lock.rb +186 -11
- package/scripts/lib/power_tools.rb +57 -14
- package/scripts/lib/project_validator.rb +113 -0
- package/scripts/lib/qmd_hook.rb +12 -8
- package/scripts/lib/restore_intent_v1.rb +154 -0
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +38 -10
- package/scripts/lib/store_discovery.rb +77 -0
- package/scripts/lib/store_provisioning.rb +21 -12
- package/scripts/new-intent +10 -12
- package/scripts/plastic-lock +76 -9
- package/scripts/project-links +132 -35
- package/scripts/provision-project-store +18 -5
- package/scripts/read-config +1 -0
- package/scripts/rebuild-graph +42 -17
- package/scripts/restore-intent-v1 +288 -0
- package/scripts/roadmap-next +9 -2
- package/scripts/roadmap-savepoint +9 -1
- package/scripts/update.rb +50 -1
- package/scripts/validate-intent +3 -1
- package/scripts/validate-project +53 -0
- package/scripts/write-config +105 -0
- package/skills/auto/SKILL.md +45 -16
- package/skills/auto/references/agent-architecture.md +7 -0
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/dashboard/SKILL.md +48 -25
- package/skills/dashboard/evals/evals.json +4 -4
- package/skills/dashboard/templates/dashboard-global.md +3 -5
- package/skills/dashboard/templates/dashboard-project.md +6 -18
- package/skills/install/SKILL.md +4 -4
- package/skills/intent-creating/SKILL.md +5 -0
- package/skills/intent-ending/SKILL.md +49 -36
- package/skills/intent-locking/SKILL.md +20 -2
- package/skills/intent-starting/SKILL.md +6 -4
- package/skills/project-continuing/SKILL.md +10 -0
- package/skills/project-continuing/evals/evals.json +3 -3
- package/skills/project-continuing/references/board-fill.md +13 -11
- package/skills/project-creating/SKILL.md +29 -1
- package/skills/releasing/SKILL.md +37 -19
- package/skills/roadmap/SKILL.md +9 -7
- package/skills/roadmap/references/file-format.md +14 -10
- package/skills/roadmap/references/operations.md +22 -18
- package/skills/roadmap-continuing/SKILL.md +5 -5
- package/skills/roadmap-continuing/evals/evals.json +3 -3
- package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
- package/skills/tutorial/SKILL.md +4 -4
- package/skills/tutorial/references/track-1-guided.md +2 -1
- package/skills/tutorial/references/track-2-auto.md +2 -1
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +12 -11
- package/skills/update/SKILL.md +30 -17
- package/templates/roadmap.md +8 -8
package/scripts/lib/lock.rb
CHANGED
|
@@ -24,7 +24,30 @@ require "time"
|
|
|
24
24
|
module Lock
|
|
25
25
|
module_function
|
|
26
26
|
|
|
27
|
+
# Skill-invocation prefix per harness (intent 201, D2/D3). Claude Code invokes a
|
|
28
|
+
# skill with a slash (/plastic-doctor); Codex CLI invokes explicitly with a
|
|
29
|
+
# dollar ($plastic-doctor) and may also select one implicitly by matching the
|
|
30
|
+
# skill's description. This table is the actual source of truth for
|
|
31
|
+
# Bridge.skill_ref (bridge.rb requires lock.rb, never the reverse, so the
|
|
32
|
+
# table lives here rather than pulling Bridge into this dependency-free file
|
|
33
|
+
# just to render two characters). InstallerCore::DEFAULT_AGENTS carries the
|
|
34
|
+
# same values per adapter as documented config (see ACTION_2); this constant
|
|
35
|
+
# is not read from it at runtime, by the same reasoning bridge.rb/hook-*
|
|
36
|
+
# already stay clear of installer_core.rb (spec Alternatives Considered).
|
|
37
|
+
SKILL_PREFIXES = { "claude" => "/", "codex" => "$" }.freeze
|
|
38
|
+
|
|
39
|
+
# Renders a skill reference for the given harness. Unset or unrecognized
|
|
40
|
+
# harness falls back to Claude's slash form, so an existing call site that
|
|
41
|
+
# never passes harness: keeps behaving exactly as it does today (D2). name
|
|
42
|
+
# is the bare skill name ("plastic-doctor"), never pre-prefixed.
|
|
43
|
+
def self.skill_ref(name, harness: :claude)
|
|
44
|
+
prefix = SKILL_PREFIXES.fetch(harness.to_s, SKILL_PREFIXES["claude"])
|
|
45
|
+
"#{prefix}#{name}"
|
|
46
|
+
end
|
|
47
|
+
|
|
27
48
|
TYPES = %w[delivery maintenance].freeze
|
|
49
|
+
DELEGATE_ACTIVITY_LIMIT = 20
|
|
50
|
+
DELEGATE_STATUSES = %w[active finished failed].freeze
|
|
28
51
|
|
|
29
52
|
# Lease TTL. Heartbeats fire from the write-path hooks (PostToolUse
|
|
30
53
|
# gate-check and the lock-gate allow path), so a delivering session
|
|
@@ -84,7 +107,8 @@ module Lock
|
|
|
84
107
|
# [:excluded, other] the OTHER lock type is fresh (D3)
|
|
85
108
|
# [:corrupt, nil] unparseable lock file: run repair
|
|
86
109
|
def acquire(intent_dir, session:, type: "delivery", host: Socket.gethostname,
|
|
87
|
-
ttl: TTL_SECONDS, now: Time.now
|
|
110
|
+
ttl: TTL_SECONDS, now: Time.now, harness: nil, agent: nil,
|
|
111
|
+
model: nil, thread: nil, run_mode: nil)
|
|
88
112
|
raise ArgumentError, "unknown lock type #{type.inspect}" unless TYPES.include?(type)
|
|
89
113
|
raise ArgumentError, "lock session must be present" if blank?(session)
|
|
90
114
|
|
|
@@ -99,7 +123,13 @@ module Lock
|
|
|
99
123
|
if existing
|
|
100
124
|
if existing["owner_session"].to_s == session.to_s
|
|
101
125
|
data = payload(session: session, type: type, host: host, now: now,
|
|
102
|
-
delegates: Array(existing["delegates"])
|
|
126
|
+
delegates: Array(existing["delegates"]),
|
|
127
|
+
delegate_activity: Array(existing["delegate_activity"]),
|
|
128
|
+
harness: merged_value(harness, existing["owner_harness"]),
|
|
129
|
+
agent: merged_value(agent, existing["owner_agent"]),
|
|
130
|
+
model: merged_value(model, existing["owner_model"]),
|
|
131
|
+
thread: merged_value(thread, existing["owner_thread"]),
|
|
132
|
+
run_mode: merged_value(run_mode, existing["run_mode"]))
|
|
103
133
|
write(intent_dir, data, type: type)
|
|
104
134
|
return [:owned, data]
|
|
105
135
|
end
|
|
@@ -107,7 +137,9 @@ module Lock
|
|
|
107
137
|
return [:stale, existing]
|
|
108
138
|
end
|
|
109
139
|
|
|
110
|
-
data = payload(session: session, type: type, host: host, now: now
|
|
140
|
+
data = payload(session: session, type: type, host: host, now: now,
|
|
141
|
+
harness: harness, agent: agent, model: model, thread: thread,
|
|
142
|
+
run_mode: run_mode)
|
|
111
143
|
File.open(path(intent_dir, type: type),
|
|
112
144
|
File::WRONLY | File::CREAT | File::EXCL) do |io|
|
|
113
145
|
io.write(JSON.pretty_generate(data))
|
|
@@ -117,16 +149,49 @@ module Lock
|
|
|
117
149
|
[:held, read(intent_dir, type: type)] # lost the O_EXCL race
|
|
118
150
|
end
|
|
119
151
|
|
|
120
|
-
def payload(session:, type:, host:, now:, delegates: []
|
|
152
|
+
def payload(session:, type:, host:, now:, delegates: [], delegate_activity: [],
|
|
153
|
+
harness: nil, agent: nil, model: nil, thread: nil, run_mode: nil)
|
|
121
154
|
{
|
|
122
155
|
"type" => type,
|
|
123
156
|
"owner_session" => session.to_s,
|
|
124
157
|
"host" => host,
|
|
125
158
|
"acquired_at" => now.utc.iso8601,
|
|
126
159
|
"delegates" => delegates,
|
|
160
|
+
"owner_harness" => normalized_value(harness),
|
|
161
|
+
"owner_agent" => normalized_value(agent),
|
|
162
|
+
"owner_model" => normalized_value(model),
|
|
163
|
+
"owner_thread" => normalized_value(thread),
|
|
164
|
+
"run_mode" => normalized_value(run_mode),
|
|
165
|
+
"delegate_activity" => bounded_delegate_activity(delegate_activity, delegates: delegates),
|
|
127
166
|
}
|
|
128
167
|
end
|
|
129
168
|
|
|
169
|
+
def normalized_value(value)
|
|
170
|
+
blank?(value) ? nil : value.to_s
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def merged_value(explicit, existing)
|
|
174
|
+
blank?(explicit) ? normalized_value(existing) : explicit.to_s
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Keep every active record that still names an authorized delegate, while
|
|
178
|
+
# bounding completed history. Active work is current truth and must never be
|
|
179
|
+
# evicted merely because newer delegates finished.
|
|
180
|
+
def bounded_delegate_activity(records, delegates:)
|
|
181
|
+
records = Array(records)
|
|
182
|
+
authorized = Array(delegates).map(&:to_s)
|
|
183
|
+
terminal_indexes = records.each_index.reject do |index|
|
|
184
|
+
record = records[index]
|
|
185
|
+
record.is_a?(Hash) && record["status"].to_s == "active" &&
|
|
186
|
+
authorized.include?(record["session"].to_s)
|
|
187
|
+
end.last(DELEGATE_ACTIVITY_LIMIT)
|
|
188
|
+
records.each_with_index.filter_map do |record, index|
|
|
189
|
+
active = record.is_a?(Hash) && record["status"].to_s == "active" &&
|
|
190
|
+
authorized.include?(record["session"].to_s)
|
|
191
|
+
record if active || terminal_indexes.include?(index)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
130
195
|
# Owner/delegate heartbeat: touch the mtime, never rewrite content.
|
|
131
196
|
def heartbeat(intent_dir, session:, type: "delivery", now: Time.now)
|
|
132
197
|
return false unless holds?(intent_dir, session: session, type: type)
|
|
@@ -136,11 +201,49 @@ module Lock
|
|
|
136
201
|
|
|
137
202
|
# Owner registers a delegate (D4): a session allowed to write under this
|
|
138
203
|
# lock. Only the OWNER may delegate; delegates cannot re-delegate.
|
|
139
|
-
def add_delegate(intent_dir, delegate:, session:, type: "delivery"
|
|
204
|
+
def add_delegate(intent_dir, delegate:, session:, type: "delivery", now: Time.now,
|
|
205
|
+
harness: nil, agent: nil, model: nil, thread: nil)
|
|
140
206
|
data = read(intent_dir, type: type)
|
|
141
207
|
return false if blank?(delegate)
|
|
142
208
|
return false unless data && data["owner_session"].to_s == session.to_s
|
|
143
209
|
data["delegates"] = (Array(data["delegates"]) + [delegate.to_s]).uniq
|
|
210
|
+
activity = Array(data["delegate_activity"])
|
|
211
|
+
previous = activity.find { |record| record.is_a?(Hash) && record["session"].to_s == delegate.to_s }
|
|
212
|
+
activity.reject! { |record| record.is_a?(Hash) && record["session"].to_s == delegate.to_s }
|
|
213
|
+
record = {
|
|
214
|
+
"session" => delegate.to_s,
|
|
215
|
+
"status" => "active",
|
|
216
|
+
"registered_at" => now.utc.iso8601,
|
|
217
|
+
"last_activity_at" => now.utc.iso8601,
|
|
218
|
+
"harness" => merged_value(harness, previous && previous["harness"]),
|
|
219
|
+
"agent" => merged_value(agent, previous && previous["agent"]),
|
|
220
|
+
"model" => merged_value(model, previous && previous["model"]),
|
|
221
|
+
"thread" => merged_value(thread, previous && previous["thread"]),
|
|
222
|
+
}
|
|
223
|
+
data["delegate_activity"] = bounded_delegate_activity(activity + [record],
|
|
224
|
+
delegates: data["delegates"])
|
|
225
|
+
write(intent_dir, data, type: type)
|
|
226
|
+
true
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Activity metadata is observational only. Finishing or failing a delegate
|
|
230
|
+
# never removes its string session id from the authorization list.
|
|
231
|
+
def update_delegate_status(intent_dir, delegate:, status:, session:, type: "delivery",
|
|
232
|
+
now: Time.now)
|
|
233
|
+
return false unless (DELEGATE_STATUSES - ["active"]).include?(status.to_s)
|
|
234
|
+
data = read(intent_dir, type: type)
|
|
235
|
+
return false unless data && data["owner_session"].to_s == session.to_s
|
|
236
|
+
activity = Array(data["delegate_activity"])
|
|
237
|
+
index = activity.index do |record|
|
|
238
|
+
record.is_a?(Hash) && record["session"].to_s == delegate.to_s
|
|
239
|
+
end
|
|
240
|
+
return false unless index
|
|
241
|
+
activity[index] = activity[index].merge(
|
|
242
|
+
"status" => status.to_s,
|
|
243
|
+
"last_activity_at" => now.utc.iso8601
|
|
244
|
+
)
|
|
245
|
+
data["delegate_activity"] = bounded_delegate_activity(activity,
|
|
246
|
+
delegates: data["delegates"])
|
|
144
247
|
write(intent_dir, data, type: type)
|
|
145
248
|
true
|
|
146
249
|
end
|
|
@@ -164,7 +267,8 @@ module Lock
|
|
|
164
267
|
# lock; there is no silent reclaim path anywhere else.
|
|
165
268
|
# Returns [:taken, data], [:fresh, existing], or acquire's error statuses.
|
|
166
269
|
def takeover(intent_dir, session:, type: "delivery", host: Socket.gethostname,
|
|
167
|
-
ttl: TTL_SECONDS, now: Time.now
|
|
270
|
+
ttl: TTL_SECONDS, now: Time.now, harness: nil, agent: nil,
|
|
271
|
+
model: nil, thread: nil, run_mode: nil)
|
|
168
272
|
existing = read(intent_dir, type: type)
|
|
169
273
|
if existing && !authorized?(existing, session) &&
|
|
170
274
|
fresh?(intent_dir, type: type, ttl: ttl, now: now)
|
|
@@ -175,7 +279,8 @@ module Lock
|
|
|
175
279
|
p = path(intent_dir, type: type)
|
|
176
280
|
File.delete(p) if File.exist?(p)
|
|
177
281
|
status, data = acquire(intent_dir, session: session, type: type, host: host,
|
|
178
|
-
ttl: ttl, now: now
|
|
282
|
+
ttl: ttl, now: now, harness: harness, agent: agent,
|
|
283
|
+
model: model, thread: thread, run_mode: run_mode)
|
|
179
284
|
return [status, data] unless status == :acquired
|
|
180
285
|
|
|
181
286
|
audit = "#{now.utc.iso8601} Lock takeover: #{session} reclaimed #{type} " \
|
|
@@ -190,6 +295,75 @@ module Lock
|
|
|
190
295
|
def write(intent_dir, data, type: "delivery")
|
|
191
296
|
File.write(path(intent_dir, type: type), JSON.pretty_generate(data))
|
|
192
297
|
end
|
|
298
|
+
|
|
299
|
+
# Read-only normalized inspection. The lock file and its mtime remain the
|
|
300
|
+
# sole sources of owner and heartbeat truth; no environment or transcript
|
|
301
|
+
# inference belongs here.
|
|
302
|
+
def who(intent_dir, ttl: TTL_SECONDS, now: Time.now)
|
|
303
|
+
p = path(intent_dir)
|
|
304
|
+
unless File.exist?(p)
|
|
305
|
+
return { "state" => "none",
|
|
306
|
+
"claims" => Claim.claims_status(intent_dir, ttl: ttl, now: now) }
|
|
307
|
+
end
|
|
308
|
+
data = read(intent_dir)
|
|
309
|
+
unless data
|
|
310
|
+
return { "state" => "corrupt",
|
|
311
|
+
"claims" => Claim.claims_status(intent_dir, ttl: ttl, now: now) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
activity = Array(data["delegate_activity"])
|
|
315
|
+
activity_by_session = activity.each_with_object({}) do |record, memo|
|
|
316
|
+
memo[record["session"].to_s] = record if record.is_a?(Hash)
|
|
317
|
+
end
|
|
318
|
+
authorized_sessions = Array(data["delegates"]).map(&:to_s)
|
|
319
|
+
activity_sessions = activity.filter_map do |record|
|
|
320
|
+
record["session"].to_s if record.is_a?(Hash) &&
|
|
321
|
+
authorized_sessions.include?(record["session"].to_s)
|
|
322
|
+
end.uniq
|
|
323
|
+
activity_order = activity.each_with_index.each_with_object({}) do |(record, index), memo|
|
|
324
|
+
memo[record["session"].to_s] = index if record.is_a?(Hash)
|
|
325
|
+
end
|
|
326
|
+
activity_sessions.sort_by! do |session|
|
|
327
|
+
record = activity_by_session[session] || {}
|
|
328
|
+
# Active delegates are current ahead of terminal delegates. Within that
|
|
329
|
+
# group, latest activity wins; original record order is the stable
|
|
330
|
+
# fallback for legacy records without timestamps.
|
|
331
|
+
[record["status"].to_s == "active" ? 1 : 0,
|
|
332
|
+
record["last_activity_at"].to_s, activity_order.fetch(session, -1)]
|
|
333
|
+
end
|
|
334
|
+
# Legacy string-only delegates retain their authorization order. Rich
|
|
335
|
+
# records follow in deterministic current/latest order, so consumers may
|
|
336
|
+
# reliably take the last projection entry: the most recent active delegate
|
|
337
|
+
# when one exists, otherwise the most recent terminal activity.
|
|
338
|
+
ordered_sessions = (authorized_sessions - activity_sessions) + activity_sessions
|
|
339
|
+
delegates = ordered_sessions.map do |session|
|
|
340
|
+
record = activity_by_session[session] || {}
|
|
341
|
+
{
|
|
342
|
+
"session" => session,
|
|
343
|
+
"harness" => normalized_value(record["harness"]) || "unknown",
|
|
344
|
+
"agent" => normalized_value(record["agent"]) || "unknown",
|
|
345
|
+
"model" => normalized_value(record["model"]) || "unknown",
|
|
346
|
+
"thread" => normalized_value(record["thread"]) || "unknown",
|
|
347
|
+
"status" => normalized_value(record["status"]) || "unknown",
|
|
348
|
+
"registered_at" => record["registered_at"],
|
|
349
|
+
"last_activity_at" => record["last_activity_at"],
|
|
350
|
+
}
|
|
351
|
+
end
|
|
352
|
+
{
|
|
353
|
+
"state" => fresh?(intent_dir, ttl: ttl, now: now) ? "fresh" : "stale",
|
|
354
|
+
"owner" => {
|
|
355
|
+
"harness" => normalized_value(data["owner_harness"]) || "unknown",
|
|
356
|
+
"agent" => normalized_value(data["owner_agent"]) || "unknown",
|
|
357
|
+
"model" => normalized_value(data["owner_model"]) || "unknown",
|
|
358
|
+
"thread" => normalized_value(data["owner_thread"]) || "unknown",
|
|
359
|
+
"run_mode" => normalized_value(data["run_mode"]) || "unknown",
|
|
360
|
+
},
|
|
361
|
+
"owner_session" => data["owner_session"],
|
|
362
|
+
"heartbeat_at" => File.mtime(p).utc.iso8601,
|
|
363
|
+
"delegates" => delegates,
|
|
364
|
+
"claims" => Claim.claims_status(intent_dir, ttl: ttl, now: now),
|
|
365
|
+
}
|
|
366
|
+
end
|
|
193
367
|
end
|
|
194
368
|
|
|
195
369
|
# Claim: the per-artifact claim-token layer (intent 111, D1/D7). Sits BENEATH
|
|
@@ -359,7 +533,8 @@ module Claim
|
|
|
359
533
|
# ENGAGES only when a claim file exists (dormant otherwise, so single-owner flows
|
|
360
534
|
# and the existing suite stay green, AC7). Fails open on stale/corrupt via
|
|
361
535
|
# fail_open?, the named contract.
|
|
362
|
-
def claim_gate_reason(intent_dir, artifact, session:, ttl: Lock::TTL_SECONDS, now: Time.now
|
|
536
|
+
def claim_gate_reason(intent_dir, artifact, session:, ttl: Lock::TTL_SECONDS, now: Time.now,
|
|
537
|
+
harness: :claude)
|
|
363
538
|
return nil if Lock.blank?(artifact)
|
|
364
539
|
return nil unless File.exist?(path(intent_dir, artifact)) # dormant: no claim
|
|
365
540
|
return nil if holds_claim?(intent_dir, artifact, session: session) # you hold it
|
|
@@ -368,8 +543,8 @@ module Claim
|
|
|
368
543
|
holder = data && data["owner_session"]
|
|
369
544
|
since = data && data["acquired_at"]
|
|
370
545
|
"artifact #{artifact} is claimed by #{holder} since #{since}; another writer holds " \
|
|
371
|
-
"it. Back off or run
|
|
372
|
-
"delegate, the owner must register you:
|
|
373
|
-
"#{intent_dir} --session <your-session-id>"
|
|
546
|
+
"it. Back off or run #{Lock.skill_ref('plastic-doctor', harness: harness)} check the " \
|
|
547
|
+
"lock status. If you are a distinct delegate, the owner must register you: " \
|
|
548
|
+
"plastic-lock delegate --intent-dir #{intent_dir} --session <your-session-id>"
|
|
374
549
|
end
|
|
375
550
|
end
|
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
|
|
4
4
|
require_relative "qmd_sync"
|
|
5
5
|
|
|
6
|
-
# PowerTools
|
|
7
|
-
# (intent 66b; demoted to recommendations in intent 108, D8
|
|
8
|
-
# deterministic detection of each tool and builds a
|
|
9
|
-
# whichever tools are present, so the agent is
|
|
10
|
-
# them: QMD for finding intents,
|
|
6
|
+
# PowerTools - detect-then-degrade harness for Plastic's optional power-tools
|
|
7
|
+
# (intent 66b; demoted to recommendations in intent 108, D8; Enola added in
|
|
8
|
+
# intent 187). It owns deterministic detection of each tool and builds a
|
|
9
|
+
# RECOMMENDATION string for whichever tools are present, so the agent is
|
|
10
|
+
# reminded (not obliged) to prefer them: QMD for finding intents, Enola or
|
|
11
|
+
# Serena for code navigation.
|
|
11
12
|
#
|
|
12
13
|
# Strictly detect-then-degrade: a tool that is absent contributes nothing, and
|
|
13
14
|
# `mandate` returns nil when no tool is present. Nothing here installs anything.
|
|
14
15
|
#
|
|
15
16
|
# Pure and dependency-injected: every detection runs through an injected callable
|
|
16
|
-
# or keyword probe (PATH scan /
|
|
17
|
+
# or keyword probe (PATH scan / marker-directory walk), so the whole module is
|
|
17
18
|
# unit-testable with no real binaries, no network, and no global/ENV state.
|
|
18
19
|
module PowerTools
|
|
19
20
|
module_function
|
|
@@ -31,6 +32,15 @@ module PowerTools
|
|
|
31
32
|
!!path_probe.call
|
|
32
33
|
end
|
|
33
34
|
|
|
35
|
+
# True when Enola is present: a `.enola` directory exists in cwd or any
|
|
36
|
+
# ancestor (a generated snapshot), OR `enola` is resolvable on PATH. Both
|
|
37
|
+
# probes are injectable so tests do not depend on the host having Enola
|
|
38
|
+
# installed or indexed (intent 187).
|
|
39
|
+
def enola?(cwd:, path_probe: method(:which_enola), marker_finder: method(:enola_marker?))
|
|
40
|
+
return true if marker_finder.call(cwd)
|
|
41
|
+
!!path_probe.call
|
|
42
|
+
end
|
|
43
|
+
|
|
34
44
|
# True when `serena` is an executable on PATH. Mirrors QmdSync.which_qmd.
|
|
35
45
|
def which_serena
|
|
36
46
|
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
@@ -39,6 +49,14 @@ module PowerTools
|
|
|
39
49
|
end
|
|
40
50
|
end
|
|
41
51
|
|
|
52
|
+
# True when `enola` is an executable on PATH. Mirrors which_serena.
|
|
53
|
+
def which_enola
|
|
54
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
55
|
+
candidate = File.join(dir, "enola")
|
|
56
|
+
File.file?(candidate) && File.executable?(candidate)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
42
60
|
# Walk up from cwd to the filesystem root, returning true if any level holds a
|
|
43
61
|
# `.serena` directory.
|
|
44
62
|
def serena_marker?(cwd)
|
|
@@ -52,26 +70,51 @@ module PowerTools
|
|
|
52
70
|
false
|
|
53
71
|
end
|
|
54
72
|
|
|
73
|
+
# Walk up from cwd to the filesystem root, returning true if any level holds
|
|
74
|
+
# an `.enola` directory (a generated snapshot).
|
|
75
|
+
def enola_marker?(cwd)
|
|
76
|
+
dir = File.expand_path(cwd)
|
|
77
|
+
loop do
|
|
78
|
+
return true if Dir.exist?(File.join(dir, ".enola"))
|
|
79
|
+
parent = File.dirname(dir)
|
|
80
|
+
break if parent == dir
|
|
81
|
+
dir = parent
|
|
82
|
+
end
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
55
86
|
QMD_OBLIGATION = "prefer `qmd search` / `qmd query` over the `plastic-*` " \
|
|
56
87
|
"collections to check for existing or related intents before " \
|
|
57
88
|
"treating work as new"
|
|
58
89
|
SERENA_OBLIGATION = "prefer its symbolic tools (find_symbol / get_symbols_overview / " \
|
|
59
90
|
"find_referencing_symbols) for code navigation"
|
|
91
|
+
ENOLA_OBLIGATION = "prefer its MCP symbol resolution (or `.enola/facts.jsonl`) for " \
|
|
92
|
+
"code navigation over grep"
|
|
60
93
|
|
|
61
94
|
# Recommendation text for whichever tools are present, or nil when none are.
|
|
62
|
-
#
|
|
63
|
-
# embedded newline); one present returns that tool's own
|
|
64
|
-
# returns nil.
|
|
65
|
-
|
|
95
|
+
# QMD plus a code-navigation tool collapse to ONE combined line naming both
|
|
96
|
+
# obligations (no embedded newline); one tool present returns that tool's own
|
|
97
|
+
# line; neither returns nil.
|
|
98
|
+
#
|
|
99
|
+
# Enola-first: Enola and Serena share ONE code-navigation slot. When both are
|
|
100
|
+
# present, only Enola is named (intent 187, matching the owner's standing
|
|
101
|
+
# Enola-first ruling and avoiding a bloated three-tool line). The QMD-only and
|
|
102
|
+
# Serena-only lines are unchanged from before Enola existed.
|
|
103
|
+
def mandate(cwd:, qmd_detector: QmdSync.method(:detect), serena_detector: nil, enola_detector: nil)
|
|
66
104
|
qmd_present = qmd?(detector: qmd_detector)
|
|
105
|
+
enola_present = enola_detector ? !!enola_detector.call : enola?(cwd: cwd)
|
|
67
106
|
serena_present = serena_detector ? !!serena_detector.call : serena?(cwd: cwd)
|
|
68
107
|
|
|
69
|
-
|
|
70
|
-
|
|
108
|
+
nav_present = enola_present || serena_present
|
|
109
|
+
nav_name = enola_present ? "Enola" : "Serena"
|
|
110
|
+
nav_obligation = enola_present ? ENOLA_OBLIGATION : SERENA_OBLIGATION
|
|
111
|
+
|
|
112
|
+
if qmd_present && nav_present
|
|
113
|
+
"QMD and #{nav_name} are available: #{QMD_OBLIGATION}, and #{nav_obligation}."
|
|
71
114
|
elsif qmd_present
|
|
72
115
|
"QMD is available: #{QMD_OBLIGATION}."
|
|
73
|
-
elsif
|
|
74
|
-
"
|
|
116
|
+
elsif nav_present
|
|
117
|
+
"#{nav_name} is available: #{nav_obligation}."
|
|
75
118
|
end
|
|
76
119
|
end
|
|
77
120
|
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
# ProjectValidator - the single source of truth for "is a project spawn
|
|
7
|
+
# complete?" (intent 190).
|
|
8
|
+
#
|
|
9
|
+
# A project can be registered in projects.yml, have a store, and still be
|
|
10
|
+
# missing the pieces a real project needs: project.yml, a root AGENTS.md.
|
|
11
|
+
# The intent-26 spawn shipped exactly that shape and was caught only by a
|
|
12
|
+
# much later, pull-only plastic-doctor sweep. This module lets
|
|
13
|
+
# plastic-project-creating verify a spawn BEFORE announcing it as done,
|
|
14
|
+
# mirroring how scripts/new-intent already runs IntentValidator before
|
|
15
|
+
# announcing a new intent (validate-intent).
|
|
16
|
+
#
|
|
17
|
+
# Pure and dependency-injected: validate accepts an injectable plastic_home,
|
|
18
|
+
# uses no eval, performs no file writes, no global-constant injection.
|
|
19
|
+
# scripts/doctor.rb's check_project_store already covers 4 of these 6
|
|
20
|
+
# invariants (project_dir_exists, project_store_dir, project_index,
|
|
21
|
+
# project_yml_exists) plus cross_references, advisorially and pull-only;
|
|
22
|
+
# doctor adopting this module is a named follow-up, not part of this intent
|
|
23
|
+
# (D8). Invariant 4 (project-root AGENTS.md) has no existing check anywhere.
|
|
24
|
+
module ProjectValidator
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def validate(slug, plastic_home: File.join(Dir.home, ".plastic"))
|
|
28
|
+
missing = []
|
|
29
|
+
errors = []
|
|
30
|
+
|
|
31
|
+
entry = registration_for(slug, plastic_home)
|
|
32
|
+
unless entry
|
|
33
|
+
missing << "projects.yml registration"
|
|
34
|
+
errors << "project '#{slug}' is not registered in projects.yml with a 'path' key"
|
|
35
|
+
return { ok: false, missing: missing, errors: errors }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
project_path = entry["path"].to_s
|
|
39
|
+
|
|
40
|
+
# Invariant 2: registered project directory exists on disk.
|
|
41
|
+
unless File.directory?(project_path)
|
|
42
|
+
missing << "project directory"
|
|
43
|
+
errors << "registered project directory does not exist: #{project_path}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
project_dir = File.join(plastic_home, "projects", slug)
|
|
47
|
+
|
|
48
|
+
# Invariant 3: project.yml exists AND parses as YAML.
|
|
49
|
+
project_yml_path = File.join(project_dir, "project.yml")
|
|
50
|
+
if File.exist?(project_yml_path)
|
|
51
|
+
parsed = begin
|
|
52
|
+
YAML.safe_load(File.read(project_yml_path))
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
unless parsed.is_a?(Hash)
|
|
57
|
+
missing << "project.yml (valid YAML)"
|
|
58
|
+
errors << "project.yml exists at #{project_yml_path} but does not parse as YAML"
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
missing << "project.yml"
|
|
62
|
+
errors << "project.yml missing at #{project_yml_path}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Invariant 4: project-root AGENTS.md (the registered path, NOT
|
|
66
|
+
# ~/.plastic/projects/{slug}/). This is the intent-26 spawn's gap,
|
|
67
|
+
# uncaught by doctor.rb today.
|
|
68
|
+
agents_md_path = File.join(project_path, "AGENTS.md")
|
|
69
|
+
unless File.exist?(agents_md_path)
|
|
70
|
+
missing << "AGENTS.md (project root)"
|
|
71
|
+
errors << "AGENTS.md missing at project root: #{agents_md_path}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Invariant 5: store/ exists.
|
|
75
|
+
store_dir = File.join(project_dir, "store")
|
|
76
|
+
unless File.directory?(store_dir)
|
|
77
|
+
missing << "store/"
|
|
78
|
+
errors << "store directory missing: #{store_dir}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Invariant 6: INDEX.md exists.
|
|
82
|
+
index_md_path = File.join(project_dir, "INDEX.md")
|
|
83
|
+
unless File.exist?(index_md_path)
|
|
84
|
+
missing << "INDEX.md"
|
|
85
|
+
errors << "INDEX.md missing: #{index_md_path}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
{ ok: missing.empty?, missing: missing, errors: errors }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Invariant 1: registered in projects.yml with a 'path'. Returns the
|
|
92
|
+
# project's entry Hash, or nil when unregistered or the entry has no path.
|
|
93
|
+
def registration_for(slug, plastic_home)
|
|
94
|
+
projects = load_projects(plastic_home)
|
|
95
|
+
entry = projects[slug]
|
|
96
|
+
entry.is_a?(Hash) && entry["path"] ? entry : nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Parse projects.yml -> the `projects` Hash, or {} on any error/absence.
|
|
100
|
+
# Mirrors StoreProvisioning.load_projects.
|
|
101
|
+
def load_projects(plastic_home)
|
|
102
|
+
path = File.join(plastic_home, "projects.yml")
|
|
103
|
+
return {} unless File.exist?(path)
|
|
104
|
+
|
|
105
|
+
data = begin
|
|
106
|
+
YAML.safe_load(File.read(path)) || {}
|
|
107
|
+
rescue StandardError
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
projects = data.is_a?(Hash) ? data["projects"] : nil
|
|
111
|
+
projects.is_a?(Hash) ? projects : {}
|
|
112
|
+
end
|
|
113
|
+
end
|
package/scripts/lib/qmd_hook.rb
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
require_relative "qmd_sync"
|
|
5
5
|
require_relative "power_tools"
|
|
6
6
|
|
|
7
|
-
# QmdHook
|
|
7
|
+
# QmdHook - decision logic for the power-tools UserPromptSubmit hook (intents 66,
|
|
8
8
|
# 66b). Pure and dependency-injected: returns the additionalContext string to
|
|
9
9
|
# emit, or nil to emit nothing. The executable hook wires real deps and prints;
|
|
10
10
|
# this is unit-tested with a fake runner/detector (no real qmd, no network).
|
|
11
11
|
#
|
|
12
12
|
# When qmd is present it still injects scored qmd hits (intent 66), then appends
|
|
13
|
-
# the PowerTools mandate (a
|
|
14
|
-
# intents,
|
|
13
|
+
# the PowerTools mandate (a recommendation per present tool: qmd for finding
|
|
14
|
+
# intents, Enola-first for code navigation, falling back to Serena; intent 187
|
|
15
|
+
# added the enola_detector alongside the pre-existing serena_detector).
|
|
15
16
|
module QmdHook
|
|
16
17
|
module_function
|
|
17
18
|
|
|
@@ -19,11 +20,13 @@ module QmdHook
|
|
|
19
20
|
|
|
20
21
|
def run(prompt:, cwd:, plastic_home:, runner: QmdSync.default_runner,
|
|
21
22
|
detector: QmdSync.method(:detect), limit: 3, min_score: 0.5,
|
|
22
|
-
serena_detector: nil)
|
|
23
|
+
serena_detector: nil, enola_detector: nil)
|
|
23
24
|
serena_detector ||= -> { PowerTools.serena?(cwd: cwd) }
|
|
25
|
+
enola_detector ||= -> { PowerTools.enola?(cwd: cwd) }
|
|
24
26
|
qmd_present = !!detector.call
|
|
25
27
|
serena_present = !!serena_detector.call
|
|
26
|
-
|
|
28
|
+
enola_present = !!enola_detector.call
|
|
29
|
+
return nil unless qmd_present || serena_present || enola_present
|
|
27
30
|
|
|
28
31
|
p = prompt.to_s.strip
|
|
29
32
|
# The hit SEARCH is the only expensive step and the only one gated by prompt
|
|
@@ -37,19 +40,20 @@ module QmdHook
|
|
|
37
40
|
hits = QmdSync.search(p, collections: collections, limit: limit,
|
|
38
41
|
min_score: min_score, runner: runner, detector: detector)
|
|
39
42
|
if hits.any?
|
|
40
|
-
parts << "Related / prior Plastic intents (qmd BM25, includes completed)
|
|
43
|
+
parts << "Related / prior Plastic intents (qmd BM25, includes completed) - " \
|
|
41
44
|
"check before treating this as new work:"
|
|
42
45
|
hits.each do |h|
|
|
43
46
|
loc = h[:file].to_s.sub(%r{\Aqmd://}, "")
|
|
44
47
|
pct = (h[:score] * 100).round
|
|
45
|
-
parts << "- [#{pct}%] #{loc}
|
|
48
|
+
parts << "- [#{pct}%] #{loc} - #{h[:title]}"
|
|
46
49
|
end
|
|
47
50
|
parts << ""
|
|
48
51
|
end
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
mandate = PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
|
|
52
|
-
serena_detector: -> { serena_present }
|
|
55
|
+
serena_detector: -> { serena_present },
|
|
56
|
+
enola_detector: -> { enola_present })
|
|
53
57
|
parts << mandate if mandate
|
|
54
58
|
return nil if parts.empty?
|
|
55
59
|
parts.join("\n")
|