@zalom/plastic 1.0.0-beta.2 → 1.0.0-beta.21
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 +131 -7
- package/agents/plastic-brainstorming.md +9 -1
- package/agents/plastic-enforcer.md +1 -1
- package/agents/plastic-executor.md +11 -1
- package/agents/plastic-intent-curator.md +7 -5
- package/agents/plastic-planner.md +11 -1
- package/agents/plastic-spec-specialist.md +9 -1
- package/hooks/hooks.json +20 -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 +163 -0
- package/scripts/doctor.rb +172 -0
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +2 -2
- package/scripts/hook-code-gate +11 -6
- package/scripts/hook-create-gate +2 -2
- package/scripts/hook-gate-check +14 -23
- package/scripts/hook-retrieval-gate +136 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/hook-session-start +1 -1
- package/scripts/insight-append +51 -0
- package/scripts/lib/bridge.rb +374 -34
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +23 -0
- package/scripts/lib/link_suggestions.rb +322 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/power_tools.rb +76 -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 +409 -0
- package/scripts/link-suggest +211 -0
- package/scripts/new-intent +138 -29
- package/scripts/project-links +287 -0
- package/scripts/qmd-sync +50 -3
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +26 -1
- package/skills/auto/SKILL.md +58 -11
- package/skills/auto/evals/evals.json +48 -0
- package/skills/auto/references/agent-architecture.md +27 -4
- package/skills/auto/references/agent-report-contract.md +121 -0
- 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/executing-plan/SKILL.md +2 -2
- 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 +6 -1
- package/skills/intent-curator/evals/evals.json +22 -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/managing-index/SKILL.md +8 -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 +32 -0
- package/skills/research/SKILL.md +8 -0
- package/skills/research/evals/evals.json +22 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- package/skills/writing-instructions/references/agentskills-spec.md +0 -135
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# insight-append - the blessed write path for an intent's `## Insights` section
|
|
6
|
+
# (intent 82). A thin CLI wrapper over Insights.append_insight: it formats the
|
|
7
|
+
# `{utc-iso8601} · {stage} · {author}` prefix, validates it, and appends one
|
|
8
|
+
# entry at the bottom of the section (creating the section or file if absent).
|
|
9
|
+
#
|
|
10
|
+
# Sibling to scripts/spawn-preamble and scripts/agent-report. The library's
|
|
11
|
+
# `now:` seam is the test seam; the CLI uses the default Time.now, which is fine
|
|
12
|
+
# because determinism is covered at the library level (test/insights_test.rb).
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# insight-append <intent_dir> <text> --stage S --author A
|
|
16
|
+
#
|
|
17
|
+
# Exit codes: 0 (entry appended), 2 (usage).
|
|
18
|
+
|
|
19
|
+
require_relative "lib/insights"
|
|
20
|
+
|
|
21
|
+
def parse_args(argv)
|
|
22
|
+
stage = nil
|
|
23
|
+
author = nil
|
|
24
|
+
positional = []
|
|
25
|
+
i = 0
|
|
26
|
+
while i < argv.length
|
|
27
|
+
case argv[i]
|
|
28
|
+
when "--stage"
|
|
29
|
+
stage = argv[i + 1]
|
|
30
|
+
i += 2
|
|
31
|
+
when "--author"
|
|
32
|
+
author = argv[i + 1]
|
|
33
|
+
i += 2
|
|
34
|
+
else
|
|
35
|
+
positional << argv[i]
|
|
36
|
+
i += 1
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
[positional[0], positional[1], stage, author]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
intent_dir, text, stage, author = parse_args(ARGV)
|
|
43
|
+
|
|
44
|
+
if [intent_dir, text, stage, author].any? { |v| v.nil? || v.to_s.empty? }
|
|
45
|
+
warn "usage: insight-append <intent_dir> <text> --stage S --author A"
|
|
46
|
+
exit 2
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
entry = Insights.append_insight(File.expand_path(intent_dir), text,
|
|
50
|
+
stage: stage, author: author)
|
|
51
|
+
puts "appended: #{entry}"
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -6,6 +6,8 @@ require "yaml"
|
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "tempfile"
|
|
8
8
|
require "digest"
|
|
9
|
+
require "socket"
|
|
10
|
+
require_relative "worktree"
|
|
9
11
|
|
|
10
12
|
module Bridge
|
|
11
13
|
STAGES = %w[what why how exec done].freeze
|
|
@@ -18,13 +20,15 @@ module Bridge
|
|
|
18
20
|
# (<id>--<slug>.md) is never sentineled; it is born complete.
|
|
19
21
|
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
20
22
|
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
|
|
23
|
+
# Bridge cleanup is terminal-state, not age-based (intent 80). A bridge is dead
|
|
24
|
+
# weight ONLY once its intent is terminal (no longer in its store's INDEX.md
|
|
25
|
+
# `## Active` block); such bridges are purged. An Active intent's bridge is kept
|
|
26
|
+
# unconditionally, because while the intent is live the bridge is still load-
|
|
27
|
+
# bearing: it is the continuation signal (a parked or interrupted run resumes
|
|
28
|
+
# from it) and the anti-collision lock (it keys the per-session statusline so
|
|
29
|
+
# parallel sessions do not overwrite each other). An age window was the wrong
|
|
30
|
+
# axis: it left dead bridges resident for ~2 days AND could reap bridges of
|
|
31
|
+
# interrupted-but-still-active intents, which are exactly the ones to preserve.
|
|
28
32
|
|
|
29
33
|
def self.intent_file(intent_dir)
|
|
30
34
|
dir_name = File.basename(intent_dir)
|
|
@@ -57,12 +61,19 @@ module Bridge
|
|
|
57
61
|
"auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
|
|
58
62
|
end
|
|
59
63
|
|
|
60
|
-
# Resolve a bridge session: first non-empty of explicit
|
|
61
|
-
# then a derived key. Never returns nil/empty.
|
|
64
|
+
# Resolve a bridge session: first non-empty of explicit (the stdin session_id),
|
|
65
|
+
# CLAUDE_CODE_SESSION_ID, then a derived key. Never returns nil/empty.
|
|
66
|
+
# Whitespace-only counts as empty.
|
|
67
|
+
#
|
|
68
|
+
# The CLAUDE_CODE_SESSION_ID fallback (intent 79) carries the bg/headless real
|
|
69
|
+
# session id (Claude Code passes session_id on stdin, not via an env var; the
|
|
70
|
+
# headless id lives in CLAUDE_CODE_SESSION_ID). Keying by the real id (instead of
|
|
71
|
+
# a derived hash) lets the statusline, which receives that same id on stdin, find
|
|
72
|
+
# the bridge by direct filename lookup.
|
|
62
73
|
def self.resolve_session(explicit, intent_id:, store:)
|
|
63
74
|
return explicit.to_s.strip unless blank?(explicit)
|
|
64
|
-
|
|
65
|
-
return
|
|
75
|
+
code_env = ENV["CLAUDE_CODE_SESSION_ID"]
|
|
76
|
+
return code_env.to_s.strip unless blank?(code_env)
|
|
66
77
|
derive_key(store, intent_id)
|
|
67
78
|
end
|
|
68
79
|
|
|
@@ -103,6 +114,22 @@ module Bridge
|
|
|
103
114
|
end
|
|
104
115
|
return nil if parsed.empty?
|
|
105
116
|
|
|
117
|
+
has_session = !blank?(session)
|
|
118
|
+
|
|
119
|
+
# Strict per-session ownership (intent 90): when the caller HAS a session, a foreign
|
|
120
|
+
# session's bridge is NEVER a valid resolution. Own-session and the derived-key case both
|
|
121
|
+
# reduce to candidate["session"] == session (the derived key IS the session that armed the
|
|
122
|
+
# bridge). A caller that owns no bridge resolves to nil, so its gates fail open instead of
|
|
123
|
+
# inheriting another session's armed intent.
|
|
124
|
+
#
|
|
125
|
+
# When the caller has NO session (truly headless, intent 52), keep the legacy degraded
|
|
126
|
+
# selection below so a single armed derived-key bridge is still discoverable - the hook
|
|
127
|
+
# cannot know the session there, and a lone armed intent must still gate.
|
|
128
|
+
if has_session
|
|
129
|
+
parsed = parsed.select { |c| c[:data]["session"].to_s == session.to_s }
|
|
130
|
+
return nil if parsed.empty?
|
|
131
|
+
end
|
|
132
|
+
|
|
106
133
|
auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
|
|
107
134
|
pool = auto.empty? ? parsed : auto
|
|
108
135
|
|
|
@@ -116,31 +143,71 @@ module Bridge
|
|
|
116
143
|
cwd_abs.start_with?("#{store_abs}/") ||
|
|
117
144
|
store_abs.start_with?("#{cwd_abs}/")
|
|
118
145
|
end
|
|
119
|
-
|
|
146
|
+
# Hard cwd filter when the caller has a session (intent 90): a non-matching store
|
|
147
|
+
# excludes the candidate outright. Without a session, keep the best-effort revert
|
|
148
|
+
# (intent 52) so a lone armed bridge is still found when cwd does not overlap its store.
|
|
149
|
+
pool = has_session ? matching : (matching.empty? ? pool : matching)
|
|
120
150
|
end
|
|
121
151
|
|
|
122
152
|
pool.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
123
153
|
end
|
|
124
154
|
|
|
125
|
-
# ---
|
|
126
|
-
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
155
|
+
# --- Terminal-state bridge purge (intent 80) -------------------------------
|
|
156
|
+
|
|
157
|
+
# True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
|
|
158
|
+
# the PARENT of the store/ dir the bridge records, so we resolve it from the
|
|
159
|
+
# bridge's intent.store. Non-raising: any failure (missing/unreadable INDEX,
|
|
160
|
+
# bad arg) returns false, which means "not active" so the caller treats the
|
|
161
|
+
# bridge as purgeable. `index_active_ids` is a pure-data test seam: when an
|
|
162
|
+
# Array of id strings is supplied, membership is checked against it directly
|
|
163
|
+
# with no file read.
|
|
164
|
+
def self.intent_active?(intent_id, store:, index_active_ids: nil)
|
|
165
|
+
target = intent_id.to_s
|
|
166
|
+
return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
|
|
167
|
+
|
|
168
|
+
index = File.join(File.dirname(store.to_s), "INDEX.md")
|
|
169
|
+
return false unless File.exist?(index)
|
|
170
|
+
|
|
171
|
+
in_active = false
|
|
172
|
+
File.foreach(index) do |line|
|
|
173
|
+
stripped = line.chomp
|
|
174
|
+
if stripped == "## Active"
|
|
175
|
+
in_active = true
|
|
176
|
+
next
|
|
177
|
+
end
|
|
178
|
+
next unless in_active
|
|
179
|
+
break if stripped.start_with?("## ") # next section ends the Active block
|
|
180
|
+
m = stripped.match(/^- \[(\S+) +—/)
|
|
181
|
+
return true if m && m[1] == target
|
|
182
|
+
end
|
|
183
|
+
false
|
|
184
|
+
rescue StandardError
|
|
185
|
+
false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Remove tmp/plastic-*.json bridge files whose intent is terminal, so
|
|
189
|
+
# discover_bridge's per-fire scan stays bounded. Best-effort and non-raising:
|
|
190
|
+
# returns the array of removed paths. A bridge is purged when it cannot be
|
|
191
|
+
# parsed, has no intent.id, has no intent.store, or its intent is not Active in
|
|
192
|
+
# its store's INDEX.md. An Active intent's bridge is kept (continuation signal +
|
|
193
|
+
# anti-collision lock), and the current session's own bridge is never purged
|
|
133
194
|
# (preserves the disarm_auto contract that it stays readable). Wired into
|
|
134
195
|
# arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
|
|
135
|
-
# clean.
|
|
136
|
-
def self.
|
|
137
|
-
tmp: tmp_dir)
|
|
196
|
+
# clean at deterministic work boundaries.
|
|
197
|
+
def self.purge_done_bridges(session:, tmp: tmp_dir)
|
|
138
198
|
current = path(session, tmp: tmp)
|
|
139
199
|
removed = []
|
|
140
200
|
Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
|
|
141
201
|
next if f == current
|
|
142
202
|
begin
|
|
143
|
-
|
|
203
|
+
data = JSON.parse(File.read(f)) rescue nil
|
|
204
|
+
keep = false
|
|
205
|
+
if data
|
|
206
|
+
id = data.dig("intent", "id")
|
|
207
|
+
store = data.dig("intent", "store")
|
|
208
|
+
keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
|
|
209
|
+
end
|
|
210
|
+
next if keep
|
|
144
211
|
File.delete(f)
|
|
145
212
|
removed << f
|
|
146
213
|
rescue Errno::ENOENT
|
|
@@ -152,7 +219,7 @@ module Bridge
|
|
|
152
219
|
end
|
|
153
220
|
removed
|
|
154
221
|
rescue => e
|
|
155
|
-
$stderr.puts "plastic:
|
|
222
|
+
$stderr.puts "plastic: purge_done_bridges failed: #{e.message}"
|
|
156
223
|
removed || []
|
|
157
224
|
end
|
|
158
225
|
|
|
@@ -224,6 +291,51 @@ module Bridge
|
|
|
224
291
|
end
|
|
225
292
|
end
|
|
226
293
|
|
|
294
|
+
# --- Gate-boundary narration (intent 84, Lever 1) -------------------------
|
|
295
|
+
#
|
|
296
|
+
# ONE concise sentence that states what happened AND what's next, preserving
|
|
297
|
+
# the `Next: ...` hint the agent consumes. Pure and side-effect-free so the
|
|
298
|
+
# hook stays a thin caller and the formatter is unit-testable in isolation.
|
|
299
|
+
# No "Stage transition: X -> Y" prose, no arrow; a colon/parentheses carry the
|
|
300
|
+
# stage word. Returns a single line (no embedded newlines).
|
|
301
|
+
STAGE_LABELS = {
|
|
302
|
+
"what" => "What", "why" => "Why", "how" => "How",
|
|
303
|
+
"exec" => "Exec", "done" => "Done"
|
|
304
|
+
}.freeze
|
|
305
|
+
|
|
306
|
+
NEXT_HINTS = {
|
|
307
|
+
"why" => "write spec.md",
|
|
308
|
+
"how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
|
|
309
|
+
"exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
|
|
310
|
+
"done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
|
|
311
|
+
}.freeze
|
|
312
|
+
|
|
313
|
+
def self.stage_label(stage)
|
|
314
|
+
STAGE_LABELS[stage] || stage.to_s
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Build the gate-hook `additionalContext` sentence.
|
|
318
|
+
# transition: "PLASTIC: How reached (plan.md written). Next: <hint>"
|
|
319
|
+
# same-stage write: "PLASTIC: plan.md written (How). Next: <hint>"
|
|
320
|
+
# `new_missing` (missing files for the new stage) takes precedence over the
|
|
321
|
+
# stage hint, exactly as before, so the `Next:` content is unchanged.
|
|
322
|
+
def self.gate_narration(old_stage:, new_stage:, basename:, new_missing:, next_hints: NEXT_HINTS)
|
|
323
|
+
head = if old_stage != new_stage
|
|
324
|
+
"PLASTIC: #{stage_label(new_stage)} reached (#{basename} written)."
|
|
325
|
+
else
|
|
326
|
+
"PLASTIC: #{basename} written (#{stage_label(new_stage)})."
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
nxt =
|
|
330
|
+
if Array(new_missing).any?
|
|
331
|
+
"Next: #{Array(new_missing).join(", ")}"
|
|
332
|
+
elsif next_hints[new_stage]
|
|
333
|
+
"Next: #{next_hints[new_stage]}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
nxt ? "#{head} #{nxt}" : head
|
|
337
|
+
end
|
|
338
|
+
|
|
227
339
|
# --- Cycle-step savepoint ledger (intent 34) ------------------------------
|
|
228
340
|
#
|
|
229
341
|
# savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
|
|
@@ -256,8 +368,31 @@ module Bridge
|
|
|
256
368
|
end.compact
|
|
257
369
|
end
|
|
258
370
|
|
|
259
|
-
#
|
|
260
|
-
#
|
|
371
|
+
# (stage, milestone) pairs already recorded in the ledger. The pair (not the
|
|
372
|
+
# milestone text alone) is the dedup key, because state-from-ledger lines like
|
|
373
|
+
# `Why started` and `How started` share the milestone text "started" while
|
|
374
|
+
# being distinct events (intent 81).
|
|
375
|
+
def self.savepoint_recorded_pairs(intent_dir)
|
|
376
|
+
f = File.join(intent_dir, SAVEPOINT_FILE)
|
|
377
|
+
return [] unless File.exist?(f)
|
|
378
|
+
File.read(f).each_line.filter_map do |line|
|
|
379
|
+
parts = line.strip.split(/\s{2,}/)
|
|
380
|
+
parts.length >= 3 ? [parts[1], parts[2]] : nil
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Append one ledger line for (stage, milestone) unless that pair is already
|
|
385
|
+
# recorded. The single append primitive shared by every line class. Returns
|
|
386
|
+
# true when a line was written, false when it was a no-op.
|
|
387
|
+
def self.append_savepoint_line(intent_dir, stage, milestone, now)
|
|
388
|
+
return false if savepoint_recorded_pairs(intent_dir).include?([stage, milestone])
|
|
389
|
+
line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
|
|
390
|
+
File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
|
|
391
|
+
true
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Append the artifact-landing milestone for file_path if (and only if) it is a
|
|
395
|
+
# milestone not already recorded. Returns true when a line was written.
|
|
261
396
|
def self.append_savepoint(intent_dir, file_path, now: Time.now)
|
|
262
397
|
basename = File.basename(file_path)
|
|
263
398
|
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
@@ -265,11 +400,62 @@ module Bridge
|
|
|
265
400
|
# A sentinel-marked lifecycle file logs NO milestone (the stage is not real
|
|
266
401
|
# yet). The intent file is never sentineled, so it still logs its What line.
|
|
267
402
|
return false unless stage_file_present?(File.join(intent_dir, basename))
|
|
268
|
-
return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
|
|
269
403
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
404
|
+
append_savepoint_line(intent_dir, stage, milestone, now)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# --- State-from-ledger: pre-stage, exec-start, and terminal lines (81) ------
|
|
408
|
+
#
|
|
409
|
+
# On top of intent 34's artifact-landing milestones, the ledger gains:
|
|
410
|
+
# - `started` lines, one per cycle stage entry (pre-stage, written by the
|
|
411
|
+
# PreToolUse savepoint hook the moment a stage's artifact is first written);
|
|
412
|
+
# - an `Exec started` companion emitted when checklist.md lands;
|
|
413
|
+
# - a terminal `Done delivered|abandoned` line written by the completion path.
|
|
414
|
+
# None of these are derivable from files on disk, so they are deliberately NOT
|
|
415
|
+
# part of savepoint_milestone and are never regenerated by rebuild_savepoint:
|
|
416
|
+
# a rebuilt ledger is the file-landing skeleton, the live ledger is richer.
|
|
417
|
+
|
|
418
|
+
# Map a written filename to the [stage, "started"] pre-stage milestone, or nil.
|
|
419
|
+
# spec.md => entering Why, plan.md => entering How. checklist.md/outcome.md do
|
|
420
|
+
# not open a stage (checklist's Exec-start is the append_exec_started companion).
|
|
421
|
+
def self.savepoint_started_milestone(basename)
|
|
422
|
+
case basename
|
|
423
|
+
when "spec.md" then ["Why", "started"]
|
|
424
|
+
when "plan.md" then ["How", "started"]
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Append the pre-stage `started` line for file_path, iff: the basename opens a
|
|
429
|
+
# stage, the stage is genuinely starting (its artifact is not yet a REAL file,
|
|
430
|
+
# so a sentinel placeholder still counts as "starting"), and the pair is not
|
|
431
|
+
# already recorded. Returns true when a line was written.
|
|
432
|
+
def self.append_started_savepoint(intent_dir, file_path, now: Time.now)
|
|
433
|
+
basename = File.basename(file_path)
|
|
434
|
+
stage, milestone = savepoint_started_milestone(basename)
|
|
435
|
+
return false unless milestone
|
|
436
|
+
return false if stage_file_present?(File.join(intent_dir, basename))
|
|
437
|
+
|
|
438
|
+
append_savepoint_line(intent_dir, stage, milestone, now)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Append the `Exec started` companion (emitted when checklist.md lands, in the
|
|
442
|
+
# same PostToolUse event as the `How checklist.md created` line). Idempotent.
|
|
443
|
+
def self.append_exec_started(intent_dir, now: Time.now)
|
|
444
|
+
append_savepoint_line(intent_dir, "Exec", "started", now)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
|
|
448
|
+
|
|
449
|
+
# Append the terminal bookend `Done delivered|abandoned`, written by the
|
|
450
|
+
# completion path when an intent transfers to INDEX's Completed/Abandoned
|
|
451
|
+
# section. Idempotent per disposition. Raises on an unknown disposition.
|
|
452
|
+
def self.append_terminal_savepoint(intent_dir, disposition, now: Time.now)
|
|
453
|
+
unless TERMINAL_DISPOSITIONS.include?(disposition)
|
|
454
|
+
raise ArgumentError,
|
|
455
|
+
"disposition must be one of #{TERMINAL_DISPOSITIONS.join(', ')}, got #{disposition.inspect}"
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
append_savepoint_line(intent_dir, "Done", disposition, now)
|
|
273
459
|
end
|
|
274
460
|
|
|
275
461
|
# Reconstruct the ledger from files on disk (timestamps from mtimes), in
|
|
@@ -320,6 +506,23 @@ module Bridge
|
|
|
320
506
|
"context_pct" => 0,
|
|
321
507
|
"warning_at" => 80,
|
|
322
508
|
"critical_at" => 90
|
|
509
|
+
},
|
|
510
|
+
# Worktree isolation block (intent 73c). Born unprovisioned; arm_auto calls
|
|
511
|
+
# Worktree.provision to fill it. code/store are abs paths or null.
|
|
512
|
+
"worktree" => {
|
|
513
|
+
"code" => nil,
|
|
514
|
+
"code_branch" => nil,
|
|
515
|
+
"store" => nil,
|
|
516
|
+
"store_branch" => nil,
|
|
517
|
+
"provisioned" => false
|
|
518
|
+
},
|
|
519
|
+
# Delivery lock block (intent 73c). The bridge IS the lock; the owner is
|
|
520
|
+
# whoever armed it. Born unowned; arm_auto stamps owner_session/pid/etc.
|
|
521
|
+
"lock" => {
|
|
522
|
+
"owner_session" => nil,
|
|
523
|
+
"pid" => nil,
|
|
524
|
+
"acquired_at" => nil,
|
|
525
|
+
"host" => nil
|
|
323
526
|
}
|
|
324
527
|
}
|
|
325
528
|
|
|
@@ -386,13 +589,32 @@ module Bridge
|
|
|
386
589
|
# (mid-session intent creation). Re-derives intent state, then sets build.auto.
|
|
387
590
|
def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
|
|
388
591
|
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
389
|
-
if blank?(session) && blank?(ENV["
|
|
592
|
+
if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
|
|
390
593
|
$stderr.puts "plastic: no session id available; arming auto with derived bridge key #{key}"
|
|
391
594
|
end
|
|
392
595
|
data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
|
|
393
596
|
data["build"]["auto"] = true
|
|
597
|
+
|
|
598
|
+
# Acquire the delivery lock: this armed bridge is now the single owner of the
|
|
599
|
+
# intent's delivery (intent 73c). Stamp owner + pid liveness fields.
|
|
600
|
+
data["lock"] = {
|
|
601
|
+
"owner_session" => key,
|
|
602
|
+
"pid" => Process.pid,
|
|
603
|
+
"acquired_at" => Time.now.utc.iso8601,
|
|
604
|
+
"host" => (Socket.gethostname rescue nil)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
# Provision the per-intent worktrees (mandatory code worktree for project
|
|
608
|
+
# intents; fail-open for non-git / global-only). Never let a provision error
|
|
609
|
+
# break arming: the lock and auto flag still matter.
|
|
610
|
+
begin
|
|
611
|
+
Worktree.provision(data)
|
|
612
|
+
rescue => e
|
|
613
|
+
$stderr.puts "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
|
|
614
|
+
end
|
|
615
|
+
|
|
394
616
|
write(key, data)
|
|
395
|
-
|
|
617
|
+
purge_done_bridges(session: key)
|
|
396
618
|
data
|
|
397
619
|
end
|
|
398
620
|
|
|
@@ -402,8 +624,18 @@ module Bridge
|
|
|
402
624
|
return nil unless data
|
|
403
625
|
data["build"] ||= {}
|
|
404
626
|
data["build"]["auto"] = false
|
|
627
|
+
|
|
628
|
+
# Release the worktrees the matching arm provisioned (intent 73c). Non-fatal:
|
|
629
|
+
# a release error must not block disarming. CLEANUP (73c3) refines the
|
|
630
|
+
# merge-vs-remove policy on the completion/release path.
|
|
631
|
+
begin
|
|
632
|
+
Worktree.release(data)
|
|
633
|
+
rescue => e
|
|
634
|
+
$stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
|
|
635
|
+
end
|
|
636
|
+
|
|
405
637
|
write(session, data)
|
|
406
|
-
|
|
638
|
+
purge_done_bridges(session: session)
|
|
407
639
|
data
|
|
408
640
|
end
|
|
409
641
|
|
|
@@ -441,6 +673,114 @@ module Bridge
|
|
|
441
673
|
"(blocked edit: #{file_abs})"
|
|
442
674
|
end
|
|
443
675
|
|
|
676
|
+
# --- Worktree isolation gate (intent 73c2) ---
|
|
677
|
+
|
|
678
|
+
# Returns a reason String to BLOCK, or nil to ALLOW. Two independent rules,
|
|
679
|
+
# both fail-open by construction:
|
|
680
|
+
#
|
|
681
|
+
# 1. When the bridge has a provisioned code worktree, a code edit (a target
|
|
682
|
+
# outside ~/.plastic and outside this intent's store dir) MUST land inside
|
|
683
|
+
# worktree["code"]; otherwise BLOCK and name the expected worktree path.
|
|
684
|
+
# 2. When the target lives inside ANOTHER intent's store dir whose bridge lock
|
|
685
|
+
# is held by a LIVE non-owner session, BLOCK (non-owner edit to an active
|
|
686
|
+
# intent).
|
|
687
|
+
#
|
|
688
|
+
# Fails open (returns nil) when provisioned is false (non-git / global-only) or
|
|
689
|
+
# the bridge carries no worktree/lock blocks. Logs nothing on the allow path.
|
|
690
|
+
def self.worktree_gate_decision(bridge_data, file_path, home: Dir.home, current_session: nil)
|
|
691
|
+
return nil unless bridge_data.is_a?(Hash)
|
|
692
|
+
return nil if blank?(file_path)
|
|
693
|
+
|
|
694
|
+
file_abs = File.expand_path(file_path.to_s)
|
|
695
|
+
plastic_home = File.expand_path(File.join(home, ".plastic"))
|
|
696
|
+
under_plastic = file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
|
|
697
|
+
|
|
698
|
+
intent_info = bridge_data["intent"] || {}
|
|
699
|
+
store = intent_info["store"]
|
|
700
|
+
dir = intent_info["dir"]
|
|
701
|
+
intent_dir_abs = (store && dir) ? File.expand_path("#{store}/#{dir}") : nil
|
|
702
|
+
under_own_intent = intent_dir_abs &&
|
|
703
|
+
(file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/"))
|
|
704
|
+
|
|
705
|
+
# Rule 1: provisioned code worktree confines project-code edits.
|
|
706
|
+
worktree = bridge_data["worktree"] || {}
|
|
707
|
+
if worktree["provisioned"] == true
|
|
708
|
+
code = worktree["code"].to_s
|
|
709
|
+
# Project code = outside ~/.plastic and outside this intent's store dir.
|
|
710
|
+
is_project_code = !under_plastic && !under_own_intent
|
|
711
|
+
if is_project_code && !blank?(code)
|
|
712
|
+
code_abs = File.expand_path(code)
|
|
713
|
+
inside_code = file_abs == code_abs || file_abs.start_with?("#{code_abs}/")
|
|
714
|
+
unless inside_code
|
|
715
|
+
id = intent_info["id"]
|
|
716
|
+
return "intent #{id} is isolated to its worktree — edit project code " \
|
|
717
|
+
"inside #{code_abs}, not the shared checkout. (blocked edit: #{file_abs})"
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# Rule 2: do not edit another intent's locked, live store dir.
|
|
723
|
+
if under_plastic
|
|
724
|
+
reason = non_owner_store_edit_reason(file_abs, plastic_home, intent_dir_abs,
|
|
725
|
+
home: home, current_session: current_session,
|
|
726
|
+
own_session: bridge_data["session"])
|
|
727
|
+
return reason if reason
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
nil
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# Helper for rule 2. A store dir is `<plastic_home>/store/{id}--{slug}` (global)
|
|
734
|
+
# or `<plastic_home>/projects/{slug}/store/{id}--{slug}` (project). When the
|
|
735
|
+
# edit target sits inside such a dir that is NOT this intent's own dir, and a
|
|
736
|
+
# live non-owner session holds that intent's bridge lock, BLOCK.
|
|
737
|
+
def self.non_owner_store_edit_reason(file_abs, plastic_home, own_intent_dir_abs,
|
|
738
|
+
home:, current_session:, own_session:)
|
|
739
|
+
return nil if own_intent_dir_abs &&
|
|
740
|
+
(file_abs == own_intent_dir_abs || file_abs.start_with?("#{own_intent_dir_abs}/"))
|
|
741
|
+
|
|
742
|
+
parsed = parse_store_target(file_abs, plastic_home)
|
|
743
|
+
return nil unless parsed
|
|
744
|
+
|
|
745
|
+
session = blank?(current_session) ? own_session : current_session
|
|
746
|
+
held = Worktree.lock_held_by_other?(
|
|
747
|
+
intent_id: parsed[:id], store: parsed[:store],
|
|
748
|
+
current_session: session, home: home,
|
|
749
|
+
)
|
|
750
|
+
return nil unless held
|
|
751
|
+
|
|
752
|
+
"intent #{parsed[:id]} is owned by another live session — its delivery lock " \
|
|
753
|
+
"is held elsewhere. Back off; do not edit #{file_abs}."
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Resolve an edit target inside a store to {id:, store:} for the intent dir it
|
|
757
|
+
# belongs to, or nil if the path is not inside an `{id}--{slug}` intent dir.
|
|
758
|
+
def self.parse_store_target(file_abs, plastic_home)
|
|
759
|
+
rels = []
|
|
760
|
+
global_store = File.join(plastic_home, "store")
|
|
761
|
+
if file_abs.start_with?("#{global_store}/")
|
|
762
|
+
rels << [file_abs[(global_store.length + 1)..], global_store]
|
|
763
|
+
end
|
|
764
|
+
projects = File.join(plastic_home, "projects")
|
|
765
|
+
if file_abs.start_with?("#{projects}/")
|
|
766
|
+
tail = file_abs[(projects.length + 1)..].to_s
|
|
767
|
+
parts = tail.split(File::SEPARATOR)
|
|
768
|
+
if parts.length >= 2 && parts[1] == "store"
|
|
769
|
+
pstore = File.join(projects, parts[0], "store")
|
|
770
|
+
rels << [file_abs[(pstore.length + 1)..], pstore]
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
rels.each do |rel, store_dir|
|
|
775
|
+
next if blank?(rel)
|
|
776
|
+
first = rel.split(File::SEPARATOR).first.to_s
|
|
777
|
+
idx = first.index("--")
|
|
778
|
+
next unless idx && idx > 0
|
|
779
|
+
return { id: first[0...idx], store: store_dir }
|
|
780
|
+
end
|
|
781
|
+
nil
|
|
782
|
+
end
|
|
783
|
+
|
|
444
784
|
# --- Bash-edit gate (intent 27a) ---
|
|
445
785
|
|
|
446
786
|
# Extract the set of file paths a Bash command writes to. Conservative by
|