@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.
- package/PLASTIC-reference.md +133 -0
- package/PLASTIC.md +407 -84
- package/README.md +99 -82
- package/agents/plastic-brainstorming.md +12 -9
- package/agents/plastic-enforcer.md +52 -12
- package/agents/plastic-executor.md +10 -10
- package/agents/plastic-future-intent-researcher.md +4 -8
- package/agents/plastic-intent-curator.md +12 -14
- package/agents/plastic-intent-discovery.md +35 -0
- package/agents/plastic-planner.md +15 -10
- package/agents/plastic-spec-specialist.md +15 -10
- package/bin/plastic.js +7 -3
- package/hooks/code-gate +10 -1
- package/hooks/hooks.json +28 -3
- package/hooks/lock-gate +21 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/hooks/statusline +48 -6
- package/package.json +2 -1
- package/scripts/agent-report +21 -0
- package/scripts/dashboard.rb +98 -23
- package/scripts/doctor.rb +404 -5
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +22 -4
- package/scripts/hook-code-gate +11 -6
- package/scripts/hook-continue +17 -0
- package/scripts/hook-create-gate +45 -9
- package/scripts/hook-gate-check +24 -23
- package/scripts/hook-lock-gate +83 -0
- package/scripts/hook-retrieval-gate +148 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/hook-session-start +1 -1
- package/scripts/insight-append +51 -0
- package/scripts/install.rb +44 -7
- package/scripts/lib/agent_models.rb +43 -0
- package/scripts/lib/bridge.rb +924 -84
- package/scripts/lib/dashboard_banner.rb +42 -0
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +104 -60
- package/scripts/lib/link_suggestions.rb +319 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/lock.rb +375 -0
- package/scripts/lib/power_tools.rb +22 -21
- package/scripts/lib/preflight.rb +79 -0
- package/scripts/lib/qmd_sync.rb +15 -0
- package/scripts/lib/retrieval_gate.rb +211 -0
- package/scripts/lib/worktree.rb +384 -0
- package/scripts/link-suggest +213 -0
- package/scripts/new-intent +138 -29
- package/scripts/plastic-lock +164 -0
- package/scripts/project-links +287 -0
- package/scripts/read-config +4 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +11 -3
- package/scripts/update.rb +16 -7
- package/skills/auto/SKILL.md +126 -19
- package/skills/auto/references/agent-architecture.md +7 -4
- package/skills/auto/references/agent-report-contract.md +36 -1
- package/skills/auto/references/end-tail.md +56 -0
- package/skills/auto/references/human-report-contract.md +55 -0
- package/skills/auto/references/tiers.md +77 -0
- package/skills/brainstorming/SKILL.md +7 -34
- package/skills/brainstorming/references/design-principles.md +49 -0
- package/skills/continuing/SKILL.md +26 -7
- package/skills/creating-intent/SKILL.md +13 -28
- package/skills/creating-intent/references/lifecycle.md +9 -4
- package/skills/creating-project/SKILL.md +11 -74
- package/skills/creating-project/references/project-scaffolding.md +97 -0
- package/skills/creating-skills/SKILL.md +65 -0
- package/skills/creating-skills/evals/evals.json +108 -0
- package/skills/creating-skills/references/agents.md +168 -0
- package/skills/creating-skills/references/evals.md +41 -0
- package/skills/creating-skills/references/hooks.md +248 -0
- package/skills/creating-skills/references/progressive-disclosure.md +176 -0
- package/skills/creating-skills/references/scripts.md +166 -0
- package/skills/creating-skills/references/skills.md +165 -0
- package/skills/creating-skills/scripts/scaffold.rb +313 -0
- package/skills/dashboard/SKILL.md +13 -11
- package/skills/dashboard/references/classification.md +22 -0
- package/skills/doctor/SKILL.md +10 -7
- package/skills/executing-plan/SKILL.md +4 -4
- package/skills/humanizer/SKILL.md +39 -0
- package/skills/humanizer/evals/evals.json +70 -0
- package/skills/humanizer/references/always-on-snippet.md +9 -0
- package/skills/humanizer/references/examples.md +48 -0
- package/skills/install/SKILL.md +75 -84
- package/skills/intent-curator/SKILL.md +5 -1
- package/skills/intent-discovery/SKILL.md +47 -0
- package/skills/intent-starting/SKILL.md +130 -0
- package/skills/intent-starting/evals/evals.json +117 -0
- package/skills/intent-starting/references/boarding-matrix.md +35 -0
- package/skills/linking-intents/SKILL.md +48 -12
- package/skills/linking-intents/references/zettelkasten.md +7 -0
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +6 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
- package/skills/releasing/SKILL.md +44 -29
- package/skills/releasing/references/promotion-and-tagging.md +60 -0
- package/skills/roadmap/SKILL.md +51 -0
- package/skills/roadmap/references/file-format.md +91 -0
- package/skills/roadmap/references/operations.md +78 -0
- package/skills/uninstall/SKILL.md +29 -11
- package/skills/update/SKILL.md +34 -23
- package/skills/versions/SKILL.md +27 -12
- package/skills/writing-plans/SKILL.md +10 -88
- package/skills/writing-plans/references/plan-format.md +102 -0
- package/templates/config.yml +8 -0
- package/templates/outcome.md +3 -0
- package/templates/revisions.md +58 -0
- package/templates/roadmap.md +30 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- 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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
package/scripts/lib/qmd_sync.rb
CHANGED
|
@@ -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))
|