@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.
Files changed (71) hide show
  1. package/PLASTIC-reference.md +8 -6
  2. package/PLASTIC.md +52 -14
  3. package/hooks/hooks.json +5 -0
  4. package/hooks/links-gate +3 -0
  5. package/package.json +1 -1
  6. package/scripts/codex-hook +122 -8
  7. package/scripts/dashboard.rb +323 -71
  8. package/scripts/doctor.rb +393 -58
  9. package/scripts/end-intent +347 -43
  10. package/scripts/hook-links-gate +74 -0
  11. package/scripts/hook-lock-gate +8 -3
  12. package/scripts/install.rb +51 -6
  13. package/scripts/lib/bridge.rb +105 -27
  14. package/scripts/lib/config_asks.rb +110 -0
  15. package/scripts/lib/graph_rebuild.rb +30 -6
  16. package/scripts/lib/hook_registry.rb +34 -3
  17. package/scripts/lib/installer_core.rb +70 -13
  18. package/scripts/lib/intent_validator.rb +38 -10
  19. package/scripts/lib/links_gate.rb +140 -0
  20. package/scripts/lib/links_projection.rb +71 -12
  21. package/scripts/lib/lock.rb +186 -11
  22. package/scripts/lib/power_tools.rb +57 -14
  23. package/scripts/lib/project_validator.rb +113 -0
  24. package/scripts/lib/qmd_hook.rb +12 -8
  25. package/scripts/lib/restore_intent_v1.rb +154 -0
  26. package/scripts/lib/roadmap_queue.rb +1 -1
  27. package/scripts/lib/roadmap_savepoint.rb +38 -10
  28. package/scripts/lib/store_discovery.rb +77 -0
  29. package/scripts/lib/store_provisioning.rb +21 -12
  30. package/scripts/new-intent +10 -12
  31. package/scripts/plastic-lock +76 -9
  32. package/scripts/project-links +132 -35
  33. package/scripts/provision-project-store +18 -5
  34. package/scripts/read-config +1 -0
  35. package/scripts/rebuild-graph +42 -17
  36. package/scripts/restore-intent-v1 +288 -0
  37. package/scripts/roadmap-next +9 -2
  38. package/scripts/roadmap-savepoint +9 -1
  39. package/scripts/update.rb +50 -1
  40. package/scripts/validate-intent +3 -1
  41. package/scripts/validate-project +53 -0
  42. package/scripts/write-config +105 -0
  43. package/skills/auto/SKILL.md +45 -16
  44. package/skills/auto/references/agent-architecture.md +7 -0
  45. package/skills/auto/references/end-tail.md +27 -13
  46. package/skills/dashboard/SKILL.md +48 -25
  47. package/skills/dashboard/evals/evals.json +4 -4
  48. package/skills/dashboard/templates/dashboard-global.md +3 -5
  49. package/skills/dashboard/templates/dashboard-project.md +6 -18
  50. package/skills/install/SKILL.md +4 -4
  51. package/skills/intent-creating/SKILL.md +5 -0
  52. package/skills/intent-ending/SKILL.md +49 -36
  53. package/skills/intent-locking/SKILL.md +20 -2
  54. package/skills/intent-starting/SKILL.md +6 -4
  55. package/skills/project-continuing/SKILL.md +10 -0
  56. package/skills/project-continuing/evals/evals.json +3 -3
  57. package/skills/project-continuing/references/board-fill.md +13 -11
  58. package/skills/project-creating/SKILL.md +29 -1
  59. package/skills/releasing/SKILL.md +37 -19
  60. package/skills/roadmap/SKILL.md +9 -7
  61. package/skills/roadmap/references/file-format.md +14 -10
  62. package/skills/roadmap/references/operations.md +22 -18
  63. package/skills/roadmap-continuing/SKILL.md +5 -5
  64. package/skills/roadmap-continuing/evals/evals.json +3 -3
  65. package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
  66. package/skills/tutorial/SKILL.md +4 -4
  67. package/skills/tutorial/references/track-1-guided.md +2 -1
  68. package/skills/tutorial/references/track-2-auto.md +2 -1
  69. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +12 -11
  70. package/skills/update/SKILL.md +30 -17
  71. package/templates/roadmap.md +8 -8
@@ -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 /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>"
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 detect-then-degrade harness for Plastic's optional power-tools
7
- # (intent 66b; demoted to recommendations in intent 108, D8). It owns
8
- # deterministic detection of each tool and builds a RECOMMENDATION string for
9
- # whichever tools are present, so the agent is reminded (not obliged) to prefer
10
- # them: QMD for finding intents, Serena for code navigation.
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 / `.serena` marker walk), so the whole module is
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
- # Both present collapse to ONE combined line naming both obligations (no
63
- # embedded newline); one present returns that tool's own line; neither
64
- # returns nil.
65
- def mandate(cwd:, qmd_detector: QmdSync.method(:detect), serena_detector: nil)
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
- if qmd_present && serena_present
70
- "QMD and Serena are available: #{QMD_OBLIGATION}, and #{SERENA_OBLIGATION}."
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 serena_present
74
- "Serena is available: #{SERENA_OBLIGATION}."
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
@@ -4,14 +4,15 @@
4
4
  require_relative "qmd_sync"
5
5
  require_relative "power_tools"
6
6
 
7
- # QmdHook decision logic for the power-tools UserPromptSubmit hook (intents 66,
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 MUST obligation per present tool: qmd for finding
14
- # intents, serena for code navigation) instead of the old soft reminder.
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
- return nil unless qmd_present || serena_present
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} #{h[:title]}"
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")