@zalom/plastic 1.0.0-beta.9 → 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 +398 -82
- 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 +232 -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/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +100 -60
- package/scripts/lib/link_suggestions.rb +319 -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 +9 -1
- package/scripts/plastic-lock +164 -0
- package/scripts/read-config +4 -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-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/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +6 -0
- 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,319 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# LinkSuggestions - support links decided by CONTEXT INFLUENCE (intent 91, D7), not
|
|
5
|
+
# by shared files, shared symbols, or a topic-similarity score. A script cannot judge
|
|
6
|
+
# whether one intent's context influenced another (that is reasoning over meaning), so
|
|
7
|
+
# this helper does NOT grade. It only:
|
|
8
|
+
#
|
|
9
|
+
# - gather: DISCOVERY. Find candidate intents (via an injected candidate-finder)
|
|
10
|
+
# and surface each candidate's `## Intent` + `## Context` as the
|
|
11
|
+
# EVIDENCE an agent reads to judge influence.
|
|
12
|
+
# - record_edge: record a CONFIRMED `sources`/`chain` frontmatter edge AND append a
|
|
13
|
+
# dated line to the subject intent file's `## Insights` section
|
|
14
|
+
# carrying (utc, target, edge, rating, reason).
|
|
15
|
+
# Append-only, frontmatter block only, never a `## Links` line,
|
|
16
|
+
# never a delete. A no-op without confirm: true.
|
|
17
|
+
# - drift: flag any `## Links` wikilink with no matching frontmatter edge.
|
|
18
|
+
#
|
|
19
|
+
# Two systems frame this:
|
|
20
|
+
# - System for Brain: links are tiered by context influence (sources = foundational
|
|
21
|
+
# context that shaped creation; chain = the genuinely delivery-moving context, a
|
|
22
|
+
# HIGH bar; tags = loose theme grouping, not a link). The influence judgement is
|
|
23
|
+
# made by an agent, not here.
|
|
24
|
+
# - System for Work (Convention over Configuration): `## Links` is a derived view of
|
|
25
|
+
# `sources`/`chain`. This helper never authors a `## Links` line and never deletes.
|
|
26
|
+
#
|
|
27
|
+
# Design rules: all collaborators are injected via the constructor (the store dir, a
|
|
28
|
+
# filesystem reader, AND a candidate-finder). No eval, no ENV / global config seam.
|
|
29
|
+
# Reuses IntentValidator.parse_frontmatter; does NOT touch LinksProjection /
|
|
30
|
+
# LinksSection / project-links / doctor.
|
|
31
|
+
|
|
32
|
+
require "time"
|
|
33
|
+
require_relative "intent_validator"
|
|
34
|
+
require_relative "insights"
|
|
35
|
+
|
|
36
|
+
class LinkSuggestions
|
|
37
|
+
# One discovery candidate plus the evidence an agent reads to judge influence.
|
|
38
|
+
Candidate = Struct.new(:id, :basename, :label, :intent, :context, keyword_init: true)
|
|
39
|
+
|
|
40
|
+
# A drift finding: a `## Links` ref with no matching `sources`/`chain` edge.
|
|
41
|
+
Drift = Struct.new(:ref, :detail, keyword_init: true)
|
|
42
|
+
|
|
43
|
+
RATINGS = %w[high medium low].freeze
|
|
44
|
+
|
|
45
|
+
# A minimal default filesystem reader. Injected so tests can substitute an
|
|
46
|
+
# in-memory map; the production path reads real files. No global state.
|
|
47
|
+
class DiskReader
|
|
48
|
+
def directory?(path)
|
|
49
|
+
File.directory?(path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def children(path)
|
|
53
|
+
Dir.children(path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def exist?(path)
|
|
57
|
+
File.exist?(path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read(path)
|
|
61
|
+
File.read(path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def write(path, content)
|
|
65
|
+
File.write(path, content)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Default candidate-finder: a cheap discovery net (NOT a grade) over the loaded
|
|
70
|
+
# nodes - candidates that share a non-project tag, share a `sources` parent/family,
|
|
71
|
+
# or sit at an adjacent id. The CLI may inject a QMD-backed finder instead. Either
|
|
72
|
+
# way this is DISCOVERY ONLY; influence is judged later by an agent.
|
|
73
|
+
class FamilyTagFinder
|
|
74
|
+
def call(subject_id, nodes)
|
|
75
|
+
subject = nodes[subject_id]
|
|
76
|
+
return [] unless subject
|
|
77
|
+
|
|
78
|
+
nodes.keys.select do |other_id|
|
|
79
|
+
next false if other_id == subject_id
|
|
80
|
+
|
|
81
|
+
other = nodes[other_id]
|
|
82
|
+
shares_tag?(subject, other) || shares_family?(subject, other) ||
|
|
83
|
+
adjacent_id?(subject_id, other_id)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def shares_tag?(subject, other)
|
|
90
|
+
!(link_tags(subject) & link_tags(other)).empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def link_tags(node)
|
|
94
|
+
node[:tags].reject { |t| t.start_with?("project-") }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def shares_family?(subject, other)
|
|
98
|
+
return true unless (subject[:sources] & other[:sources]).empty?
|
|
99
|
+
|
|
100
|
+
subject[:sources].include?(other[:id]) || other[:sources].include?(subject[:id])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Adjacent ids (discovery hint only): equal non-numeric prefix with integers
|
|
104
|
+
# differing by 1 (9<->10, 99<->100, 90<->91), or a letter successor at the same
|
|
105
|
+
# depth (66a<->66b). This is NOT a grade; it only widens the candidate net.
|
|
106
|
+
def adjacent_id?(a, b)
|
|
107
|
+
return false if a == b || a.empty? || b.empty?
|
|
108
|
+
|
|
109
|
+
ma = a.match(/\A(.*?)(\d+)\z/)
|
|
110
|
+
mb = b.match(/\A(.*?)(\d+)\z/)
|
|
111
|
+
return (ma[2].to_i - mb[2].to_i).abs == 1 if ma && mb && ma[1] == mb[1]
|
|
112
|
+
|
|
113
|
+
return false unless a.length == b.length
|
|
114
|
+
|
|
115
|
+
a[0..-2] == b[0..-2] && a[-1].match?(/[a-z]/) && b[-1].match?(/[a-z]/) &&
|
|
116
|
+
(a[-1].succ == b[-1] || b[-1].succ == a[-1])
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# store_dir - the directory holding `id--slug/` intent folders for ONE store.
|
|
121
|
+
# reader - injected filesystem collaborator (DiskReader by default).
|
|
122
|
+
# finder - injected candidate-finder responding to #call(subject_id, nodes).
|
|
123
|
+
def initialize(store_dir:, reader: DiskReader.new, finder: FamilyTagFinder.new)
|
|
124
|
+
@store_dir = store_dir
|
|
125
|
+
@reader = reader
|
|
126
|
+
@finder = finder
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
attr_reader :store_dir, :reader, :finder
|
|
130
|
+
|
|
131
|
+
# Load every intent in the store as a node Hash keyed by id:
|
|
132
|
+
# { id => { id:, basename:, label:, path:, dir:, sources:[], chain:[], tags:[],
|
|
133
|
+
# links_refs:[], intent:, context: } }
|
|
134
|
+
def load_nodes
|
|
135
|
+
nodes = {}
|
|
136
|
+
return nodes unless reader.directory?(store_dir)
|
|
137
|
+
|
|
138
|
+
reader.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
|
|
139
|
+
dir = File.join(store_dir, entry)
|
|
140
|
+
next unless reader.directory?(dir)
|
|
141
|
+
|
|
142
|
+
md = File.join(dir, "#{entry}.md")
|
|
143
|
+
next unless reader.exist?(md)
|
|
144
|
+
|
|
145
|
+
content = reader.read(md)
|
|
146
|
+
fm = IntentValidator.parse_frontmatter_text(content)
|
|
147
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
148
|
+
|
|
149
|
+
id = fm["id"].to_s
|
|
150
|
+
body = IntentValidator.body_of(content)
|
|
151
|
+
nodes[id] = {
|
|
152
|
+
id: id,
|
|
153
|
+
basename: entry,
|
|
154
|
+
label: fm["intent"].to_s.strip,
|
|
155
|
+
path: md,
|
|
156
|
+
dir: dir,
|
|
157
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
158
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
159
|
+
tags: Array(fm["tags"]).map(&:to_s),
|
|
160
|
+
links_refs: links_refs(body),
|
|
161
|
+
intent: section_text(body, "Intent"),
|
|
162
|
+
context: section_text(body, "Context"),
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
nodes
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# DISCOVERY. The candidate intents for `subject_id`, each carrying its Intent +
|
|
169
|
+
# Context as the evidence an agent reads to judge influence. No grading. Sorted by
|
|
170
|
+
# natural id order for stable output.
|
|
171
|
+
def gather(subject_id, nodes: load_nodes)
|
|
172
|
+
return [] unless nodes.key?(subject_id)
|
|
173
|
+
|
|
174
|
+
ids = finder.call(subject_id, nodes)
|
|
175
|
+
ids.uniq.sort_by { |id| natural_key(id) }.filter_map do |id|
|
|
176
|
+
node = nodes[id]
|
|
177
|
+
next unless node
|
|
178
|
+
|
|
179
|
+
Candidate.new(id: id, basename: node[:basename], label: node[:label],
|
|
180
|
+
intent: node[:intent], context: node[:context])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Record a single CONFIRMED edge from `subject_id` to `target_id`:
|
|
185
|
+
# 1. append `target_id` to the subject's frontmatter `sources` or `chain`;
|
|
186
|
+
# 2. append a dated line to the subject's `## Insights` section capturing
|
|
187
|
+
# {utc, target, edge, rating, reason}.
|
|
188
|
+
# Append-only, frontmatter block only. NEVER writes a `## Links` line, NEVER
|
|
189
|
+
# deletes. A no-op (returns false) without confirm: true, so a default run mutates
|
|
190
|
+
# nothing. Returns true when it wrote, false when it declined or the edge existed.
|
|
191
|
+
def record_edge(subject_id, target_id, edge:, rating: nil, reason: nil,
|
|
192
|
+
confirm: false, now: Time.now, nodes: load_nodes)
|
|
193
|
+
return false unless confirm
|
|
194
|
+
return false unless %i[sources chain].include?(edge)
|
|
195
|
+
|
|
196
|
+
subject = nodes[subject_id]
|
|
197
|
+
return false unless subject
|
|
198
|
+
return false if subject[edge].include?(target_id)
|
|
199
|
+
|
|
200
|
+
content = reader.read(subject[:path])
|
|
201
|
+
updated = add_frontmatter_ref(content, edge.to_s, target_id)
|
|
202
|
+
return false if updated == content
|
|
203
|
+
|
|
204
|
+
final = append_link_insight(updated, target_id, edge, rating, reason, now)
|
|
205
|
+
reader.write(subject[:path], final)
|
|
206
|
+
true
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# DRIFT. The `## Links` refs on `subject_id` with no matching `sources`/`chain`
|
|
210
|
+
# frontmatter edge behind them. Fence-skipping is honored so a `[[id]]` inside an
|
|
211
|
+
# example code block is not flagged.
|
|
212
|
+
def drift(subject_id, nodes: load_nodes)
|
|
213
|
+
subject = nodes[subject_id]
|
|
214
|
+
return [] unless subject
|
|
215
|
+
|
|
216
|
+
edges = (subject[:sources] + subject[:chain]).map { |r| bare_ref(r) }
|
|
217
|
+
subject[:links_refs].reject { |r| edges.include?(bare_ref(r)) }.map do |ref|
|
|
218
|
+
Drift.new(ref: ref,
|
|
219
|
+
detail: "`## Links` references #{ref} with no sources/chain edge behind it")
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
# Insert one dated link-decision line at the bottom of the subject intent file's
|
|
226
|
+
# `## Insights` section (96 D3: link rationale lives IN the intent file, never a side
|
|
227
|
+
# file). Reuses the blessed Insights writer (intent 82) for section insertion; carries
|
|
228
|
+
# the five fields (utc, target, edge, rating, reason) as a readable line under the 82
|
|
229
|
+
# exact-timestamp prefix. Returns the augmented content for the single caller write.
|
|
230
|
+
def append_link_insight(content, target_id, edge, rating, reason, now)
|
|
231
|
+
prefix = "#{now.utc.iso8601} · Link · link-suggest"
|
|
232
|
+
text = "#{edge} edge to #{target_id} (rating #{rating || "-"}): #{reason || "-"}"
|
|
233
|
+
Insights.with_entry(content, "#{prefix} — #{text}")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Natural sort key for ids so 2 sorts before 10 and 14a groups with 14.
|
|
237
|
+
def natural_key(id)
|
|
238
|
+
id.scan(/\d+|[a-z]+/).map { |part| part.match?(/\d/) ? [0, part.to_i, ""] : [1, 0, part] }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Strip a `store:` prefix from a ref so a cross-store and same-store form compare.
|
|
242
|
+
def bare_ref(ref)
|
|
243
|
+
ref.to_s.include?(":") ? ref.to_s.split(":", 2).last : ref.to_s
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Extract bare-id wikilink refs from a body's `## Links` section. Matches
|
|
247
|
+
# `[[<id>--<slug>|...]]` and `[[<store>:<id>--<slug>|...]]`, yielding the bare id.
|
|
248
|
+
# Reads only the Links section so example fences elsewhere are not scanned.
|
|
249
|
+
def links_refs(body)
|
|
250
|
+
section = section_text(body, "Links")
|
|
251
|
+
return [] if section.empty?
|
|
252
|
+
|
|
253
|
+
section.scan(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/).filter_map do |match|
|
|
254
|
+
t = bare_ref(match.first.strip)
|
|
255
|
+
id = t.split("--", 2).first
|
|
256
|
+
id unless id.nil? || id.empty?
|
|
257
|
+
end.uniq
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The text under the first `## <heading>` heading, up to the next `## ` heading. A
|
|
261
|
+
# deliberately small reader since we only need the section content. Fence lines are
|
|
262
|
+
# skipped so a `## Links` (or any heading) inside an example block is ignored.
|
|
263
|
+
def section_text(body, heading)
|
|
264
|
+
lines = body.to_s.lines
|
|
265
|
+
out = []
|
|
266
|
+
capture = false
|
|
267
|
+
in_fence = false
|
|
268
|
+
lines.each do |line|
|
|
269
|
+
stripped = line.strip
|
|
270
|
+
if stripped.start_with?("```")
|
|
271
|
+
in_fence = !in_fence
|
|
272
|
+
next
|
|
273
|
+
end
|
|
274
|
+
next if in_fence
|
|
275
|
+
|
|
276
|
+
if stripped == "## #{heading}"
|
|
277
|
+
capture = true
|
|
278
|
+
next
|
|
279
|
+
end
|
|
280
|
+
break if capture && stripped.start_with?("## ")
|
|
281
|
+
|
|
282
|
+
out << line if capture
|
|
283
|
+
end
|
|
284
|
+
out.join.strip
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Append a ref to a frontmatter array (`sources` or `chain`), creating the key if
|
|
288
|
+
# absent. Touches ONLY the frontmatter block; the body (including `## Links`) is
|
|
289
|
+
# left byte-identical. Inline-flow arrays (`key: ["a", "b"]`) are extended in place;
|
|
290
|
+
# an absent key is inserted before the closing `---`. Never deletes.
|
|
291
|
+
def add_frontmatter_ref(content, key, ref)
|
|
292
|
+
return content unless content.start_with?("---")
|
|
293
|
+
|
|
294
|
+
parts = content.split("---", 3)
|
|
295
|
+
return content if parts.length < 3
|
|
296
|
+
|
|
297
|
+
fm = parts[1]
|
|
298
|
+
line_re = /^#{Regexp.escape(key)}:\s*(.*)$/
|
|
299
|
+
if fm =~ line_re
|
|
300
|
+
current = Regexp.last_match(1).strip
|
|
301
|
+
new_line = extend_flow_array(key, current, ref)
|
|
302
|
+
fm = fm.sub(line_re, new_line)
|
|
303
|
+
else
|
|
304
|
+
fm = fm.rstrip + "\n#{key}: [\"#{ref}\"]\n"
|
|
305
|
+
end
|
|
306
|
+
"---#{fm}---#{parts[2]}"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def extend_flow_array(key, current, ref)
|
|
310
|
+
if current.empty? || current == "[]"
|
|
311
|
+
%(#{key}: ["#{ref}"])
|
|
312
|
+
elsif current.start_with?("[") && current.end_with?("]")
|
|
313
|
+
inner = current[1..-2].strip
|
|
314
|
+
inner.empty? ? %(#{key}: ["#{ref}"]) : %(#{key}: [#{inner}, "#{ref}"])
|
|
315
|
+
else
|
|
316
|
+
%(#{key}: ["#{ref}"])
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -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
|