@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.
Files changed (107) hide show
  1. package/PLASTIC-reference.md +133 -0
  2. package/PLASTIC.md +398 -82
  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 +232 -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/hook_registry.rb +95 -0
  39. package/scripts/lib/insights.rb +86 -0
  40. package/scripts/lib/installer_core.rb +100 -60
  41. package/scripts/lib/link_suggestions.rb +319 -0
  42. package/scripts/lib/lock.rb +375 -0
  43. package/scripts/lib/power_tools.rb +22 -21
  44. package/scripts/lib/preflight.rb +79 -0
  45. package/scripts/lib/qmd_sync.rb +15 -0
  46. package/scripts/lib/retrieval_gate.rb +211 -0
  47. package/scripts/lib/worktree.rb +384 -0
  48. package/scripts/link-suggest +213 -0
  49. package/scripts/new-intent +9 -1
  50. package/scripts/plastic-lock +164 -0
  51. package/scripts/read-config +4 -0
  52. package/scripts/spawn-preamble +11 -3
  53. package/scripts/update.rb +16 -7
  54. package/skills/auto/SKILL.md +126 -19
  55. package/skills/auto/references/agent-architecture.md +7 -4
  56. package/skills/auto/references/agent-report-contract.md +36 -1
  57. package/skills/auto/references/end-tail.md +56 -0
  58. package/skills/auto/references/human-report-contract.md +55 -0
  59. package/skills/auto/references/tiers.md +77 -0
  60. package/skills/brainstorming/SKILL.md +7 -34
  61. package/skills/brainstorming/references/design-principles.md +49 -0
  62. package/skills/continuing/SKILL.md +26 -7
  63. package/skills/creating-intent/SKILL.md +13 -28
  64. package/skills/creating-project/SKILL.md +11 -74
  65. package/skills/creating-project/references/project-scaffolding.md +97 -0
  66. package/skills/creating-skills/SKILL.md +65 -0
  67. package/skills/creating-skills/evals/evals.json +108 -0
  68. package/skills/creating-skills/references/agents.md +168 -0
  69. package/skills/creating-skills/references/evals.md +41 -0
  70. package/skills/creating-skills/references/hooks.md +248 -0
  71. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  72. package/skills/creating-skills/references/scripts.md +166 -0
  73. package/skills/creating-skills/references/skills.md +165 -0
  74. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  75. package/skills/dashboard/SKILL.md +13 -11
  76. package/skills/dashboard/references/classification.md +22 -0
  77. package/skills/doctor/SKILL.md +10 -7
  78. package/skills/executing-plan/SKILL.md +4 -4
  79. package/skills/humanizer/SKILL.md +39 -0
  80. package/skills/humanizer/evals/evals.json +70 -0
  81. package/skills/humanizer/references/always-on-snippet.md +9 -0
  82. package/skills/humanizer/references/examples.md +48 -0
  83. package/skills/install/SKILL.md +75 -84
  84. package/skills/intent-curator/SKILL.md +5 -1
  85. package/skills/intent-discovery/SKILL.md +47 -0
  86. package/skills/intent-starting/SKILL.md +130 -0
  87. package/skills/intent-starting/evals/evals.json +117 -0
  88. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  89. package/skills/linking-intents/SKILL.md +48 -12
  90. package/skills/lock/SKILL.md +41 -0
  91. package/skills/managing-index/SKILL.md +6 -0
  92. package/skills/releasing/SKILL.md +44 -29
  93. package/skills/releasing/references/promotion-and-tagging.md +60 -0
  94. package/skills/roadmap/SKILL.md +51 -0
  95. package/skills/roadmap/references/file-format.md +91 -0
  96. package/skills/roadmap/references/operations.md +78 -0
  97. package/skills/uninstall/SKILL.md +29 -11
  98. package/skills/update/SKILL.md +34 -23
  99. package/skills/versions/SKILL.md +27 -12
  100. package/skills/writing-plans/SKILL.md +10 -88
  101. package/skills/writing-plans/references/plan-format.md +102 -0
  102. package/templates/config.yml +8 -0
  103. package/templates/outcome.md +3 -0
  104. package/templates/revisions.md +58 -0
  105. package/templates/roadmap.md +30 -0
  106. package/skills/writing-instructions/SKILL.md +0 -159
  107. 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