@zalom/plastic 1.0.0-beta.8 → 1.0.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.
Files changed (116) hide show
  1. package/PLASTIC-reference.md +133 -0
  2. package/PLASTIC.md +407 -84
  3. package/README.md +99 -82
  4. package/agents/plastic-brainstorming.md +12 -9
  5. package/agents/plastic-enforcer.md +52 -12
  6. package/agents/plastic-executor.md +10 -10
  7. package/agents/plastic-future-intent-researcher.md +4 -8
  8. package/agents/plastic-intent-curator.md +12 -14
  9. package/agents/plastic-intent-discovery.md +35 -0
  10. package/agents/plastic-planner.md +15 -10
  11. package/agents/plastic-spec-specialist.md +15 -10
  12. package/bin/plastic.js +7 -3
  13. package/hooks/code-gate +10 -1
  14. package/hooks/hooks.json +28 -3
  15. package/hooks/lock-gate +21 -0
  16. package/hooks/retrieval-gate +10 -0
  17. package/hooks/savepoint-pre +10 -0
  18. package/hooks/statusline +48 -6
  19. package/package.json +2 -1
  20. package/scripts/agent-report +21 -0
  21. package/scripts/dashboard.rb +98 -23
  22. package/scripts/doctor.rb +404 -5
  23. package/scripts/hook-auto-arm +1 -1
  24. package/scripts/hook-bash-gate +22 -4
  25. package/scripts/hook-code-gate +11 -6
  26. package/scripts/hook-continue +17 -0
  27. package/scripts/hook-create-gate +45 -9
  28. package/scripts/hook-gate-check +24 -23
  29. package/scripts/hook-lock-gate +83 -0
  30. package/scripts/hook-retrieval-gate +148 -0
  31. package/scripts/hook-savepoint-pre +32 -0
  32. package/scripts/hook-session-start +1 -1
  33. package/scripts/insight-append +51 -0
  34. package/scripts/install.rb +44 -7
  35. package/scripts/lib/agent_models.rb +43 -0
  36. package/scripts/lib/bridge.rb +924 -84
  37. package/scripts/lib/dashboard_banner.rb +42 -0
  38. package/scripts/lib/frontmatter_writer.rb +130 -0
  39. package/scripts/lib/graph_rebuild.rb +328 -0
  40. package/scripts/lib/hook_registry.rb +95 -0
  41. package/scripts/lib/insights.rb +86 -0
  42. package/scripts/lib/installer_core.rb +104 -60
  43. package/scripts/lib/link_suggestions.rb +319 -0
  44. package/scripts/lib/links_projection.rb +160 -0
  45. package/scripts/lib/links_section.rb +207 -0
  46. package/scripts/lib/lock.rb +375 -0
  47. package/scripts/lib/power_tools.rb +22 -21
  48. package/scripts/lib/preflight.rb +79 -0
  49. package/scripts/lib/qmd_sync.rb +15 -0
  50. package/scripts/lib/retrieval_gate.rb +211 -0
  51. package/scripts/lib/worktree.rb +384 -0
  52. package/scripts/link-suggest +213 -0
  53. package/scripts/new-intent +138 -29
  54. package/scripts/plastic-lock +164 -0
  55. package/scripts/project-links +287 -0
  56. package/scripts/read-config +4 -0
  57. package/scripts/rebuild-graph +244 -0
  58. package/scripts/spawn-preamble +11 -3
  59. package/scripts/update.rb +16 -7
  60. package/skills/auto/SKILL.md +126 -19
  61. package/skills/auto/references/agent-architecture.md +7 -4
  62. package/skills/auto/references/agent-report-contract.md +36 -1
  63. package/skills/auto/references/end-tail.md +56 -0
  64. package/skills/auto/references/human-report-contract.md +55 -0
  65. package/skills/auto/references/tiers.md +77 -0
  66. package/skills/brainstorming/SKILL.md +7 -34
  67. package/skills/brainstorming/references/design-principles.md +49 -0
  68. package/skills/continuing/SKILL.md +26 -7
  69. package/skills/creating-intent/SKILL.md +13 -28
  70. package/skills/creating-intent/references/lifecycle.md +9 -4
  71. package/skills/creating-project/SKILL.md +11 -74
  72. package/skills/creating-project/references/project-scaffolding.md +97 -0
  73. package/skills/creating-skills/SKILL.md +65 -0
  74. package/skills/creating-skills/evals/evals.json +108 -0
  75. package/skills/creating-skills/references/agents.md +168 -0
  76. package/skills/creating-skills/references/evals.md +41 -0
  77. package/skills/creating-skills/references/hooks.md +248 -0
  78. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  79. package/skills/creating-skills/references/scripts.md +166 -0
  80. package/skills/creating-skills/references/skills.md +165 -0
  81. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  82. package/skills/dashboard/SKILL.md +13 -11
  83. package/skills/dashboard/references/classification.md +22 -0
  84. package/skills/doctor/SKILL.md +10 -7
  85. package/skills/executing-plan/SKILL.md +4 -4
  86. package/skills/humanizer/SKILL.md +39 -0
  87. package/skills/humanizer/evals/evals.json +70 -0
  88. package/skills/humanizer/references/always-on-snippet.md +9 -0
  89. package/skills/humanizer/references/examples.md +48 -0
  90. package/skills/install/SKILL.md +75 -84
  91. package/skills/intent-curator/SKILL.md +5 -1
  92. package/skills/intent-discovery/SKILL.md +47 -0
  93. package/skills/intent-starting/SKILL.md +130 -0
  94. package/skills/intent-starting/evals/evals.json +117 -0
  95. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  96. package/skills/linking-intents/SKILL.md +48 -12
  97. package/skills/linking-intents/references/zettelkasten.md +7 -0
  98. package/skills/lock/SKILL.md +41 -0
  99. package/skills/managing-index/SKILL.md +6 -0
  100. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  101. package/skills/releasing/SKILL.md +44 -29
  102. package/skills/releasing/references/promotion-and-tagging.md +60 -0
  103. package/skills/roadmap/SKILL.md +51 -0
  104. package/skills/roadmap/references/file-format.md +91 -0
  105. package/skills/roadmap/references/operations.md +78 -0
  106. package/skills/uninstall/SKILL.md +29 -11
  107. package/skills/update/SKILL.md +34 -23
  108. package/skills/versions/SKILL.md +27 -12
  109. package/skills/writing-plans/SKILL.md +10 -88
  110. package/skills/writing-plans/references/plan-format.md +102 -0
  111. package/templates/config.yml +8 -0
  112. package/templates/outcome.md +3 -0
  113. package/templates/revisions.md +58 -0
  114. package/templates/roadmap.md +30 -0
  115. package/skills/writing-instructions/SKILL.md +0 -159
  116. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -0,0 +1,375 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "fileutils"
