@zalom/plastic 1.0.0-beta.3 → 1.0.0-beta.31

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 (102) hide show
  1. package/PLASTIC.md +293 -7
  2. package/README.md +10 -0
  3. package/agents/plastic-brainstorming.md +2 -2
  4. package/agents/plastic-enforcer.md +17 -2
  5. package/agents/plastic-executor.md +3 -3
  6. package/agents/plastic-future-intent-researcher.md +2 -1
  7. package/agents/plastic-intent-curator.md +15 -7
  8. package/agents/plastic-intent-discovery.md +37 -0
  9. package/agents/plastic-planner.md +2 -2
  10. package/agents/plastic-spec-specialist.md +2 -2
  11. package/hooks/code-gate +10 -1
  12. package/hooks/hooks.json +28 -3
  13. package/hooks/lock-gate +21 -0
  14. package/hooks/retrieval-gate +10 -0
  15. package/hooks/savepoint-pre +10 -0
  16. package/hooks/statusline +150 -41
  17. package/package.json +1 -1
  18. package/scripts/agent-report +21 -0
  19. package/scripts/doctor.rb +404 -5
  20. package/scripts/hook-auto-arm +1 -1
  21. package/scripts/hook-bash-gate +22 -4
  22. package/scripts/hook-code-gate +11 -6
  23. package/scripts/hook-create-gate +45 -9
  24. package/scripts/hook-gate-check +24 -23
  25. package/scripts/hook-lock-gate +83 -0
  26. package/scripts/hook-retrieval-gate +148 -0
  27. package/scripts/hook-savepoint-pre +32 -0
  28. package/scripts/hook-session-start +1 -1
  29. package/scripts/insight-append +51 -0
  30. package/scripts/lib/agent_models.rb +43 -0
  31. package/scripts/lib/bridge.rb +625 -47
  32. package/scripts/lib/frontmatter_writer.rb +130 -0
  33. package/scripts/lib/graph_rebuild.rb +328 -0
  34. package/scripts/lib/hook_registry.rb +95 -0
  35. package/scripts/lib/insights.rb +86 -0
  36. package/scripts/lib/installer_core.rb +64 -54
  37. package/scripts/lib/link_suggestions.rb +319 -0
  38. package/scripts/lib/links_projection.rb +160 -0
  39. package/scripts/lib/links_section.rb +207 -0
  40. package/scripts/lib/lock.rb +375 -0
  41. package/scripts/lib/power_tools.rb +75 -0
  42. package/scripts/lib/qmd_hook.rb +38 -25
  43. package/scripts/lib/qmd_sync.rb +36 -0
  44. package/scripts/lib/retrieval_gate.rb +211 -0
  45. package/scripts/lib/worktree.rb +384 -0
  46. package/scripts/link-suggest +213 -0
  47. package/scripts/new-intent +138 -29
  48. package/scripts/plastic-lock +164 -0
  49. package/scripts/project-links +287 -0
  50. package/scripts/qmd-sync +50 -3
  51. package/scripts/read-config +4 -0
  52. package/scripts/rebuild-graph +244 -0
  53. package/scripts/spawn-preamble +11 -3
  54. package/skills/auto/SKILL.md +98 -16
  55. package/skills/auto/evals/evals.json +48 -0
  56. package/skills/auto/references/agent-architecture.md +7 -4
  57. package/skills/auto/references/agent-report-contract.md +36 -1
  58. package/skills/brainstorming/SKILL.md +1 -0
  59. package/skills/brainstorming/evals/evals.json +22 -0
  60. package/skills/continuing/SKILL.md +30 -8
  61. package/skills/continuing/evals/evals.json +9 -0
  62. package/skills/creating-intent/SKILL.md +16 -2
  63. package/skills/creating-intent/evals/evals.json +16 -0
  64. package/skills/creating-intent/references/lifecycle.md +9 -4
  65. package/skills/creating-skills/SKILL.md +65 -0
  66. package/skills/creating-skills/evals/evals.json +108 -0
  67. package/skills/creating-skills/references/agents.md +168 -0
  68. package/skills/creating-skills/references/evals.md +41 -0
  69. package/skills/creating-skills/references/hooks.md +248 -0
  70. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  71. package/skills/creating-skills/references/scripts.md +166 -0
  72. package/skills/creating-skills/references/skills.md +165 -0
  73. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  74. package/skills/dashboard/SKILL.md +5 -0
  75. package/skills/dashboard/evals/evals.json +22 -0
  76. package/skills/doctor/SKILL.md +4 -1
  77. package/skills/executing-plan/SKILL.md +4 -4
  78. package/skills/humanizer/SKILL.md +39 -0
  79. package/skills/humanizer/evals/evals.json +70 -0
  80. package/skills/humanizer/references/always-on-snippet.md +9 -0
  81. package/skills/humanizer/references/examples.md +48 -0
  82. package/skills/intent-curator/SKILL.md +7 -1
  83. package/skills/intent-curator/evals/evals.json +22 -0
  84. package/skills/intent-discovery/SKILL.md +46 -0
  85. package/skills/intent-starting/SKILL.md +127 -0
  86. package/skills/intent-starting/evals/evals.json +117 -0
  87. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  88. package/skills/linking-intents/SKILL.md +54 -12
  89. package/skills/linking-intents/evals/evals.json +22 -0
  90. package/skills/linking-intents/references/zettelkasten.md +7 -0
  91. package/skills/lock/SKILL.md +41 -0
  92. package/skills/managing-index/SKILL.md +12 -0
  93. package/skills/managing-index/evals/evals.json +22 -0
  94. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  95. package/skills/releasing/SKILL.md +45 -1
  96. package/skills/research/SKILL.md +8 -0
  97. package/skills/research/evals/evals.json +22 -0
  98. package/templates/config.yml +8 -0
  99. package/templates/outcome.md +3 -0
  100. package/templates/revisions.md +58 -0
  101. package/skills/writing-instructions/SKILL.md +0 -159
  102. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -0,0 +1,207 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "intent_validator"
