@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.
- package/PLASTIC.md +293 -7
- package/README.md +10 -0
- package/agents/plastic-brainstorming.md +2 -2
- package/agents/plastic-enforcer.md +17 -2
- package/agents/plastic-executor.md +3 -3
- package/agents/plastic-future-intent-researcher.md +2 -1
- package/agents/plastic-intent-curator.md +15 -7
- package/agents/plastic-intent-discovery.md +37 -0
- package/agents/plastic-planner.md +2 -2
- package/agents/plastic-spec-specialist.md +2 -2
- 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 +150 -41
- package/package.json +1 -1
- package/scripts/agent-report +21 -0
- package/scripts/doctor.rb +404 -5
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +22 -4
- package/scripts/hook-code-gate +11 -6
- package/scripts/hook-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/lib/agent_models.rb +43 -0
- package/scripts/lib/bridge.rb +625 -47
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +64 -54
- package/scripts/lib/link_suggestions.rb +319 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/lock.rb +375 -0
- package/scripts/lib/power_tools.rb +75 -0
- package/scripts/lib/qmd_hook.rb +38 -25
- package/scripts/lib/qmd_sync.rb +36 -0
- package/scripts/lib/retrieval_gate.rb +211 -0
- package/scripts/lib/worktree.rb +384 -0
- package/scripts/link-suggest +213 -0
- package/scripts/new-intent +138 -29
- package/scripts/plastic-lock +164 -0
- package/scripts/project-links +287 -0
- package/scripts/qmd-sync +50 -3
- package/scripts/read-config +4 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +11 -3
- package/skills/auto/SKILL.md +98 -16
- package/skills/auto/evals/evals.json +48 -0
- package/skills/auto/references/agent-architecture.md +7 -4
- package/skills/auto/references/agent-report-contract.md +36 -1
- package/skills/brainstorming/SKILL.md +1 -0
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/continuing/SKILL.md +30 -8
- package/skills/continuing/evals/evals.json +9 -0
- package/skills/creating-intent/SKILL.md +16 -2
- package/skills/creating-intent/evals/evals.json +16 -0
- package/skills/creating-intent/references/lifecycle.md +9 -4
- 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 +5 -0
- package/skills/dashboard/evals/evals.json +22 -0
- package/skills/doctor/SKILL.md +4 -1
- 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/intent-curator/SKILL.md +7 -1
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/intent-discovery/SKILL.md +46 -0
- package/skills/intent-starting/SKILL.md +127 -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 +54 -12
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +7 -0
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +12 -0
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
- package/skills/releasing/SKILL.md +45 -1
- package/skills/research/SKILL.md +8 -0
- package/skills/research/evals/evals.json +22 -0
- package/templates/config.yml +8 -0
- package/templates/outcome.md +3 -0
- package/templates/revisions.md +58 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- 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
|