6
+ require "socket"
7
+ require "time"
8
+
9
+ # Lock: the durable single-owner delivery lock (intent 108).
10
+ #
11
+ # One JSON lock file per intent, delivery.lock, living IN the intent dir beside
12
+ # savepoint.md (git-ignored, transient state). Ownership is session-keyed (D1):
13
+ # the file records the owner session, never a pid. Liveness is a lease: the
14
+ # owner's hooks touch the file mtime on tool calls (heartbeat); the lock is
15
+ # stale only when that heartbeat is older than the TTL. The /tmp bridge is a
16
+ # per-session CACHE of this state; on any disagreement the lock file wins (D2).
17
+ #
18
+ # Mutual-exclusion seam (D3): the schema carries a type ("delivery" now,
19
+ # "maintenance" in a chained intent after 93) and acquire refuses while the
20
+ # OTHER type is fresh. Only the seam ships in 108.
21
+ #
22
+ # Pure and dependency-injected: every function takes explicit paths plus ttl:
23
+ # and now:; nothing here reads ENV or globals, and nothing shells out.
24
+ module Lock
25
+ module_function
26
+
27
+ TYPES = %w[delivery maintenance].freeze
28
+
29
+ # Lease TTL. Heartbeats fire from the write-path hooks (PostToolUse
30
+ # gate-check and the lock-gate allow path), so a delivering session
31
+ # refreshes constantly; 30 minutes tolerates long read-only stretches
32
+ # without opening a takeover window mid-delivery. Reclaim is explicit
33
+ # either way (takeover), so the TTL only bounds WHEN takeover is allowed.
34
+ TTL_SECONDS = 1800
35
+
36
+ def blank?(value)
37
+ value.nil? || value.to_s.strip.empty?
38
+ end
39
+
40
+ def path(intent_dir, type: "delivery")
41
+ File.join(intent_dir, "#{type}.lock")
42
+ end
43
+
44
+ # Parsed lock Hash, or nil when absent or corrupt (corrupt? distinguishes).
45
+ def read(intent_dir, type: "delivery")
46
+ p = path(intent_dir, type: type)
47
+ return nil unless File.exist?(p)
48
+ data = JSON.parse(File.read(p)) rescue nil
49
+ data.is_a?(Hash) ? data : nil
50
+ end
51
+
52
+ def corrupt?(intent_dir, type: "delivery")
53
+ File.exist?(path(intent_dir, type: type)) && read(intent_dir, type: type).nil?
54
+ end
55
+
56
+ # Lease freshness: the file mtime IS the heartbeat.
57
+ def fresh?(intent_dir, type: "delivery", ttl: TTL_SECONDS, now: Time.now)
58
+ p = path(intent_dir, type: type)
59
+ return false unless File.exist?(p)
60
+ (now - File.mtime(p)) <= ttl
61
+ end
62
+
63
+ # session is the owner or a registered delegate (D4).
64
+ def authorized?(data, session)
65
+ return false unless data.is_a?(Hash)
66
+ return false if blank?(session)
67
+ return true if data["owner_session"].to_s == session.to_s
68
+ Array(data["delegates"]).map(&:to_s).include?(session.to_s)
69
+ end
70
+
71
+ # The one question gates ask: does session hold this intent's lock?
72
+ # Owner/delegate on an EXISTING lock counts even when stale (a stale lock is
73
+ # still theirs until an explicit takeover replaces it); freshness only
74
+ # guards AGAINST other sessions.
75
+ def holds?(intent_dir, session:, type: "delivery")
76
+ authorized?(read(intent_dir, type: type), session)
77
+ end
78
+
79
+ # Atomic acquisition (O_EXCL). Returns a [status, data] pair:
80
+ # [:acquired, lock] created fresh
81
+ # [:owned, lock] re-acquire by the current owner (idempotent re-arm)
82
+ # [:held, lock] fresh foreign lock: back off
83
+ # [:stale, lock] expired foreign lock: explicit takeover required
84
+ # [:excluded, other] the OTHER lock type is fresh (D3)
85
+ # [:corrupt, nil] unparseable lock file: run repair
86
+ def acquire(intent_dir, session:, type: "delivery", host: Socket.gethostname,
87
+ ttl: TTL_SECONDS, now: Time.now)
88
+ raise ArgumentError, "unknown lock type #{type.inspect}" unless TYPES.include?(type)
89
+ raise ArgumentError, "lock session must be present" if blank?(session)
90
+
91
+ other = (TYPES - [type]).first
92
+ if fresh?(intent_dir, type: other, ttl: ttl, now: now)
93
+ return [:excluded, read(intent_dir, type: other)]
94
+ end
95
+
96
+ return [:corrupt, nil] if corrupt?(intent_dir, type: type)
97
+
98
+ existing = read(intent_dir, type: type)
99
+ if existing
100
+ if existing["owner_session"].to_s == session.to_s
101
+ data = payload(session: session, type: type, host: host, now: now,
102
+ delegates: Array(existing["delegates"]))
103
+ write(intent_dir, data, type: type)
104
+ return [:owned, data]
105
+ end
106
+ return [:held, existing] if fresh?(intent_dir, type: type, ttl: ttl, now: now)
107
+ return [:stale, existing]
108
+ end
109
+
110
+ data = payload(session: session, type: type, host: host, now: now)
111
+ File.open(path(intent_dir, type: type),
112
+ File::WRONLY | File::CREAT | File::EXCL) do |io|
113
+ io.write(JSON.pretty_generate(data))
114
+ end
115
+ [:acquired, data]
116
+ rescue Errno::EEXIST
117
+ [:held, read(intent_dir, type: type)] # lost the O_EXCL race
118
+ end
119
+
120
+ def payload(session:, type:, host:, now:, delegates: [])
121
+ {
122
+ "type" => type,
123
+ "owner_session" => session.to_s,
124
+ "host" => host,
125
+ "acquired_at" => now.utc.iso8601,
126
+ "delegates" => delegates,
127
+ }
128
+ end
129
+
130
+ # Owner/delegate heartbeat: touch the mtime, never rewrite content.
131
+ def heartbeat(intent_dir, session:, type: "delivery", now: Time.now)
132
+ return false unless holds?(intent_dir, session: session, type: type)
133
+ FileUtils.touch(path(intent_dir, type: type), mtime: now)
134
+ true
135
+ end
136
+
137
+ # Owner registers a delegate (D4): a session allowed to write under this
138
+ # lock. Only the OWNER may delegate; delegates cannot re-delegate.
139
+ def add_delegate(intent_dir, delegate:, session:, type: "delivery")
140
+ data = read(intent_dir, type: type)
141
+ return false if blank?(delegate)
142
+ return false unless data && data["owner_session"].to_s == session.to_s
143
+ data["delegates"] = (Array(data["delegates"]) + [delegate.to_s]).uniq
144
+ write(intent_dir, data, type: type)
145
+ true
146
+ end
147
+
148
+ # Owner releases the lock (disarm / End tail, D6). force: true is the repair
149
+ # path's escape hatch for corrupt or own-session rebuilds.
150
+ # Returns :released, :not_owner, or :none.
151
+ def release(intent_dir, session:, type: "delivery", force: false)
152
+ p = path(intent_dir, type: type)
153
+ return :none unless File.exist?(p)
154
+ data = read(intent_dir, type: type)
155
+ unless force || (data && data["owner_session"].to_s == session.to_s)
156
+ return :not_owner
157
+ end
158
+ File.delete(p)
159
+ :released
160
+ end
161
+
162
+ # Explicit takeover of a stale (or corrupt) lock (D2): replace the lock and
163
+ # append an audit line to savepoint.md. NEVER takes over a fresh foreign
164
+ # lock; there is no silent reclaim path anywhere else.
165
+ # Returns [:taken, data], [:fresh, existing], or acquire's error statuses.
166
+ def takeover(intent_dir, session:, type: "delivery", host: Socket.gethostname,
167
+ ttl: TTL_SECONDS, now: Time.now)
168
+ existing = read(intent_dir, type: type)
169
+ if existing && !authorized?(existing, session) &&
170
+ fresh?(intent_dir, type: type, ttl: ttl, now: now)
171
+ return [:fresh, existing]
172
+ end
173
+
174
+ old_owner = existing ? existing["owner_session"] : "corrupt-or-missing"
175
+ p = path(intent_dir, type: type)
176
+ File.delete(p) if File.exist?(p)
177
+ status, data = acquire(intent_dir, session: session, type: type, host: host,
178
+ ttl: ttl, now: now)
179
+ return [status, data] unless status == :acquired
180
+
181
+ audit = "#{now.utc.iso8601} Lock takeover: #{session} reclaimed #{type} " \
182
+ "lock from #{old_owner}\n"
183
+ File.open(File.join(intent_dir, "savepoint.md"), "a") { |io| io.write(audit) }
184
+ [:taken, data]
185
+ end
186
+
187
+ # Rewrite the lock file in place (owner-side mutations). A content write also
188
+ # refreshes the mtime, which is correct: every sanctioned mutation is owner
189
+ # activity.
190
+ def write(intent_dir, data, type: "delivery")
191
+ File.write(path(intent_dir, type: type), JSON.pretty_generate(data))
192
+ end
193
+ end
194
+
195
+ # Claim: the per-artifact claim-token layer (intent 111, D1/D7). Sits BENEATH
196
+ # the session-keyed delivery lock: a lifecycle-file write must hold BOTH the
197
+ # intent's delivery lock (Lock, unchanged) AND that specific artifact's claim.
198
+ # Neither layer replaces the other.
199
+ #
200
+ # Storage: one small JSON file per artifact, sibling to delivery.lock, under
201
+ # `.claims/<artifact>.claim` INSIDE the intent dir. Scope is strictly
202
+ # per-intent-per-artifact (D4, hard constraint): a claim's on-disk path is
203
+ # always `<intent_dir>/.claims/<artifact>.claim`, so a claim can never affect
204
+ # any artifact but its own, nor any intent but its own. This is what stops a
205
+ # stuck/stale claim from recreating the collision-90 failure mode.
206
+ #
207
+ # Exclusivity is O_EXCL at acquire, not session-equality (see plan.md): a
208
+ # fresh claim is NEVER idempotently re-granted, even to the session that
209
+ # holds it. This is what makes "exactly one writer" mechanical rather than a
210
+ # convention: the second acquire against a live claim is rejected at the
211
+ # filesystem, even when both callers share one CLAUDE_CODE_SESSION_ID.
212
+ #
213
+ # Fail open, always (D3): a stale or corrupt claim never blocks; it yields to
214
+ # the current writer and the condition is surfaced (see Claim.fail_open?,
215
+ # added in a later action, the named contract 112 gates on).
216
+ #
217
+ # Pure and dependency-injected: every function takes explicit paths plus ttl:
218
+ # and now:; nothing here reads ENV or globals, and nothing shells out. Does
219
+ # not touch any Lock function.
220
+ module Claim
221
+ module_function
222
+
223
+ CLAIMS_DIR = ".claims"
224
+
225
+ def dir_path(intent_dir)
226
+ File.join(intent_dir, CLAIMS_DIR)
227
+ end
228
+
229
+ def path(intent_dir, artifact)
230
+ File.join(dir_path(intent_dir), "#{artifact}.claim")
231
+ end
232
+
233
+ # Parsed claim Hash, or nil when absent or corrupt (corrupt? distinguishes).
234
+ def read(intent_dir, artifact)
235
+ p = path(intent_dir, artifact)
236
+ return nil unless File.exist?(p)
237
+ data = JSON.parse(File.read(p)) rescue nil
238
+ data.is_a?(Hash) ? data : nil
239
+ end
240
+
241
+ def corrupt?(intent_dir, artifact)
242
+ File.exist?(path(intent_dir, artifact)) && read(intent_dir, artifact).nil?
243
+ end
244
+
245
+ # Lease freshness: the file mtime IS the heartbeat (mirrors Lock.fresh?).
246
+ def fresh?(intent_dir, artifact, ttl: Lock::TTL_SECONDS, now: Time.now)
247
+ p = path(intent_dir, artifact)
248
+ return false unless File.exist?(p)
249
+ (now - File.mtime(p)) <= ttl
250
+ end
251
+
252
+ def payload(session:, artifact:, now:, delegate: nil)
253
+ {
254
+ "artifact" => artifact,
255
+ "owner_session" => session.to_s,
256
+ "acquired_at" => now.utc.iso8601,
257
+ "delegate" => delegate,
258
+ }
259
+ end
260
+
261
+ # Atomic acquisition (O_EXCL). Returns a [status, data] pair:
262
+ # [:acquired, claim] created fresh
263
+ # [:held, claim] fresh claim (own or foreign): never idempotently
264
+ # re-granted; the caller backs off or waits
265
+ # [:stale, claim] expired claim: caller may take over (see plastic-lock)
266
+ # [:corrupt, nil] unparseable claim file: caller may repair
267
+ def acquire_claim(intent_dir, artifact, session:, delegate: nil,
268
+ ttl: Lock::TTL_SECONDS, now: Time.now)
269
+ raise ArgumentError, "claim session must be present" if Lock.blank?(session)
270
+ raise ArgumentError, "claim artifact must be present" if Lock.blank?(artifact)
271
+
272
+ FileUtils.mkdir_p(dir_path(intent_dir))
273
+ return [:corrupt, nil] if corrupt?(intent_dir, artifact)
274
+
275
+ existing = read(intent_dir, artifact)
276
+ if existing
277
+ return [:held, existing] if fresh?(intent_dir, artifact, ttl: ttl, now: now)
278
+ return [:stale, existing]
279
+ end
280
+
281
+ data = payload(session: session, artifact: artifact, now: now, delegate: delegate)
282
+ File.open(path(intent_dir, artifact),
283
+ File::WRONLY | File::CREAT | File::EXCL) do |io|
284
+ io.write(JSON.pretty_generate(data))
285
+ end
286
+ [:acquired, data]
287
+ rescue Errno::EEXIST
288
+ [:held, read(intent_dir, artifact)] # lost the O_EXCL race
289
+ end
290
+
291
+ # session is the owner or the registered delegate on this claim. Stale-own
292
+ # still counts as holding (mirrors Lock.holds?): the claim is theirs until
293
+ # an explicit takeover replaces it; freshness only guards AGAINST others.
294
+ def holds_claim?(intent_dir, artifact, session:)
295
+ data = read(intent_dir, artifact)
296
+ return false unless data.is_a?(Hash)
297
+ return false if Lock.blank?(session)
298
+ data["owner_session"].to_s == session.to_s || data["delegate"].to_s == session.to_s
299
+ end
300
+
301
+ # Owner (or force:) releases the claim. Returns :none, :not_owner, or
302
+ # :released.
303
+ def release_claim(intent_dir, artifact, session:, force: false)
304
+ p = path(intent_dir, artifact)
305
+ return :none unless File.exist?(p)
306
+ unless force || holds_claim?(intent_dir, artifact, session: session)
307
+ return :not_owner
308
+ end
309
+ File.delete(p)
310
+ :released
311
+ end
312
+
313
+ # Owner/delegate heartbeat: touch the mtime, never rewrite content. False
314
+ # (no-op) when the session does not hold the claim.
315
+ def heartbeat(intent_dir, artifact, session:, now: Time.now)
316
+ return false unless holds_claim?(intent_dir, artifact, session: session)
317
+ FileUtils.touch(path(intent_dir, artifact), mtime: now)
318
+ true
319
+ end
320
+
321
+ # The named fail-open contract (intent 111 D6; intent 112 gates its Exec on
322
+ # this test and re-runs it as a regression check on every lock.rb edit it
323
+ # makes). True iff a claim FILE exists but is unresolvable (stale or
324
+ # corrupt): the write must PROCEED (yield the claim to the current writer)
325
+ # and surface the condition; it MUST NEVER block. Absence of a claim is not
326
+ # fail-open, that is plain dormancy (the gate is not engaged at all).
327
+ def fail_open?(intent_dir, artifact, ttl: Lock::TTL_SECONDS, now: Time.now)
328
+ return true if corrupt?(intent_dir, artifact)
329
+ !!(read(intent_dir, artifact) && !fresh?(intent_dir, artifact, ttl: ttl, now: now))
330
+ end
331
+
332
+ # The data behind `plastic-lock status` (AC5): every live claim under this
333
+ # intent, with enough to show who holds what since when, and whether it is
334
+ # still fresh. Returns [] when no artifact has ever been claimed.
335
+ def claims_status(intent_dir, ttl: Lock::TTL_SECONDS, now: Time.now)
336
+ return [] unless Dir.exist?(dir_path(intent_dir))
337
+ Dir.glob(File.join(dir_path(intent_dir), "*.claim")).sort.map do |file|
338
+ artifact = File.basename(file, ".claim")
339
+ data = begin
340
+ parsed = JSON.parse(File.read(file))
341
+ parsed.is_a?(Hash) ? parsed : nil
342
+ rescue JSON::ParserError
343
+ nil
344
+ end
345
+ {
346
+ "artifact" => (data && data["artifact"]) || artifact,
347
+ "owner_session" => data && data["owner_session"],
348
+ "delegate" => data && data["delegate"],
349
+ "acquired_at" => data && data["acquired_at"],
350
+ "fresh" => fresh?(intent_dir, artifact, ttl: ttl, now: now),
351
+ "corrupt" => data.nil?,
352
+ }
353
+ end
354
+ end
355
+
356
+ # Second, independent write gate at the artifact grain (intent 111 D7). Returns a
357
+ # deny reason String to BLOCK, or nil to ALLOW. Composes UNDER the delivery-lock
358
+ # gate: only reached after the session already holds the intent's delivery lock.
359
+ # ENGAGES only when a claim file exists (dormant otherwise, so single-owner flows
360
+ # and the existing suite stay green, AC7). Fails open on stale/corrupt via
361
+ # fail_open?, the named contract.
362
+ def claim_gate_reason(intent_dir, artifact, session:, ttl: Lock::TTL_SECONDS, now: Time.now)
363
+ return nil if Lock.blank?(artifact)
364
+ return nil unless File.exist?(path(intent_dir, artifact)) # dormant: no claim
365
+ return nil if holds_claim?(intent_dir, artifact, session: session) # you hold it
366
+ return nil if fail_open?(intent_dir, artifact, ttl: ttl, now: now) # stale/corrupt: yield
367
+ data = read(intent_dir, artifact)
368
+ holder = data && data["owner_session"]
369
+ since = data && data["acquired_at"]
370
+ "artifact #{artifact} is claimed by #{holder} since #{since}; another writer holds " \
371
+ "it. Back off or run /plastic-lock status. If you are a distinct delegate, the " \
372
+ "owner must register you: plastic-lock delegate --intent-dir #{intent_dir} " \
373
+ "--session <your-session-id>"
374
+ end
375
+ end
@@ -4,10 +4,10 @@
4
4
  require_relative "qmd_sync"