5
+
6
+ # LinksSection — pure, minimal, style-preserving rewrite of ONLY the `## Links`
7
+ # section of an intent file's content string (intent 72). Mirrors the discipline of
8
+ # FrontmatterWriter: pure (no file IO, no eval, no global/ENV state), and returns
9
+ # the original content UNCHANGED when nothing changed (idempotency).
10
+ #
11
+ # `## Links` is canonically the LAST sanctioned section
12
+ # (IntentValidator::SANCTIONED_SECTIONS = Intent, Context, Outcome, Insights,
13
+ # Links), and real intents put it last. So a file with an existing `## Links` has
14
+ # its section replaced in place (from the heading to the next top-level `## `
15
+ # heading or EOF), and a file WITHOUT a `## Links` gets one appended at end-of-body,
16
+ # separated by exactly one blank line, preserving the body's trailing-newline shape.
17
+ #
18
+ # FENCE AWARENESS (intent 72 corruption fix): a `## Links` heading INSIDE a fenced
19
+ # code block (``` or ~~~, possibly with an info string like ```markdown) is part of
20
+ # an EXAMPLE, not a real section. All section scanning here IGNORES headings inside
21
+ # fences and only ever targets the REAL `## Links` section (outside any fence). The
22
+ # section end is the next `## ` heading that is ALSO outside a fence, so a replace
23
+ # never consumes or unbalances a code fence. If more than one REAL `## Links`
24
+ # heading exists, #rewrite raises AmbiguousLinks rather than guess.
25
+ #
26
+ # Frontmatter is NEVER touched: the leading `---`...`---` block is preserved
27
+ # byte-for-byte and only the body is rebuilt.
28
+ module LinksSection
29
+ module_function
30
+
31
+ HEADING = "## Links"
32
+
33
+ # A fence delimiter: ``` or ~~~ (any length >= 3), optional leading whitespace,
34
+ # optional info string (e.g. ```markdown). Mirrors CommonMark fenced-code rules
35
+ # closely enough for intent bodies.
36
+ FENCE_RE = /\A\s*(`{3,}|~{3,})/
37
+
38
+ # Raised when a body has more than one REAL `## Links` heading outside any fence;
39
+ # the tool must fail loud rather than guess which one to rewrite.
40
+ class AmbiguousLinks < StandardError
41
+ def initialize(count)
42
+ super("found #{count} real `## Links` headings outside code fences; refusing to guess")
43
+ end
44
+ end
45
+
46
+ # PURE. Replace (or insert) the REAL `## Links` section in `content` with
47
+ # `section_text` (the canonical block from LinksProjection.section, which begins
48
+ # with the `## Links` heading line and ends with a single trailing newline).
49
+ # Returns the new content, or the original when nothing changed. Raises
50
+ # AmbiguousLinks when more than one real `## Links` heading exists.
51
+ def rewrite(content, section_text)
52
+ return content unless content.is_a?(String)
53
+
54
+ fm, body = split_frontmatter(content)
55
+ new_body = rewrite_body(body, section_text)
56
+ updated = "#{fm}#{new_body}"
57
+ updated == content ? content : updated
58
+ end
59
+
60
+ # Split content into [frontmatter_with_delimiters, body]. When there is no
61
+ # frontmatter block, the frontmatter part is "" and the whole content is the
62
+ # body. The frontmatter part is preserved byte-for-byte by the caller.
63
+ def split_frontmatter(content)
64
+ return ["", content] unless content.start_with?("---")
65
+
66
+ parts = content.split("---", 3)
67
+ return ["", content] if parts.length < 3
68
+
69
+ ["---#{parts[1]}---", parts[2]]
70
+ end
71
+
72
+ # Rewrite ONLY the REAL `## Links` section within the body text.
73
+ def rewrite_body(body, section_text)
74
+ bounds = links_bounds(body)
75
+ if bounds
76
+ replace_section(body, section_text, bounds)
77
+ else
78
+ insert_section(body, section_text)
79
+ end
80
+ end
81
+
82
+ # PURE. Locate the REAL `## Links` section (fence-aware). Returns
83
+ # [start_index, end_index] line indices into body.lines, where start_index is the
84
+ # `## Links` heading line and end_index is the index of the next out-of-fence
85
+ # `## ` heading (or lines.length at EOF). Returns nil when there is no real
86
+ # `## Links` heading. Raises AmbiguousLinks when more than one exists.
87
+ def links_bounds(body)
88
+ lines = body.to_s.lines
89
+ starts = real_links_heading_indices(lines)
90
+ return nil if starts.empty?
91
+ raise AmbiguousLinks, starts.length if starts.length > 1
92
+
93
+ start = starts.first
94
+ stop = next_out_of_fence_heading(lines, start + 1)
95
+ [start, stop]
96
+ end
97
+
98
+ # PURE. Indices of every `## Links` heading line that is OUTSIDE any code fence.
99
+ # Accepts a body String or an Array of lines.
100
+ def real_links_heading_indices(body_or_lines)
101
+ lines = body_or_lines.is_a?(Array) ? body_or_lines : body_or_lines.to_s.lines
102
+ indices = []
103
+ in_fence = false
104
+ fence_marker = nil
105
+ lines.each_with_index do |line, i|
106
+ if (m = fence_open_close(line, in_fence, fence_marker))
107
+ in_fence = m[:in_fence]
108
+ fence_marker = m[:marker]
109
+ next
110
+ end
111
+ indices << i if !in_fence && line.rstrip == HEADING
112
+ end
113
+ indices
114
+ end
115
+
116
+ # PURE. Index of the first `## ` heading at or after `from` that is OUTSIDE any
117
+ # code fence. Returns lines.length when none (EOF). Fence state is recomputed
118
+ # from the top so nested example fences after the real heading are respected.
119
+ def next_out_of_fence_heading(lines, from)
120
+ in_fence = false
121
+ fence_marker = nil
122
+ lines.each_with_index do |line, i|
123
+ if (m = fence_open_close(line, in_fence, fence_marker))
124
+ in_fence = m[:in_fence]
125
+ fence_marker = m[:marker]
126
+ next
127
+ end
128
+ return i if i >= from && !in_fence && line.start_with?("## ")
129
+ end
130
+ lines.length
131
+ end
132
+
133
+ # PURE. Given the current fence state, decide whether `line` is a fence delimiter
134
+ # and return the new state, or nil when the line is not a fence delimiter.
135
+ # An opening fence records its marker family (` or ~); a closing fence must use a
136
+ # marker of the SAME family and carry no info string.
137
+ def fence_open_close(line, in_fence, fence_marker)
138
+ m = line.match(FENCE_RE)
139
+ return nil unless m
140
+
141
+ marker = m[1]
142
+ family = marker[0] # "`" or "~"
143
+ if in_fence
144
+ # A closing fence uses the same family, length >= the opener, no info string.
145
+ rest = line.sub(FENCE_RE, "").strip
146
+ if family == fence_marker && rest.empty?
147
+ { in_fence: false, marker: nil }
148
+ else
149
+ # A delimiter of the OTHER family (or an info-string line) inside a fence is
150
+ # literal content, not a fence event.
151
+ nil
152
+ end
153
+ else
154
+ { in_fence: true, marker: family }
155
+ end
156
+ end
157
+
158
+ # True iff the body has a REAL (out-of-fence) `## Links` heading. Used by callers
159
+ # to classify regenerate-vs-add without re-deriving fence state.
160
+ def links_heading?(body)
161
+ !real_links_heading_indices(body.to_s.lines).empty?
162
+ end
163
+
164
+ # Replace the REAL `## Links` section (the [start, stop] line bounds) with
165
+ # `section_text`, preserving everything before the heading and after the section
166
+ # byte-for-byte (including any fenced example that lives BEFORE the real section).
167
+ def replace_section(body, section_text, bounds)
168
+ lines = body.lines
169
+ start, stop = bounds
170
+ before = lines[0...start].join
171
+ tail = (lines[stop..] || [])
172
+
173
+ # `section_text` already ends with exactly one newline. When there is trailing
174
+ # content (another section follows), separate the block from it with one blank
175
+ # line; otherwise the section ends the body.
176
+ block = tail.empty? ? section_text : "#{section_text}\n"
177
+ "#{before}#{block}#{tail.join}"
178
+ end
179
+
180
+ # Append a `## Links` section at end-of-body, after the last existing section,
181
+ # separated by exactly one blank line, preserving the body's trailing newline.
182
+ def insert_section(body, section_text)
183
+ trimmed = body.to_s.sub(/\s+\z/, "")
184
+ if trimmed.empty?
185
+ # An empty body (no sections) just becomes the section.
186
+ section_text
187
+ else
188
+ "#{trimmed}\n\n#{section_text}"
189
+ end
190
+ end
191
+
192
+ # PURE. Extract the REAL `## Links` section text (fence-aware), normalized to the
193
+ # canonical block shape the projection emits: heading line + entry lines + a
194
+ # single trailing newline. Returns "" when there is no real section. Shared by
195
+ # the IO shell's audit and the doctor drift check so all three agree on the
196
+ # location. Raises AmbiguousLinks when more than one real heading exists.
197
+ def extract_section(body)
198
+ bounds = links_bounds(body)
199
+ return "" if bounds.nil?
200
+
201
+ start, stop = bounds
202
+ lines = body.to_s.lines
203
+ section = lines[(start + 1)...stop].join.sub(/\n+\z/, "\n")
204
+ section = "" if section.strip.empty?
205
+ "#{HEADING}\n#{section}"
206
+ end
207
+ 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
@@ -0,0 +1,75 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "qmd_sync"
5
+
6
+ # PowerTools — detect-then-degrade harness for Plastic's optional power-tools
7
+ # (intent 66b; demoted to recommendations in intent 108, D8). It owns
8
+ # deterministic detection of each tool and builds a RECOMMENDATION string for
9
+ # whichever tools are present, so the agent is reminded (not obliged) to prefer
10
+ # them: QMD for finding intents, Serena for code navigation.
11
+ #
12
+ # Strictly detect-then-degrade: a tool that is absent contributes nothing, and
13
+ # `mandate` returns nil when no tool is present. Nothing here installs anything.
14
+ #
15
+ # Pure and dependency-injected: every detection runs through an injected callable
16
+ # or keyword probe (PATH scan / `.serena` marker walk), so the whole module is
17
+ # unit-testable with no real binaries, no network, and no global/ENV state.
18
+ module PowerTools
19
+ module_function
20
+
21
+ # True when QMD is present. Reuses QmdSync.detect (PATH probe), injectable.
22
+ def qmd?(detector: QmdSync.method(:detect))
23
+ !!detector.call
24
+ end
25
+
26
+ # True when Serena is present: a `.serena` directory exists in cwd or any
27
+ # ancestor, OR `serena` is resolvable on PATH. Both probes are injectable so
28
+ # tests do not depend on the host having Serena installed.
29
+ def serena?(cwd:, path_probe: method(:which_serena), marker_finder: method(:serena_marker?))
30
+ return true if marker_finder.call(cwd)
31
+ !!path_probe.call
32
+ end
33
+
34
+ # True when `serena` is an executable on PATH. Mirrors QmdSync.which_qmd.
35
+ def which_serena
36
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
37
+ candidate = File.join(dir, "serena")
38
+ File.file?(candidate) && File.executable?(candidate)
39
+ end
40
+ end
41
+
42
+ # Walk up from cwd to the filesystem root, returning true if any level holds a
43
+ # `.serena` directory.
44
+ def serena_marker?(cwd)
45
+ dir = File.expand_path(cwd)
46
+ loop do
47
+ return true if Dir.exist?(File.join(dir, ".serena"))
48
+ parent = File.dirname(dir)
49
+ break if parent == dir
50
+ dir = parent
51
+ end
52
+ false
53
+ end
54
+
55
+ # Recommendation text for whichever tools are present, joined by newlines, or
56
+ # nil when none are. One recommendation line per present tool.
57
+ def mandate(cwd:, qmd_detector: QmdSync.method(:detect), serena_detector: nil)
58
+ lines = []
59
+
60
+ if qmd?(detector: qmd_detector)
61
+ lines << "QMD is available: prefer `qmd search` / `qmd query` over the " \
62
+ "`plastic-*` collections to check for existing or related intents " \
63
+ "before treating work as new."
64
+ end
65
+
66
+ serena_present = serena_detector ? !!serena_detector.call : serena?(cwd: cwd)
67
+ if serena_present
68
+ lines << "Serena is available: prefer its symbolic tools (find_symbol / " \
69
+ "get_symbols_overview / find_referencing_symbols) for code navigation."
70
+ end
71
+
72
+ return nil if lines.empty?
73
+ lines.join("\n")
74
+ end
75
+ end