@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.10
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 +170 -469
- package/README.md +95 -58
- package/agents/plastic-brainstorming.md +45 -0
- package/agents/plastic-enforcer.md +36 -0
- package/agents/plastic-executor.md +47 -0
- package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
- package/agents/{intent-curator.md → plastic-intent-curator.md} +8 -6
- package/agents/plastic-planner.md +47 -0
- package/agents/plastic-spec-specialist.md +45 -0
- package/bin/plastic.js +57 -0
- package/bin/test +28 -0
- package/deprecations.yml +1 -10
- package/hooks/auto-arm +5 -0
- package/hooks/bash-gate +3 -0
- package/hooks/check-update +12 -8
- package/hooks/code-gate +12 -0
- package/hooks/create-gate +3 -0
- package/hooks/gate-check +3 -1
- package/hooks/hooks.json +52 -0
- package/hooks/qmd-search +8 -0
- package/hooks/statusline +150 -41
- package/package.json +2 -2
- package/scripts/agent-report +142 -0
- package/scripts/dashboard.rb +687 -0
- package/scripts/doctor.rb +1219 -621
- package/scripts/hook-auto-arm +51 -0
- package/scripts/hook-bash-gate +41 -0
- package/scripts/hook-code-gate +27 -0
- package/scripts/hook-continue +15 -114
- package/scripts/hook-create-gate +59 -0
- package/scripts/hook-gate-check +47 -32
- package/scripts/hook-qmd-search +44 -0
- package/scripts/hook-session-start +106 -38
- package/scripts/install.rb +91 -529
- package/scripts/lib/boot_banner.rb +28 -0
- package/scripts/lib/bridge.rb +452 -19
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/installer_core.rb +812 -0
- package/scripts/lib/intent_validator.rb +235 -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 +57 -0
- package/scripts/lib/qmd_sync.rb +230 -0
- package/scripts/lib/store_provisioning.rb +100 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +327 -0
- package/scripts/project-links +287 -0
- package/scripts/provision-project-store +53 -0
- package/scripts/qmd-sync +139 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/select-update-target +93 -0
- package/scripts/spawn-preamble +138 -0
- package/scripts/uninstall.rb +53 -0
- package/scripts/update.rb +164 -0
- package/scripts/validate-intent +54 -0
- package/scripts/versions.rb +141 -0
- package/skills/_active-intent-gate.md +1 -1
- package/skills/add-project-store/SKILL.md +54 -0
- package/skills/auto/SKILL.md +87 -7
- package/skills/auto/evals/evals.json +255 -0
- package/skills/auto/references/agent-architecture.md +155 -0
- package/skills/auto/references/agent-report-contract.md +86 -0
- package/skills/brainstorming/SKILL.md +10 -9
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/brainstorming-grill-me/SKILL.md +6 -6
- package/skills/continuing/SKILL.md +99 -82
- package/skills/continuing/evals/evals.json +145 -0
- package/skills/continuing/references/context-management.md +32 -0
- package/skills/creating-intent/SKILL.md +82 -36
- package/skills/creating-intent/evals/evals.json +72 -0
- package/skills/creating-intent/references/lifecycle.md +81 -0
- package/skills/creating-intent/references/wikilinks.md +8 -0
- package/skills/creating-project/SKILL.md +40 -8
- package/skills/creating-project/references/hubs-projects.md +55 -0
- package/skills/dashboard/SKILL.md +126 -0
- package/skills/dashboard/evals/evals.json +22 -0
- package/skills/dashboard/templates/dashboard-global.md +31 -0
- package/skills/dashboard/templates/dashboard-project.md +40 -0
- package/skills/doctor/SKILL.md +51 -4
- package/skills/doctor/references/gates-stuck-detection.md +38 -0
- package/skills/doctor/report.md +4 -0
- package/skills/evaluating-skills/SKILL.md +140 -0
- package/skills/evaluating-skills/assets/eval-template.json +12 -0
- package/skills/evaluating-skills/evals/evals.json +75 -0
- package/skills/evaluating-skills/references/convention-checks.md +76 -0
- package/skills/evaluating-skills/references/eval-methodology.md +154 -0
- package/skills/executing-plan/SKILL.md +5 -3
- package/skills/install/SKILL.md +69 -8
- package/skills/intent-curator/SKILL.md +6 -4
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/linking-intents/SKILL.md +22 -7
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +45 -0
- package/skills/managing-index/SKILL.md +11 -1
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +7 -2
- package/skills/releasing/SKILL.md +80 -23
- package/skills/releasing/references/deprecations.md +60 -0
- package/skills/research/SKILL.md +10 -2
- package/skills/research/evals/evals.json +22 -0
- package/skills/savepoint/SKILL.md +46 -37
- package/skills/savepoint/references/context-management.md +32 -0
- package/skills/uninstall/SKILL.md +39 -28
- package/skills/update/SKILL.md +41 -44
- package/skills/versions/SKILL.md +65 -0
- package/skills/writing-instructions/SKILL.md +159 -0
- package/skills/writing-instructions/references/agentskills-spec.md +135 -0
- package/skills/writing-plans/SKILL.md +5 -5
- package/templates/agents.md +7 -7
- package/templates/outcome.md +13 -0
- package/templates/savepoint.md +14 -13
- package/templates/spec.md +25 -0
- package/bin/install.js +0 -29
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Renders the SessionStart boot banner from a core-health result (intent 36a).
|
|
5
|
+
#
|
|
6
|
+
# Pure and dependency-injected: it takes the health hash (the return value of
|
|
7
|
+
# Doctor#run_core_checks) and the version string, and returns a single banner
|
|
8
|
+
# line. No I/O, no globals, no ENV — so it is unit-testable in isolation while
|
|
9
|
+
# the hook feeds it real data.
|
|
10
|
+
module BootBanner
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# health: the Hash returned by Doctor#run_core_checks, or nil if the check
|
|
14
|
+
# itself raised (degraded to an error banner).
|
|
15
|
+
# version: the installed Plastic version string, or nil.
|
|
16
|
+
#
|
|
17
|
+
# Returns one of two binary lines:
|
|
18
|
+
# "Plastic Core loaded — v{VER} | doctor --core run: success"
|
|
19
|
+
# "Plastic Core loaded — v{VER} | doctor --core run: error — run /plastic-doctor"
|
|
20
|
+
def render(health:, version:)
|
|
21
|
+
ver = version || "unknown"
|
|
22
|
+
if !health.nil? && health[:status] == "pass"
|
|
23
|
+
"Plastic Core loaded — v#{ver} | doctor --core run: success"
|
|
24
|
+
else
|
|
25
|
+
"Plastic Core loaded — v#{ver} | doctor --core run: error — run /plastic-doctor"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -5,46 +5,247 @@ require "json"
|
|
|
5
5
|
require "yaml"
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "tempfile"
|
|
8
|
+
require "digest"
|
|
8
9
|
|
|
9
10
|
module Bridge
|
|
10
11
|
STAGES = %w[what why how exec done].freeze
|
|
11
12
|
|
|
13
|
+
# Placeholder sentinel (intent 60b). A scaffolded lifecycle file
|
|
14
|
+
# (spec.md/plan.md/checklist.md/outcome.md) carries this exact string as its
|
|
15
|
+
# first line until an agent fills the file and deletes the sentinel. The
|
|
16
|
+
# sentinel is the "stage not reached yet" marker, so stage detection treats a
|
|
17
|
+
# sentinel-marked file as absent (see stage_file_present?). The intent file
|
|
18
|
+
# (<id>--<slug>.md) is never sentineled; it is born complete.
|
|
19
|
+
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
20
|
+
|
|
21
|
+
# Bridge cleanup is terminal-state, not age-based (intent 80). A bridge is dead
|
|
22
|
+
# weight ONLY once its intent is terminal (no longer in its store's INDEX.md
|
|
23
|
+
# `## Active` block); such bridges are purged. An Active intent's bridge is kept
|
|
24
|
+
# unconditionally, because while the intent is live the bridge is still load-
|
|
25
|
+
# bearing: it is the continuation signal (a parked or interrupted run resumes
|
|
26
|
+
# from it) and the anti-collision lock (it keys the per-session statusline so
|
|
27
|
+
# parallel sessions do not overwrite each other). An age window was the wrong
|
|
28
|
+
# axis: it left dead bridges resident for ~2 days AND could reap bridges of
|
|
29
|
+
# interrupted-but-still-active intents, which are exactly the ones to preserve.
|
|
30
|
+
|
|
12
31
|
def self.intent_file(intent_dir)
|
|
13
32
|
dir_name = File.basename(intent_dir)
|
|
14
33
|
"#{intent_dir}/#{dir_name}.md"
|
|
15
34
|
end
|
|
16
35
|
|
|
17
|
-
|
|
18
|
-
|
|
36
|
+
# Single source for the OS temp location holding bridge files. Lets every
|
|
37
|
+
# process (arm_auto, the gate hooks) agree, and lets tests fully isolate by
|
|
38
|
+
# pointing PLASTIC_TMP at a Dir.mktmpdir. This is OS-temp-location resolution,
|
|
39
|
+
# not a logic-config injection seam.
|
|
40
|
+
def self.tmp_dir
|
|
41
|
+
t = ENV["PLASTIC_TMP"]
|
|
42
|
+
(t.nil? || t.strip.empty?) ? "/tmp" : t
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.path(session, tmp: tmp_dir)
|
|
46
|
+
"#{tmp}/plastic-#{session}.json"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# --- Session resolution (intent 52) ----------------------------------------
|
|
50
|
+
|
|
51
|
+
def self.blank?(value)
|
|
52
|
+
value.nil? || value.to_s.strip.empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Deterministic, session-id-less bridge key derived from store + intent id.
|
|
56
|
+
# Stable across processes so a session-less arm and a later session-less
|
|
57
|
+
# gate-check resolve to the same bridge file.
|
|
58
|
+
def self.derive_key(store, intent_id)
|
|
59
|
+
"auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Resolve a bridge session: first non-empty of explicit, CLAUDE_SESSION_ID,
|
|
63
|
+
# CLAUDE_CODE_SESSION_ID, then a derived key. Never returns nil/empty.
|
|
64
|
+
# Whitespace-only counts as empty.
|
|
65
|
+
#
|
|
66
|
+
# The CLAUDE_CODE_SESSION_ID fallback (intent 79) is additive: it only changes
|
|
67
|
+
# behavior when CLAUDE_SESSION_ID is blank but CLAUDE_CODE_SESSION_ID is set —
|
|
68
|
+
# the bg/headless case where the real session id lives in CLAUDE_CODE_SESSION_ID.
|
|
69
|
+
# Keying by the real id (instead of a derived hash) lets the statusline, which
|
|
70
|
+
# receives that same id on stdin, find the bridge by direct filename lookup.
|
|
71
|
+
def self.resolve_session(explicit, intent_id:, store:)
|
|
72
|
+
return explicit.to_s.strip unless blank?(explicit)
|
|
73
|
+
env = ENV["CLAUDE_SESSION_ID"]
|
|
74
|
+
return env.to_s.strip unless blank?(env)
|
|
75
|
+
code_env = ENV["CLAUDE_CODE_SESSION_ID"]
|
|
76
|
+
return code_env.to_s.strip unless blank?(code_env)
|
|
77
|
+
derive_key(store, intent_id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Walk up from file_path; return the first ancestor that looks like an intent
|
|
81
|
+
# directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
|
|
82
|
+
# target without needing a bridge. The input is always a file inside the intent
|
|
83
|
+
# dir (never the dir itself), so the walk-up starts at its parent.
|
|
84
|
+
def self.intent_dir_for(file_path)
|
|
85
|
+
dir = File.expand_path(file_path)
|
|
86
|
+
loop do
|
|
87
|
+
parent = File.dirname(dir)
|
|
88
|
+
break if parent == dir # reached filesystem root
|
|
89
|
+
dir = parent
|
|
90
|
+
return dir if dir.match?(%r{/store/[^/]+--[^/]+\z})
|
|
91
|
+
end
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A bridge hash is usable iff it has a non-empty session and an intent Hash.
|
|
96
|
+
def self.bridge_valid?(data)
|
|
97
|
+
data.is_a?(Hash) && !blank?(data["session"]) && data["intent"].is_a?(Hash)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Resolve the active bridge. Exact-session lookup first; otherwise scan tmp:
|
|
101
|
+
# for plastic-*.json, keep only valid bridges, prefer auto-armed, then prefer
|
|
102
|
+
# the one whose intent.store matches cwd, tie-break by newest mtime.
|
|
103
|
+
def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
|
|
104
|
+
if !blank?(session) && File.exist?(path(session, tmp: tmp))
|
|
105
|
+
exact = read(session, tmp: tmp)
|
|
106
|
+
return exact if bridge_valid?(exact)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
|
|
110
|
+
parsed = candidates.filter_map do |f|
|
|
111
|
+
data = (JSON.parse(File.read(f)) rescue nil)
|
|
112
|
+
next unless data && bridge_valid?(data)
|
|
113
|
+
{ file: f, data: data, mtime: File.mtime(f) }
|
|
114
|
+
end
|
|
115
|
+
return nil if parsed.empty?
|
|
116
|
+
|
|
117
|
+
auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
|
|
118
|
+
pool = auto.empty? ? parsed : auto
|
|
119
|
+
|
|
120
|
+
unless blank?(cwd)
|
|
121
|
+
cwd_abs = File.expand_path(cwd)
|
|
122
|
+
matching = pool.select do |c|
|
|
123
|
+
store = c[:data].dig("intent", "store").to_s
|
|
124
|
+
next false if store.empty?
|
|
125
|
+
store_abs = File.expand_path(store)
|
|
126
|
+
cwd_abs == store_abs ||
|
|
127
|
+
cwd_abs.start_with?("#{store_abs}/") ||
|
|
128
|
+
store_abs.start_with?("#{cwd_abs}/")
|
|
129
|
+
end
|
|
130
|
+
pool = matching unless matching.empty?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
pool.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# --- Terminal-state bridge purge (intent 80) -------------------------------
|
|
137
|
+
|
|
138
|
+
# True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
|
|
139
|
+
# the PARENT of the store/ dir the bridge records, so we resolve it from the
|
|
140
|
+
# bridge's intent.store. Non-raising: any failure (missing/unreadable INDEX,
|
|
141
|
+
# bad arg) returns false, which means "not active" so the caller treats the
|
|
142
|
+
# bridge as purgeable. `index_active_ids` is a pure-data test seam: when an
|
|
143
|
+
# Array of id strings is supplied, membership is checked against it directly
|
|
144
|
+
# with no file read.
|
|
145
|
+
def self.intent_active?(intent_id, store:, index_active_ids: nil)
|
|
146
|
+
target = intent_id.to_s
|
|
147
|
+
return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
|
|
148
|
+
|
|
149
|
+
index = File.join(File.dirname(store.to_s), "INDEX.md")
|
|
150
|
+
return false unless File.exist?(index)
|
|
151
|
+
|
|
152
|
+
in_active = false
|
|
153
|
+
File.foreach(index) do |line|
|
|
154
|
+
stripped = line.chomp
|
|
155
|
+
if stripped == "## Active"
|
|
156
|
+
in_active = true
|
|
157
|
+
next
|
|
158
|
+
end
|
|
159
|
+
next unless in_active
|
|
160
|
+
break if stripped.start_with?("## ") # next section ends the Active block
|
|
161
|
+
m = stripped.match(/^- \[(\S+) +—/)
|
|
162
|
+
return true if m && m[1] == target
|
|
163
|
+
end
|
|
164
|
+
false
|
|
165
|
+
rescue StandardError
|
|
166
|
+
false
|
|
19
167
|
end
|
|
20
168
|
|
|
21
|
-
|
|
22
|
-
|
|
169
|
+
# Remove tmp/plastic-*.json bridge files whose intent is terminal, so
|
|
170
|
+
# discover_bridge's per-fire scan stays bounded. Best-effort and non-raising:
|
|
171
|
+
# returns the array of removed paths. A bridge is purged when it cannot be
|
|
172
|
+
# parsed, has no intent.id, has no intent.store, or its intent is not Active in
|
|
173
|
+
# its store's INDEX.md. An Active intent's bridge is kept (continuation signal +
|
|
174
|
+
# anti-collision lock), and the current session's own bridge is never purged
|
|
175
|
+
# (preserves the disarm_auto contract that it stays readable). Wired into
|
|
176
|
+
# arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
|
|
177
|
+
# clean at deterministic work boundaries.
|
|
178
|
+
def self.purge_done_bridges(session:, tmp: tmp_dir)
|
|
179
|
+
current = path(session, tmp: tmp)
|
|
180
|
+
removed = []
|
|
181
|
+
Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
|
|
182
|
+
next if f == current
|
|
183
|
+
begin
|
|
184
|
+
data = JSON.parse(File.read(f)) rescue nil
|
|
185
|
+
keep = false
|
|
186
|
+
if data
|
|
187
|
+
id = data.dig("intent", "id")
|
|
188
|
+
store = data.dig("intent", "store")
|
|
189
|
+
keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
|
|
190
|
+
end
|
|
191
|
+
next if keep
|
|
192
|
+
File.delete(f)
|
|
193
|
+
removed << f
|
|
194
|
+
rescue Errno::ENOENT
|
|
195
|
+
# Raced with another job that already removed it; count as purged.
|
|
196
|
+
removed << f
|
|
197
|
+
rescue => e
|
|
198
|
+
$stderr.puts "plastic: purge skipped #{f}: #{e.message}"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
removed
|
|
202
|
+
rescue => e
|
|
203
|
+
$stderr.puts "plastic: purge_done_bridges failed: #{e.message}"
|
|
204
|
+
removed || []
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.read(session, tmp: tmp_dir)
|
|
208
|
+
p = path(session, tmp: tmp)
|
|
23
209
|
return nil unless File.exist?(p)
|
|
24
210
|
JSON.parse(File.read(p))
|
|
25
211
|
rescue JSON::ParserError
|
|
26
212
|
nil
|
|
27
213
|
end
|
|
28
214
|
|
|
29
|
-
def self.write(session, data)
|
|
30
|
-
|
|
215
|
+
def self.write(session, data, tmp: tmp_dir)
|
|
216
|
+
raise ArgumentError, "bridge session must be present" if blank?(session)
|
|
217
|
+
p = path(session, tmp: tmp)
|
|
31
218
|
# Atomic write: tmp file + rename to prevent partial reads
|
|
32
|
-
|
|
33
|
-
File.write(
|
|
34
|
-
File.rename(
|
|
219
|
+
tmp_file = "#{p}.tmp.#{Process.pid}"
|
|
220
|
+
File.write(tmp_file, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
|
|
221
|
+
File.rename(tmp_file, p)
|
|
35
222
|
rescue => e
|
|
36
|
-
File.delete(
|
|
223
|
+
File.delete(tmp_file) if tmp_file && File.exist?(tmp_file)
|
|
37
224
|
raise e
|
|
38
225
|
end
|
|
39
226
|
|
|
227
|
+
# True iff a lifecycle file is PRESENT AND REAL: it exists and its first line is
|
|
228
|
+
# not the placeholder sentinel. Reads only the file head (never the whole file)
|
|
229
|
+
# so the dashboard stays fast across many intents. Exact first-line match only,
|
|
230
|
+
# so a real file that merely contains an HTML comment later is unaffected, and a
|
|
231
|
+
# partially-edited sentinel reads as real rather than sticking as a placeholder.
|
|
232
|
+
def self.stage_file_present?(path)
|
|
233
|
+
return false unless File.exist?(path)
|
|
234
|
+
first = File.open(path, &:gets)
|
|
235
|
+
return true if first.nil? # empty file: present, not a sentinel
|
|
236
|
+
first.chomp != PLACEHOLDER_SENTINEL
|
|
237
|
+
rescue StandardError
|
|
238
|
+
File.exist?(path)
|
|
239
|
+
end
|
|
240
|
+
|
|
40
241
|
def self.derive_stage(intent_dir)
|
|
41
|
-
return "done" if
|
|
42
|
-
if
|
|
242
|
+
return "done" if stage_file_present?("#{intent_dir}/outcome.md")
|
|
243
|
+
if stage_file_present?("#{intent_dir}/plan.md") &&
|
|
43
244
|
File.directory?("#{intent_dir}/actions") &&
|
|
44
|
-
|
|
245
|
+
stage_file_present?("#{intent_dir}/checklist.md")
|
|
45
246
|
return "exec"
|
|
46
247
|
end
|
|
47
|
-
return "how" if
|
|
248
|
+
return "how" if stage_file_present?("#{intent_dir}/spec.md")
|
|
48
249
|
return "why" if File.exist?(intent_file(intent_dir))
|
|
49
250
|
"what"
|
|
50
251
|
end
|
|
@@ -52,8 +253,9 @@ module Bridge
|
|
|
52
253
|
def self.has_files(intent_dir)
|
|
53
254
|
files = []
|
|
54
255
|
ifile = File.basename(intent_file(intent_dir))
|
|
55
|
-
|
|
56
|
-
|
|
256
|
+
files << ifile if File.exist?("#{intent_dir}/#{ifile}")
|
|
257
|
+
["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
258
|
+
files << f if stage_file_present?("#{intent_dir}/#{f}")
|
|
57
259
|
end
|
|
58
260
|
files << "actions/" if File.directory?("#{intent_dir}/actions")
|
|
59
261
|
files
|
|
@@ -70,6 +272,72 @@ module Bridge
|
|
|
70
272
|
end
|
|
71
273
|
end
|
|
72
274
|
|
|
275
|
+
# --- Cycle-step savepoint ledger (intent 34) ------------------------------
|
|
276
|
+
#
|
|
277
|
+
# savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
|
|
278
|
+
# (newest at the bottom). It is sugar on top of the conventions: derived from
|
|
279
|
+
# files-on-disk, rebuildable, never a source of truth. Milestones are
|
|
280
|
+
# file-event boundaries only; action/resource files record nothing.
|
|
281
|
+
|
|
282
|
+
SAVEPOINT_FILE = "savepoint.md"
|
|
283
|
+
|
|
284
|
+
# Map a written filename to [stage_label, milestone_text], or nil if the file
|
|
285
|
+
# is not a lifecycle milestone.
|
|
286
|
+
def self.savepoint_milestone(intent_dir, basename)
|
|
287
|
+
return ["What", basename] if basename == File.basename(intent_file(intent_dir))
|
|
288
|
+
|
|
289
|
+
case basename
|
|
290
|
+
when "spec.md" then ["Why", "spec.md created"]
|
|
291
|
+
when "plan.md" then ["How", "plan.md created"]
|
|
292
|
+
when "checklist.md" then ["How", "checklist.md created"]
|
|
293
|
+
when "outcome.md" then ["Exec", "outcome.md created"]
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Milestones already recorded in the ledger (field 3 of each line).
|
|
298
|
+
def self.savepoint_recorded_milestones(intent_dir)
|
|
299
|
+
f = File.join(intent_dir, SAVEPOINT_FILE)
|
|
300
|
+
return [] unless File.exist?(f)
|
|
301
|
+
File.read(f).each_line.map do |line|
|
|
302
|
+
parts = line.strip.split(/\s{2,}/)
|
|
303
|
+
parts.length >= 3 ? parts[2] : nil
|
|
304
|
+
end.compact
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Append a milestone line for file_path if (and only if) it is a milestone
|
|
308
|
+
# not already recorded. Returns true when a line was written, false otherwise.
|
|
309
|
+
def self.append_savepoint(intent_dir, file_path, now: Time.now)
|
|
310
|
+
basename = File.basename(file_path)
|
|
311
|
+
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
312
|
+
return false unless milestone
|
|
313
|
+
# A sentinel-marked lifecycle file logs NO milestone (the stage is not real
|
|
314
|
+
# yet). The intent file is never sentineled, so it still logs its What line.
|
|
315
|
+
return false unless stage_file_present?(File.join(intent_dir, basename))
|
|
316
|
+
return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
|
|
317
|
+
|
|
318
|
+
line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
|
|
319
|
+
File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
|
|
320
|
+
true
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Reconstruct the ledger from files on disk (timestamps from mtimes), in
|
|
324
|
+
# stage order, overwriting savepoint.md. Returns the number of lines written.
|
|
325
|
+
def self.rebuild_savepoint(intent_dir)
|
|
326
|
+
ordered = [
|
|
327
|
+
File.basename(intent_file(intent_dir)),
|
|
328
|
+
"spec.md", "plan.md", "checklist.md", "outcome.md",
|
|
329
|
+
]
|
|
330
|
+
lines = ordered.filter_map do |basename|
|
|
331
|
+
path = File.join(intent_dir, basename)
|
|
332
|
+
next unless stage_file_present?(path)
|
|
333
|
+
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
334
|
+
next unless milestone
|
|
335
|
+
"#{File.mtime(path).utc.iso8601} #{stage} #{milestone}\n"
|
|
336
|
+
end
|
|
337
|
+
File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
|
|
338
|
+
lines.length
|
|
339
|
+
end
|
|
340
|
+
|
|
73
341
|
def self.derive(session, intent_id:, intent_dir:, store:, name:)
|
|
74
342
|
stage = derive_stage(intent_dir)
|
|
75
343
|
has = has_files(intent_dir)
|
|
@@ -88,6 +356,7 @@ module Bridge
|
|
|
88
356
|
"has" => has,
|
|
89
357
|
"missing" => missing,
|
|
90
358
|
"gate_failures" => 0,
|
|
359
|
+
"auto" => false,
|
|
91
360
|
"last_activity" => Time.now.utc.iso8601
|
|
92
361
|
},
|
|
93
362
|
"observe" => {
|
|
@@ -117,16 +386,16 @@ module Bridge
|
|
|
117
386
|
return "Cannot start Why — What is incomplete (#{File.basename(ifile)} missing or no ## Intent)"
|
|
118
387
|
end
|
|
119
388
|
when "plan.md"
|
|
120
|
-
unless
|
|
389
|
+
unless stage_file_present?("#{intent_dir}/spec.md")
|
|
121
390
|
return "Cannot start How — Why is incomplete (spec.md missing)"
|
|
122
391
|
end
|
|
123
392
|
when "checklist.md"
|
|
124
|
-
unless
|
|
393
|
+
unless stage_file_present?("#{intent_dir}/plan.md") && File.directory?("#{intent_dir}/actions")
|
|
125
394
|
return "Cannot complete How — plan.md or actions/ missing"
|
|
126
395
|
end
|
|
127
396
|
when "outcome.md"
|
|
128
397
|
checklist = "#{intent_dir}/checklist.md"
|
|
129
|
-
if
|
|
398
|
+
if stage_file_present?(checklist)
|
|
130
399
|
content = File.read(checklist)
|
|
131
400
|
unchecked = content.scan(/^- \[ \]/).length
|
|
132
401
|
if unchecked > 0
|
|
@@ -159,6 +428,170 @@ module Bridge
|
|
|
159
428
|
PROJECT_CONFIG_DEFAULTS.dup
|
|
160
429
|
end
|
|
161
430
|
|
|
431
|
+
# --- Auto mode (intent 27) ---
|
|
432
|
+
|
|
433
|
+
# Arm auto mode for a session+intent. Works even when no bridge exists yet
|
|
434
|
+
# (mid-session intent creation). Re-derives intent state, then sets build.auto.
|
|
435
|
+
def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
|
|
436
|
+
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
437
|
+
if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"]) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
|
|
438
|
+
$stderr.puts "plastic: no session id available; arming auto with derived bridge key #{key}"
|
|
439
|
+
end
|
|
440
|
+
data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
|
|
441
|
+
data["build"]["auto"] = true
|
|
442
|
+
write(key, data)
|
|
443
|
+
purge_done_bridges(session: key)
|
|
444
|
+
data
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Disarm auto mode. No-op if no bridge exists for the session.
|
|
448
|
+
def self.disarm_auto(session)
|
|
449
|
+
data = read(session)
|
|
450
|
+
return nil unless data
|
|
451
|
+
data["build"] ||= {}
|
|
452
|
+
data["build"]["auto"] = false
|
|
453
|
+
write(session, data)
|
|
454
|
+
purge_done_bridges(session: session)
|
|
455
|
+
data
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Decide whether a code edit should be blocked while auto mode is armed.
|
|
459
|
+
# Returns a reason string to BLOCK, or nil to ALLOW.
|
|
460
|
+
#
|
|
461
|
+
# Blocks iff: auto armed AND intent hasn't reached How (stage what/why) AND the
|
|
462
|
+
# target is project code — i.e. NOT under ~/.plastic and NOT inside the intent dir.
|
|
463
|
+
def self.code_gate_decision(bridge_data, file_path, home: Dir.home)
|
|
464
|
+
return nil unless bridge_data.is_a?(Hash)
|
|
465
|
+
build = bridge_data["build"] || {}
|
|
466
|
+
return nil unless build["auto"] == true
|
|
467
|
+
|
|
468
|
+
intent_info = bridge_data["intent"] || {}
|
|
469
|
+
store = intent_info["store"]
|
|
470
|
+
dir = intent_info["dir"]
|
|
471
|
+
return nil unless store && dir
|
|
472
|
+
intent_dir_abs = File.expand_path("#{store}/#{dir}")
|
|
473
|
+
|
|
474
|
+
# "How reached" = the plan triplet exists. Gate by artifact presence, not the
|
|
475
|
+
# stage label (derive_stage returns "how" as soon as spec.md exists, before any
|
|
476
|
+
# plan). Code edits stay blocked until plan.md + checklist.md are both present.
|
|
477
|
+
reached_how = stage_file_present?("#{intent_dir_abs}/plan.md") &&
|
|
478
|
+
stage_file_present?("#{intent_dir_abs}/checklist.md")
|
|
479
|
+
return nil if reached_how
|
|
480
|
+
|
|
481
|
+
file_abs = File.expand_path(file_path.to_s)
|
|
482
|
+
plastic_home = File.expand_path(File.join(home, ".plastic"))
|
|
483
|
+
return nil if file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
|
|
484
|
+
return nil if file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/")
|
|
485
|
+
|
|
486
|
+
id = intent_info["id"]
|
|
487
|
+
"intent #{id} has not reached How — write plan.md + checklist.md before " \
|
|
488
|
+
"editing project code. Run plastic-auto or plastic-writing-plans first. " \
|
|
489
|
+
"(blocked edit: #{file_abs})"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# --- Bash-edit gate (intent 27a) ---
|
|
493
|
+
|
|
494
|
+
# Extract the set of file paths a Bash command writes to. Conservative by
|
|
495
|
+
# design: it is acceptable to miss exotic forms, but it must NOT flag reads
|
|
496
|
+
# or /dev/null. Returns an Array of path strings (possibly relative).
|
|
497
|
+
#
|
|
498
|
+
# Covered write vectors: redirection (>, >>, including heredoc `cat > f <<EOF`),
|
|
499
|
+
# tee / tee -a, sed -i / sed -i.bak, cp/mv (last non-flag arg), dd of=.
|
|
500
|
+
def self.bash_write_targets(command)
|
|
501
|
+
return [] unless command.is_a?(String)
|
|
502
|
+
|
|
503
|
+
targets = []
|
|
504
|
+
targets.concat(bash_redirect_targets(command))
|
|
505
|
+
# Split on command separators for per-segment utility parsing.
|
|
506
|
+
command.split(/[;\n]|&&|\|\||\|/).each do |segment|
|
|
507
|
+
targets.concat(bash_utility_targets(segment))
|
|
508
|
+
end
|
|
509
|
+
targets.uniq
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Redirections: `> path` / `>> path`, but not fd dups (`2>&1`) or /dev/null.
|
|
513
|
+
# A leading digit (fd number) before > is fine; `>&` is a dup and excluded.
|
|
514
|
+
def self.bash_redirect_targets(command)
|
|
515
|
+
targets = []
|
|
516
|
+
# Match optional leading fd digits, then > or >>, not followed by & , then path.
|
|
517
|
+
command.scan(/\d*>>?(?!&)\s*([^\s;|&<>]+)/) do |m|
|
|
518
|
+
path = m[0]
|
|
519
|
+
next if path.nil? || path.empty?
|
|
520
|
+
next if dev_null?(path)
|
|
521
|
+
targets << path
|
|
522
|
+
end
|
|
523
|
+
targets
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def self.bash_utility_targets(segment)
|
|
527
|
+
tokens = segment.strip.split(/\s+/)
|
|
528
|
+
return [] if tokens.empty?
|
|
529
|
+
|
|
530
|
+
# Find the utility name, skipping env-style assignments.
|
|
531
|
+
idx = 0
|
|
532
|
+
idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /^-/ && !tokens[idx].start_with?("of=")
|
|
533
|
+
util = File.basename(tokens[idx].to_s)
|
|
534
|
+
args = tokens[(idx + 1)..] || []
|
|
535
|
+
|
|
536
|
+
case util
|
|
537
|
+
when "tee"
|
|
538
|
+
tee_targets(args)
|
|
539
|
+
when "sed"
|
|
540
|
+
sed_targets(args)
|
|
541
|
+
when "cp", "mv"
|
|
542
|
+
copy_move_targets(args)
|
|
543
|
+
when "dd"
|
|
544
|
+
dd_targets(tokens)
|
|
545
|
+
else
|
|
546
|
+
[]
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def self.tee_targets(args)
|
|
551
|
+
args.reject { |a| a.start_with?("-") || dev_null?(a) }
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def self.sed_targets(args)
|
|
555
|
+
# In-place only: -i or -i.bak (suffix attached). Otherwise sed reads.
|
|
556
|
+
inplace = args.any? { |a| a == "-i" || a.start_with?("-i") }
|
|
557
|
+
return [] unless inplace
|
|
558
|
+
files = args.reject { |a| a.start_with?("-") }
|
|
559
|
+
# sed args: script then file(s). First non-flag is the script expression
|
|
560
|
+
# unless an -e/-f was used; conservatively treat the LAST non-flag as file.
|
|
561
|
+
files.empty? ? [] : [files.last].reject { |f| dev_null?(f) }
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def self.copy_move_targets(args)
|
|
565
|
+
files = args.reject { |a| a.start_with?("-") }
|
|
566
|
+
return [] if files.length < 2
|
|
567
|
+
dest = files.last
|
|
568
|
+
dev_null?(dest) ? [] : [dest]
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def self.dd_targets(tokens)
|
|
572
|
+
tokens.each_with_object([]) do |t, acc|
|
|
573
|
+
next unless t.start_with?("of=")
|
|
574
|
+
path = t.sub("of=", "")
|
|
575
|
+
acc << path unless path.empty? || dev_null?(path)
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def self.dev_null?(path)
|
|
580
|
+
path == "/dev/null" || path.start_with?("/dev/")
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Decide whether a Bash command should be blocked under the auto-mode code
|
|
584
|
+
# gate. Resolves each write target against cwd and applies the SAME policy as
|
|
585
|
+
# code_gate_decision. Returns the first block reason, or nil to allow.
|
|
586
|
+
def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home)
|
|
587
|
+
bash_write_targets(command).each do |target|
|
|
588
|
+
abs = File.absolute_path?(target) ? target : File.join(cwd, target)
|
|
589
|
+
reason = code_gate_decision(bridge_data, abs, home: home)
|
|
590
|
+
return reason if reason
|
|
591
|
+
end
|
|
592
|
+
nil
|
|
593
|
+
end
|
|
594
|
+
|
|
162
595
|
def self.deep_merge(base, overlay)
|
|
163
596
|
result = base.dup
|
|
164
597
|
overlay.each do |key, value|
|