5
5
 
6
6
  # PowerTools — detect-then-degrade harness for Plastic's optional power-tools
7
- # (intent 66b). It owns deterministic detection of each tool and builds an
8
- # obligation ("mandate") string for whichever tools are present, so the agent is
9
- # obliged (not merely reminded) to use them: QMD for finding intents, Serena for
10
- # code navigation.
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.
11
11
  #
12
12
  # Strictly detect-then-degrade: a tool that is absent contributes nothing, and
13
13
  # `mandate` returns nil when no tool is present. Nothing here installs anything.
@@ -52,25 +52,26 @@ module PowerTools
52
52
  false
53
53
  end
54
54
 
55
- # Obligation text for whichever tools are present, joined by newlines, or nil
56
- # when none are. One MANDATORY line per present tool.
57
- def mandate(cwd:, qmd_detector: QmdSync.method(:detect), serena_detector: nil)
58
- lines = []
59
-
60
- if qmd?(detector: qmd_detector)
61
- lines << "MANDATORY: you MUST use QMD (`qmd search` / `qmd query` over the " \
62
- "`plastic-*` collections) to check for an existing or related intent " \
63
- "before treating this as new work; do not grep/Read the store first."
64
- end
55
+ QMD_OBLIGATION = "prefer `qmd search` / `qmd query` over the `plastic-*` " \
56
+ "collections to check for existing or related intents before " \
57
+ "treating work as new"
58
+ SERENA_OBLIGATION = "prefer its symbolic tools (find_symbol / get_symbols_overview / " \
59
+ "find_referencing_symbols) for code navigation"
65
60
 
61
+ # 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)
66
+ qmd_present = qmd?(detector: qmd_detector)
66
67
  serena_present = serena_detector ? !!serena_detector.call : serena?(cwd: cwd)
67
- if serena_present
68
- lines << "MANDATORY: you MUST use Serena's symbolic tools (find_symbol / " \
69
- "get_symbols_overview / find_referencing_symbols) for code navigation " \
70
- "before grep/Read."
71
- end
72
68
 
73
- return nil if lines.empty?
74
- lines.join("\n")
69
+ if qmd_present && serena_present
70
+ "QMD and Serena are available: #{QMD_OBLIGATION}, and #{SERENA_OBLIGATION}."
71
+ elsif qmd_present
72
+ "QMD is available: #{QMD_OBLIGATION}."
73
+ elsif serena_present
74
+ "Serena is available: #{SERENA_OBLIGATION}."
75
+ end
75
76
  end
76
77
  end
@@ -0,0 +1,79 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "rubygems"
5
+
6
+ # Pure, dependency-injected pre-flight checks for Plastic's runtime dependencies
7
+ # (intent 38). Takes injected probes (ruby version, node version, git presence,
8
+ # mise presence) and returns a plain decision: ok / fatal plus branded messages.
9
+ #
10
+ # No I/O, no shelling out, no ENV reads here. Callers (scripts/install.rb,
11
+ # bin/plastic.js) own the impure probing and the printing, so this module stays
12
+ # hermetically testable. Voice matches boot_banner.rb (understated, "Plastic ..."
13
+ # prefix); no em-dash, no en-dash in any message.
14
+ module Preflight
15
+ module_function
16
+
17
+ RUBY_FLOOR = "3.0.0"
18
+ NODE_FLOOR = 18
19
+ RUBY_PIN = "3.3"
20
+
21
+ def check(ruby_version:, node_version:, git_present:, mise_present:)
22
+ messages = []
23
+
24
+ ruby_message = ruby_issue(ruby_version, mise_present)
25
+ fatal = !ruby_message.nil?
26
+ messages << ruby_message if ruby_message
27
+
28
+ node_message = node_issue(node_version)
29
+ messages << node_message if node_message
30
+
31
+ git_message = git_issue(git_present)
32
+ messages << git_message if git_message
33
+
34
+ { ok: messages.empty?, fatal: fatal, messages: messages }
35
+ end
36
+
37
+ def ruby_issue(ruby_version, mise_present)
38
+ parsed = safe_version(ruby_version)
39
+ return nil if parsed && parsed >= safe_version(RUBY_FLOOR)
40
+
41
+ lines = []
42
+ lines << "Plastic needs Ruby #{RUBY_FLOOR} or newer to run its scripts (found #{found(ruby_version)})."
43
+ lines << "Install a pinned Ruby with mise:"
44
+ lines << " curl https://mise.run | sh # only if mise is not installed yet" unless mise_present
45
+ lines << " mise use --global ruby@#{RUBY_PIN}"
46
+ lines << "Then re-run the Plastic installer."
47
+ lines.join("\n")
48
+ end
49
+
50
+ def node_issue(node_version)
51
+ parsed = safe_version(strip_leading_v(node_version))
52
+ return nil if parsed && parsed >= safe_version(NODE_FLOOR.to_s)
53
+
54
+ "Plastic works best on Node #{NODE_FLOOR} or newer (found #{found(node_version)}). " \
55
+ "Pin it with mise: mise use --global node@25"
56
+ end
57
+
58
+ def git_issue(git_present)
59
+ return nil if git_present
60
+
61
+ "Plastic uses git for its store and worktrees (git was not found). " \
62
+ "Install git, e.g. macOS: xcode-select --install"
63
+ end
64
+
65
+ def safe_version(str)
66
+ Gem::Version.new(str.to_s)
67
+ rescue ArgumentError
68
+ nil
69
+ end
70
+
71
+ def strip_leading_v(str)
72
+ str.to_s.strip.sub(/\Av/, "")
73
+ end
74
+
75
+ def found(value)
76
+ text = value.to_s.strip
77
+ text.empty? ? "not found" : text
78
+ end
79
+ end
@@ -120,6 +120,21 @@ module QmdSync
120
120
  pid
121
121
  end
122
122
 
123
+ # True when the QMD index has no pending (unembedded) documents. Binary
124
+ # freshness signal for the retrieval gate (intent 84, Lever 2). `qmd status` is
125
+ # plain text (no --json); it prints a line like "Pending: N need embedding".
126
+ # No pending line found -> treat as fresh (conservative: a parse miss must not
127
+ # block reads). Runner failure -> false (cannot confirm freshness). The caller
128
+ # gates on `detect` first, so absence is handled upstream; this only answers
129
+ # "is the present index fresh?". Pure via the injected runner.
130
+ def self.fresh?(runner: default_runner)
131
+ out, ok = runner.call(["status"])
132
+ return false unless ok && out
133
+ m = out[/^\s*Pending:\s*(\d+)\b/i, 1]
134
+ pending = m ? m.to_i : 0
135
+ pending.zero?
136
+ end
137
+
123
138
  # Read-only status used by doctor and the session-start report line.
124
139
  # Returns a structured hash; never mutates the index.
125
140
  def status(plastic_home:, runner: default_runner, detector: method(:detect))