@zalom/plastic 1.14.1 → 2.0.0-alpha.1
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 +101 -294
- package/README.md +5 -6
- package/agents/plastic-advisor.md +18 -17
- package/agents/plastic-enforcer.md +57 -69
- package/agents/plastic-executor.md +34 -20
- package/agents/plastic-faux-advisor.md +18 -18
- package/config_asks.yml +38 -0
- package/hooks/capture +4 -0
- package/hooks/close +5 -0
- package/hooks/hooks.json +9 -49
- package/hooks/record +4 -0
- package/package.json +3 -2
- package/scripts/agent-report +8 -9
- package/scripts/append-ledger +195 -0
- package/scripts/codex-hook +78 -90
- package/scripts/dashboard.rb +4 -4
- package/scripts/doctor.rb +106 -43
- package/scripts/end-intent +77 -76
- package/scripts/exec-worktree +11 -20
- package/scripts/file-session-intent +61 -0
- package/scripts/hook-capture +242 -0
- package/scripts/hook-close +34 -0
- package/scripts/hook-record +149 -0
- package/scripts/hook-session-start +95 -12
- package/scripts/install.rb +8 -6
- package/scripts/lib/agent_models.rb +1 -7
- package/scripts/lib/arm.rb +283 -0
- package/scripts/lib/backfill_intent.rb +316 -0
- package/scripts/lib/bridge.rb +24 -1597
- package/scripts/lib/doctor_core.rb +17 -88
- package/scripts/lib/doctor_session_ledger.rb +106 -0
- package/scripts/lib/exec_worktree.rb +14 -60
- package/scripts/lib/harness_text.rb +5 -4
- package/scripts/lib/hook_registry.rb +44 -107
- package/scripts/lib/insights.rb +3 -4
- package/scripts/lib/installer_core.rb +19 -30
- package/scripts/lib/lock.rb +6 -26
- package/scripts/lib/outcome_guard.rb +7 -5
- package/scripts/lib/project_validator.rb +30 -1
- package/scripts/lib/roadmap_queue.rb +3 -3
- package/scripts/lib/roadmap_savepoint.rb +1 -1
- package/scripts/lib/rule_catalog.rb +6 -3
- package/scripts/lib/savepoint.rb +320 -0
- package/scripts/lib/scaffold_intent.rb +13 -179
- package/scripts/lib/session_backfill.rb +184 -0
- package/scripts/lib/session_close.rb +84 -0
- package/scripts/lib/session_git.rb +469 -0
- package/scripts/lib/session_ledger.rb +497 -0
- package/scripts/lib/store_discovery.rb +1 -1
- package/scripts/lib/store_provisioning.rb +1 -1
- package/scripts/maintenance-run +7 -7
- package/scripts/new-intent +63 -4
- package/scripts/plastic-lock +94 -41
- package/scripts/promote-session-item +150 -0
- package/scripts/rebuild-graph +1 -1
- package/scripts/roadmap-next +2 -2
- package/scripts/rollback.rb +42 -3
- package/scripts/scaffold-intent +37 -63
- package/scripts/session-commit +116 -0
- package/scripts/spawn-preamble +5 -5
- package/scripts/update.rb +7 -3
- package/skills/agent-advisor/SKILL.md +3 -3
- package/skills/agent-advisor/references/advisor-protocol.md +20 -20
- package/skills/auto/SKILL.md +218 -317
- package/skills/auto/evals/evals.json +10 -10
- package/skills/auto/references/agent-architecture.md +102 -90
- package/skills/auto/references/agent-report-contract.md +4 -21
- package/skills/auto/references/end-tail.md +47 -53
- package/skills/auto/references/human-report-contract.md +8 -8
- package/skills/conventions/SKILL.md +1 -3
- package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
- package/skills/conventions/references/locks-and-worktrees.md +47 -55
- package/skills/conventions/references/maintenance-and-revisions.md +12 -12
- package/skills/dashboard/SKILL.md +1 -1
- package/skills/direct/SKILL.md +66 -0
- package/skills/direct/references/request-signals.md +59 -0
- package/skills/doctor/SKILL.md +58 -11
- package/skills/doctor/report.md +3 -1
- package/skills/install/SKILL.md +11 -9
- package/skills/intent-continuing/SKILL.md +131 -89
- package/skills/intent-continuing/references/boarding-matrix.md +34 -0
- package/skills/intent-continuing/references/context-management.md +2 -2
- package/skills/intent-creating/SKILL.md +2 -2
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-ending/SKILL.md +28 -37
- package/skills/intent-ending/evals/evals.json +4 -4
- package/skills/intent-executing/SKILL.md +12 -49
- package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
- package/skills/intent-speccing/SKILL.md +108 -51
- package/skills/intent-speccing/evals/evals.json +5 -5
- package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
- package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
- package/skills/project-creating/SKILL.md +1 -1
- package/skills/releasing/SKILL.md +7 -8
- package/skills/tutorial/SKILL.md +2 -2
- package/skills/tutorial/references/track-1-guided.md +16 -18
- package/skills/tutorial/references/track-2-auto.md +12 -13
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
- package/templates/agents.md +4 -7
- package/templates/project.yml +7 -0
- package/templates/roadmap.md +1 -1
- package/templates/savepoint.md +2 -2
- package/templates/session-intent.md +29 -0
- package/agents/plastic-brainstorming.md +0 -48
- package/agents/plastic-future-intent-researcher.md +0 -35
- package/agents/plastic-intent-curator.md +0 -47
- package/agents/plastic-intent-discovery.md +0 -43
- package/agents/plastic-planner.md +0 -56
- package/agents/plastic-spec-specialist.md +0 -53
- package/hooks/auto-arm +0 -5
- package/hooks/bash-gate +0 -3
- package/hooks/continue +0 -31
- package/hooks/edit-gates +0 -3
- package/hooks/future-intent-check +0 -25
- package/hooks/gate-check +0 -12
- package/hooks/power-tools +0 -8
- package/scripts/hook-auto-arm +0 -51
- package/scripts/hook-bash-gate +0 -78
- package/scripts/hook-code-gate +0 -38
- package/scripts/hook-continue +0 -48
- package/scripts/hook-create-gate +0 -51
- package/scripts/hook-edit-gates +0 -58
- package/scripts/hook-future-intent-check +0 -90
- package/scripts/hook-gate-check +0 -169
- package/scripts/hook-links-gate +0 -54
- package/scripts/hook-lock-gate +0 -47
- package/scripts/hook-power-tools +0 -38
- package/scripts/hook-savepoint-pre +0 -32
- package/scripts/lib/codex_edit_gates.rb +0 -138
- package/scripts/lib/edit_gates.rb +0 -398
- package/scripts/lib/links_gate.rb +0 -140
- package/scripts/lib/qmd_hook.rb +0 -41
- package/scripts/lib/spec_header.rb +0 -83
- package/scripts/lib/start_intent.rb +0 -296
- package/scripts/start-intent +0 -89
- package/skills/_active-intent-gate.md +0 -26
- package/skills/auto/references/tiers.md +0 -100
- package/skills/continuing/SKILL.md +0 -34
- package/skills/continuing/evals/evals.json +0 -91
- package/skills/conventions/references/gates-and-enforcement.md +0 -53
- package/skills/conventions/references/tiers-and-dispatch.md +0 -135
- package/skills/doctor/references/gates-stuck-detection.md +0 -43
- package/skills/intent-brainstorming/SKILL.md +0 -118
- package/skills/intent-brainstorming/evals/evals.json +0 -67
- package/skills/intent-continuing/evals/evals.json +0 -145
- package/skills/intent-discovering/SKILL.md +0 -56
- package/skills/intent-grilling/SKILL.md +0 -108
- package/skills/intent-linking/SKILL.md +0 -128
- package/skills/intent-linking/evals/evals.json +0 -22
- package/skills/intent-linking/references/zettelkasten.md +0 -45
- package/skills/intent-locking/SKILL.md +0 -64
- package/skills/intent-planning/SKILL.md +0 -145
- package/skills/intent-planning/evals/evals.json +0 -81
- package/skills/intent-planning/references/plan-format.md +0 -130
- package/skills/intent-researching/SKILL.md +0 -123
- package/skills/intent-researching/evals/evals.json +0 -22
- package/skills/intent-savepoint/SKILL.md +0 -83
- package/skills/intent-savepoint/references/context-management.md +0 -32
- package/skills/intent-starting/SKILL.md +0 -151
- package/skills/intent-starting/evals/evals.json +0 -117
- package/skills/intent-starting/references/boarding-matrix.md +0 -35
- package/skills/project-continuing/SKILL.md +0 -119
- package/skills/project-continuing/evals/evals.json +0 -100
- package/skills/roadmap-continuing/SKILL.md +0 -89
- package/skills/roadmap-continuing/evals/evals.json +0 -82
- package/skills/skill-creating/SKILL.md +0 -75
- package/skills/skill-creating/evals/evals.json +0 -108
- package/skills/skill-creating/references/agents.md +0 -168
- package/skills/skill-creating/references/defaults-first.md +0 -23
- package/skills/skill-creating/references/evals.md +0 -41
- package/skills/skill-creating/references/hooks.md +0 -251
- package/skills/skill-creating/references/progressive-disclosure.md +0 -176
- package/skills/skill-creating/references/scripts.md +0 -166
- package/skills/skill-creating/references/skills.md +0 -169
- package/skills/skill-creating/scripts/scaffold.rb +0 -313
- package/skills/skill-evaluating/SKILL.md +0 -141
- package/skills/skill-evaluating/assets/eval-template.json +0 -12
- package/skills/skill-evaluating/evals/evals.json +0 -75
- package/skills/skill-evaluating/references/convention-checks.md +0 -76
- package/skills/skill-evaluating/references/eval-methodology.md +0 -154
- package/skills/store-curating/SKILL.md +0 -64
- package/skills/store-curating/evals/evals.json +0 -38
- package/skills/store-indexing/SKILL.md +0 -93
- package/skills/store-indexing/evals/evals.json +0 -22
- package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
- package/skills/store-provisioning/SKILL.md +0 -55
- /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
- /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
- /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
package/scripts/lib/bridge.rb
CHANGED
|
@@ -1,301 +1,40 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
|
|
4
|
-
require "json"
|
|
5
4
|
require "yaml"
|
|
6
|
-
require "fileutils"
|
|
7
|
-
require "tempfile"
|
|
8
|
-
require "digest"
|
|
9
|
-
require "socket"
|
|
10
|
-
require_relative "worktree"
|
|
11
5
|
require_relative "lock"
|
|
12
|
-
require_relative "spec_header"
|
|
13
6
|
|
|
7
|
+
# Bridge - the shared helpers that outlived the /tmp bridge JSON.
|
|
8
|
+
#
|
|
9
|
+
# Until 2.0 this file cached a session's delivery state in
|
|
10
|
+
# `<tmp>/plastic-<session>--<id>.json` (stage, worktree, lock, auto flag) and
|
|
11
|
+
# arbitrated which bridge a gate hook should read. Ruling 6 of intent 296
|
|
12
|
+
# retired that: the per-session pointer (`~/.plastic/store/.tmp/<session>/current`)
|
|
13
|
+
# plus `delivery.lock` in the intent directory is the whole bridge, and
|
|
14
|
+
# `scripts/lib/arm.rb` is how a team takes and gives back an intent (intent
|
|
15
|
+
# 307). What stays here is the handful of pure helpers a dozen callers still
|
|
16
|
+
# share: the INDEX entry matcher, the Active check, the id-from-dir parser,
|
|
17
|
+
# the project config reader, `blank?`, and the skill-reference delegator.
|
|
14
18
|
module Bridge
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
# (spec.md/plan.md/checklist.md/outcome.md) carries this exact string as its
|
|
19
|
-
# first line until an agent fills the file and deletes the sentinel. The
|
|
20
|
-
# sentinel is the "stage not reached yet" marker, so stage detection treats a
|
|
21
|
-
# sentinel-marked file as absent (see stage_file_present?). The intent file
|
|
22
|
-
# (<id>--<slug>.md) is never sentineled; it is born complete.
|
|
23
|
-
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
24
|
-
|
|
25
|
-
# Single place a skill-reference string gets built (intent 201, D3): every
|
|
26
|
-
# message that used to write "/plastic-something" by hand calls this
|
|
27
|
-
# instead, so a fourth harness only teaches ITS prefix once instead of
|
|
28
|
-
# hunting the codebase for hardcoded slashes. The actual prefix table lives
|
|
29
|
-
# on Lock (see lock.rb), which bridge.rb already requires; this is a thin
|
|
30
|
-
# delegator so every call site in this file reads Bridge.skill_ref.
|
|
19
|
+
# Single place a skill-reference string gets built (intent 201, D3). The
|
|
20
|
+
# prefix table lives on Lock; this is a thin delegator so call sites read
|
|
21
|
+
# Bridge.skill_ref.
|
|
31
22
|
def self.skill_ref(name, harness: :claude)
|
|
32
23
|
Lock.skill_ref(name, harness: harness)
|
|
33
24
|
end
|
|
34
25
|
|
|
35
|
-
# Bridge cleanup is terminal-state, not age-based (intent 80). A bridge is dead
|
|
36
|
-
# weight ONLY once its intent is terminal (no longer in its store's INDEX.md
|
|
37
|
-
# `## Active` block); such bridges are purged. An Active intent's bridge is kept
|
|
38
|
-
# unconditionally, because while the intent is live the bridge is still load-
|
|
39
|
-
# bearing: it is the continuation signal (a parked or interrupted run resumes
|
|
40
|
-
# from it) and the anti-collision lock for parallel deliveries on one store,
|
|
41
|
-
# keeping each session's gate checks and locks from overwriting another's. An
|
|
42
|
-
# age window was the wrong
|
|
43
|
-
# axis: it left dead bridges resident for ~2 days AND could reap bridges of
|
|
44
|
-
# interrupted-but-still-active intents, which are exactly the ones to preserve.
|
|
45
|
-
|
|
46
|
-
def self.intent_file(intent_dir)
|
|
47
|
-
dir_name = File.basename(intent_dir)
|
|
48
|
-
"#{intent_dir}/#{dir_name}.md"
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Single source for the OS temp location holding bridge files. Lets every
|
|
52
|
-
# process (arm_auto, the gate hooks) agree, and lets tests fully isolate by
|
|
53
|
-
# pointing PLASTIC_TMP at a Dir.mktmpdir. This is OS-temp-location resolution,
|
|
54
|
-
# not a logic-config injection seam.
|
|
55
|
-
def self.tmp_dir
|
|
56
|
-
t = ENV["PLASTIC_TMP"]
|
|
57
|
-
(t.nil? || t.strip.empty?) ? "/tmp" : t
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Per-intent bridge key (intent 131): `plastic-<session>--<intent_id>.json`
|
|
61
|
-
# when intent_id is present, else the legacy single-key
|
|
62
|
-
# `plastic-<session>.json`. The per-intent key is what lets two concurrent
|
|
63
|
-
# deliveries under ONE session id keep separate bridge files instead of
|
|
64
|
-
# clobbering a shared one; the legacy form is still produced (and read) when
|
|
65
|
-
# no intent_id is given, so old single-key files stay valid.
|
|
66
|
-
def self.path(session, intent_id: nil, tmp: tmp_dir)
|
|
67
|
-
if blank?(intent_id)
|
|
68
|
-
"#{tmp}/plastic-#{session}.json"
|
|
69
|
-
else
|
|
70
|
-
"#{tmp}/plastic-#{session}--#{intent_id}.json"
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# --- Session resolution (intent 52) ----------------------------------------
|
|
75
|
-
|
|
76
26
|
def self.blank?(value)
|
|
77
27
|
value.nil? || value.to_s.strip.empty?
|
|
78
28
|
end
|
|
79
29
|
|
|
80
|
-
# Raised by arm when the delivery lock cannot be acquired (held elsewhere,
|
|
81
|
-
# stale, excluded, or corrupt). The message names the resolving command.
|
|
82
|
-
class LockHeldError < StandardError; end
|
|
83
|
-
|
|
84
|
-
# The absolute intent dir a bridge points at, or nil.
|
|
85
|
-
def self.bridge_intent_dir(bridge_data)
|
|
86
|
-
return nil unless bridge_data.is_a?(Hash)
|
|
87
|
-
info = bridge_data["intent"] || {}
|
|
88
|
-
store = info["store"]
|
|
89
|
-
dir = info["dir"]
|
|
90
|
-
(store && dir) ? File.expand_path("#{store}/#{dir}") : nil
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# Bridge-cache copy of the durable lock file's fields (D2: the bridge is a
|
|
94
|
-
# CACHE; the file is the truth). Never carries a pid.
|
|
95
|
-
def self.lock_cache(lock_data)
|
|
96
|
-
{
|
|
97
|
-
"owner_session" => lock_data["owner_session"],
|
|
98
|
-
"acquired_at" => lock_data["acquired_at"],
|
|
99
|
-
"host" => lock_data["host"],
|
|
100
|
-
"type" => lock_data["type"],
|
|
101
|
-
"delegates" => Array(lock_data["delegates"]),
|
|
102
|
-
"owner_harness" => lock_data["owner_harness"],
|
|
103
|
-
"owner_agent" => lock_data["owner_agent"],
|
|
104
|
-
"owner_model" => lock_data["owner_model"],
|
|
105
|
-
"owner_thread" => lock_data["owner_thread"],
|
|
106
|
-
"run_mode" => lock_data["run_mode"],
|
|
107
|
-
"delegate_activity" => Array(lock_data["delegate_activity"]),
|
|
108
|
-
}
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Deterministic, session-id-less bridge key derived from store + intent id.
|
|
112
|
-
# Stable across processes so a session-less arm and a later session-less
|
|
113
|
-
# gate-check resolve to the same bridge file.
|
|
114
|
-
def self.derive_key(store, intent_id)
|
|
115
|
-
"auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# Resolve a bridge session: first non-empty of explicit (the stdin session_id),
|
|
119
|
-
# CLAUDE_CODE_SESSION_ID, then a derived key. Never returns nil/empty.
|
|
120
|
-
# Whitespace-only counts as empty.
|
|
121
|
-
#
|
|
122
|
-
# The CLAUDE_CODE_SESSION_ID fallback (intent 79) carries the bg/headless real
|
|
123
|
-
# session id (Claude Code passes session_id on stdin, not via an env var; the
|
|
124
|
-
# headless id lives in CLAUDE_CODE_SESSION_ID). Keying by the real id (instead of
|
|
125
|
-
# a derived hash) lets the gate hooks, which receive that same id on stdin, find
|
|
126
|
-
# the bridge by direct filename lookup.
|
|
127
|
-
def self.resolve_session(explicit, intent_id:, store:)
|
|
128
|
-
return explicit.to_s.strip unless blank?(explicit)
|
|
129
|
-
code_env = ENV["CLAUDE_CODE_SESSION_ID"]
|
|
130
|
-
return code_env.to_s.strip unless blank?(code_env)
|
|
131
|
-
derive_key(store, intent_id)
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# Walk up from file_path; return the first ancestor that looks like an intent
|
|
135
|
-
# directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
|
|
136
|
-
# target without needing a bridge. The input is always a file inside the intent
|
|
137
|
-
# dir (never the dir itself), so the walk-up starts at its parent.
|
|
138
|
-
def self.intent_dir_for(file_path)
|
|
139
|
-
dir = File.expand_path(file_path)
|
|
140
|
-
loop do
|
|
141
|
-
parent = File.dirname(dir)
|
|
142
|
-
break if parent == dir # reached filesystem root
|
|
143
|
-
dir = parent
|
|
144
|
-
return dir if dir.match?(%r{/store/[^/]+--[^/]+\z})
|
|
145
|
-
end
|
|
146
|
-
nil
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# A bridge hash is usable iff it has a non-empty session and an intent Hash.
|
|
150
|
-
def self.bridge_valid?(data)
|
|
151
|
-
data.is_a?(Hash) && !blank?(data["session"]) && data["intent"].is_a?(Hash)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Tiered cwd discriminator for one bridge candidate (intent 131). A session
|
|
155
|
-
# now owns SEVERAL bridges (one per concurrent intent), so the discriminator
|
|
156
|
-
# that used to be "cwd overlaps intent.store" is too coarse: every sibling
|
|
157
|
-
# under the same store shares it. worktree.code is the only field that
|
|
158
|
-
# differs between siblings, so it is the strongest signal; the intent dir is
|
|
159
|
-
# next; the shared store is a last-resort coarse tie.
|
|
160
|
-
# 2 - cwd is the intent's provisioned code worktree (or under it)
|
|
161
|
-
# 1 - cwd is the intent's own dir (or under it)
|
|
162
|
-
# 0 - cwd merely overlaps the intent's store (shared by every sibling)
|
|
163
|
-
# -1 - no signal at all
|
|
164
|
-
def self.bridge_cwd_tier(data, cwd_abs)
|
|
165
|
-
worktree_code = data.dig("worktree", "code")
|
|
166
|
-
if !blank?(worktree_code)
|
|
167
|
-
wc_abs = File.expand_path(worktree_code)
|
|
168
|
-
return 2 if cwd_abs == wc_abs || cwd_abs.start_with?("#{wc_abs}/")
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
dir_abs = bridge_intent_dir(data)
|
|
172
|
-
if dir_abs
|
|
173
|
-
return 1 if cwd_abs == dir_abs || cwd_abs.start_with?("#{dir_abs}/")
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
store = data.dig("intent", "store").to_s
|
|
177
|
-
unless store.empty?
|
|
178
|
-
store_abs = File.expand_path(store)
|
|
179
|
-
return 0 if cwd_abs == store_abs || cwd_abs.start_with?("#{store_abs}/") ||
|
|
180
|
-
store_abs.start_with?("#{cwd_abs}/")
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
-1
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# The provisioned code worktree dir (<repo>/.claude/worktrees/{id}--{slug})
|
|
187
|
-
# that contains file_path, or nil (intent 168). Structural and bridge-free:
|
|
188
|
-
# Worktree.provision builds exactly this layout, so a file under it is
|
|
189
|
-
# "worktree-scoped" and only that intent may gate it. The {id}--{slug} shape
|
|
190
|
-
# (a `--` in the dir name) is required, so a stray .claude/worktrees/README
|
|
191
|
-
# is not worktree-scoped and returns nil.
|
|
192
|
-
def self.enclosing_worktree_dir(file_path)
|
|
193
|
-
return nil if blank?(file_path)
|
|
194
|
-
m = File.expand_path(file_path).match(%r{\A(.*/\.claude/worktrees/[^/]+--[^/]+)(?:/|\z)})
|
|
195
|
-
m && m[1]
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
# Resolve the active bridge: scan tmp for plastic-*.json (both per-intent and
|
|
199
|
-
# legacy-keyed files), keep only valid bridges, filter to the caller's own
|
|
200
|
-
# session when it has one, prefer auto-armed, then disambiguate by cwd tier
|
|
201
|
-
# (see bridge_cwd_tier), tie-break by newest mtime. No exact-session fast
|
|
202
|
-
# path: a session now legitimately owns several bridges (one per concurrent
|
|
203
|
-
# intent), so filename lookup alone cannot pick the right one; cwd must
|
|
204
|
-
# decide (intent 131).
|
|
205
|
-
def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir, edited_path: nil)
|
|
206
|
-
candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
|
|
207
|
-
parsed = candidates.filter_map do |f|
|
|
208
|
-
data = (JSON.parse(File.read(f)) rescue nil)
|
|
209
|
-
next unless data && bridge_valid?(data)
|
|
210
|
-
{ file: f, data: data, mtime: File.mtime(f) }
|
|
211
|
-
end
|
|
212
|
-
return nil if parsed.empty?
|
|
213
|
-
|
|
214
|
-
# Worktree-membership-first (intent 168). hook-code-gate passes the edited
|
|
215
|
-
# file as edited_path; when that file lies inside a provisioned code worktree
|
|
216
|
-
# (<repo>/.claude/worktrees/{id}--{slug}), only that worktree's owning intent
|
|
217
|
-
# may gate the write, BEFORE the intent-90 per-session filter below. Resolve
|
|
218
|
-
# the candidate whose worktree.code owns that dir (newest mtime on a tie), or
|
|
219
|
-
# nil when none owns it, so a session-keyed guided bridge can never claim a
|
|
220
|
-
# write located inside a sibling intent's worktree. No edited_path (every
|
|
221
|
-
# other caller) or a non-worktree path skips this and runs the pipeline
|
|
222
|
-
# unchanged (intents 90/52/131 preserved).
|
|
223
|
-
unless blank?(edited_path)
|
|
224
|
-
wt_dir = enclosing_worktree_dir(edited_path)
|
|
225
|
-
if wt_dir
|
|
226
|
-
owners = parsed.select do |c|
|
|
227
|
-
code = c[:data].dig("worktree", "code")
|
|
228
|
-
!blank?(code) && File.expand_path(code) == wt_dir
|
|
229
|
-
end
|
|
230
|
-
return owners.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
231
|
-
end
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
has_session = !blank?(session)
|
|
235
|
-
|
|
236
|
-
# Strict per-session ownership (intent 90): when the caller HAS a session, a foreign
|
|
237
|
-
# session's bridge is NEVER a valid resolution. Own-session and the derived-key case both
|
|
238
|
-
# reduce to candidate["session"] == session (the derived key IS the session that armed the
|
|
239
|
-
# bridge). A caller that owns no bridge resolves to nil, so its gates fail open instead of
|
|
240
|
-
# inheriting another session's armed intent.
|
|
241
|
-
#
|
|
242
|
-
# When the caller has NO session (truly headless, intent 52), keep the legacy degraded
|
|
243
|
-
# selection below so a single armed derived-key bridge is still discoverable - the hook
|
|
244
|
-
# cannot know the session there, and a lone armed intent must still gate.
|
|
245
|
-
if has_session
|
|
246
|
-
parsed = parsed.select { |c| c[:data]["session"].to_s == session.to_s }
|
|
247
|
-
return nil if parsed.empty?
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
# Auto-preference pool: a build-armed bridge is preferred over a merely
|
|
251
|
-
# derived one, but ONLY as a fallback when cwd cannot decide (below). cwd
|
|
252
|
-
# must win over auto-preference, so this pool is not applied before the
|
|
253
|
-
# cwd tiering (intent 131: a guided sibling in the caller's own worktree
|
|
254
|
-
# must beat an auto sibling in another worktree).
|
|
255
|
-
auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
|
|
256
|
-
auto_pool = auto.empty? ? parsed : auto
|
|
257
|
-
|
|
258
|
-
unless blank?(cwd)
|
|
259
|
-
cwd_abs = File.expand_path(cwd)
|
|
260
|
-
# Tier the FULL session pool by cwd BEFORE the auto-preference filter.
|
|
261
|
-
# When cwd overlaps ANY candidate (tier >= 0) it decides outright, even
|
|
262
|
-
# against a newer or auto-armed sibling: worktree.code (tier 2) and the
|
|
263
|
-
# intent dir (tier 1) disambiguate same-store siblings (intent 131), and
|
|
264
|
-
# a store overlap (tier 0) still selects the overlapping bridge over an
|
|
265
|
-
# off-cwd one in another store (the intent 90/52 store filter, preserved).
|
|
266
|
-
# Only when NO candidate overlaps cwd (max tier -1) do we fall through to
|
|
267
|
-
# the auto-preference pool and newest mtime, so a lone armed bridge
|
|
268
|
-
# off-cwd still resolves (intent 52 headless).
|
|
269
|
-
tiered = parsed.map { |c| [bridge_cwd_tier(c[:data], cwd_abs), c] }
|
|
270
|
-
max_tier = tiered.map(&:first).max
|
|
271
|
-
if max_tier && max_tier >= 0
|
|
272
|
-
winners = tiered.select { |tier, _| tier == max_tier }.map { |_, c| c }
|
|
273
|
-
return winners.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
274
|
-
end
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
auto_pool.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
278
|
-
end
|
|
279
|
-
|
|
280
30
|
# --- Shared INDEX entry matcher (intent 188, D12/D13) -----------------------
|
|
281
31
|
#
|
|
282
32
|
# ONE definition site for the "- [ID <sep> Title](link)" shape both
|
|
283
33
|
# `intent_active?` (below) and `scripts/end-intent`'s own INDEX-move parser
|
|
284
|
-
# depend on, so the two regexes can never drift apart
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
# Hardening this widens intent_active?'s fail-open case: a hyphen-formatted
|
|
289
|
-
# `## Active` line used to read as not-active (lock gate failed open); it now
|
|
290
|
-
# reads as active (gate correctly blocks). Accepted as a bug fix (D13): no
|
|
291
|
-
# passing test relied on the old fail-open behavior. Every WRITE still emits
|
|
292
|
-
# the real em dash (D10); only what this matcher can PARSE has widened.
|
|
293
|
-
#
|
|
294
|
-
# The separator is built from the codepoint, not a literal byte in this
|
|
295
|
-
# source file, so this new code stays em-dash free (the shipped-file
|
|
296
|
-
# convention; store files like INDEX.md are the exempt surface this matcher
|
|
297
|
-
# READS, not where this constant lives). Matches the existing convention in
|
|
298
|
-
# scripts/end-intent.
|
|
34
|
+
# depend on, so the two regexes can never drift apart. Accepts a real em dash
|
|
35
|
+
# (U+2014) OR a plain hyphen as the id/title separator on READ; every WRITE
|
|
36
|
+
# still emits the real em dash (D10). The separator is built from the
|
|
37
|
+
# codepoint, not a literal byte, so this file stays em-dash free.
|
|
299
38
|
EM_DASH = "\u2014".freeze
|
|
300
39
|
INDEX_ENTRY_RE = /\A- \[(\S+)\s+(?:#{Regexp.escape(EM_DASH)}|-)\s+(.*?)\]\(([^)]+)\)/.freeze
|
|
301
40
|
|
|
@@ -305,15 +44,11 @@ module Bridge
|
|
|
305
44
|
line.to_s.match(INDEX_ENTRY_RE)
|
|
306
45
|
end
|
|
307
46
|
|
|
308
|
-
#
|
|
309
|
-
|
|
310
|
-
#
|
|
311
|
-
#
|
|
312
|
-
#
|
|
313
|
-
# bad arg) returns false, which means "not active" so the caller treats the
|
|
314
|
-
# bridge as purgeable. `index_active_ids` is a pure-data test seam: when an
|
|
315
|
-
# Array of id strings is supplied, membership is checked against it directly
|
|
316
|
-
# with no file read.
|
|
47
|
+
# True iff the intent is Active in its store's INDEX.md, which lives at the
|
|
48
|
+
# PARENT of the store/ dir. Non-raising: any failure (missing or unreadable
|
|
49
|
+
# INDEX, bad arg) returns false. `index_active_ids` is a pure-data test
|
|
50
|
+
# seam: when an Array of id strings is supplied, membership is checked
|
|
51
|
+
# against it directly with no file read.
|
|
317
52
|
def self.intent_active?(intent_id, store:, index_active_ids: nil)
|
|
318
53
|
target = intent_id.to_s
|
|
319
54
|
return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
|
|
@@ -338,535 +73,7 @@ module Bridge
|
|
|
338
73
|
false
|
|
339
74
|
end
|
|
340
75
|
|
|
341
|
-
#
|
|
342
|
-
# discover_bridge's per-fire scan stays bounded. Best-effort and non-raising:
|
|
343
|
-
# returns the array of removed paths. A bridge is purged when it cannot be
|
|
344
|
-
# parsed, has no intent.id, has no intent.store, or its intent is not Active in
|
|
345
|
-
# its store's INDEX.md. An Active intent's bridge is kept (continuation signal +
|
|
346
|
-
# anti-collision lock), and the current session's own bridge is never purged
|
|
347
|
-
# (preserves the disarm_auto contract that it stays readable). Wired into
|
|
348
|
-
# arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
|
|
349
|
-
# clean at deterministic work boundaries.
|
|
350
|
-
def self.purge_done_bridges(session:, tmp: tmp_dir)
|
|
351
|
-
# Own-bridge predicate (intent 131): a session now legitimately owns
|
|
352
|
-
# SEVERAL bridges (one per concurrent intent), so "current" is no longer
|
|
353
|
-
# one filename. Skip the legacy single-key file for this session AND every
|
|
354
|
-
# per-intent-keyed file for this session; none of the session's own live
|
|
355
|
-
# bridges may be reaped mid-run.
|
|
356
|
-
own_legacy_name = File.basename(path(session, tmp: tmp))
|
|
357
|
-
own_prefix = "plastic-#{session}--"
|
|
358
|
-
removed = []
|
|
359
|
-
Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
|
|
360
|
-
next if File.basename(f) == own_legacy_name || File.basename(f).start_with?(own_prefix)
|
|
361
|
-
begin
|
|
362
|
-
data = JSON.parse(File.read(f)) rescue nil
|
|
363
|
-
keep = false
|
|
364
|
-
if data
|
|
365
|
-
id = data.dig("intent", "id")
|
|
366
|
-
store = data.dig("intent", "store")
|
|
367
|
-
keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
|
|
368
|
-
# Never purge a bridge whose intent still holds a delivery lock
|
|
369
|
-
# (intent 108, D6): the End tail clears the lock BEFORE the bridge
|
|
370
|
-
# becomes purge-eligible, so a held lock means the tail is not done.
|
|
371
|
-
unless keep
|
|
372
|
-
dir = bridge_intent_dir(data)
|
|
373
|
-
keep = !dir.nil? && File.exist?(Lock.path(dir))
|
|
374
|
-
end
|
|
375
|
-
end
|
|
376
|
-
next if keep
|
|
377
|
-
File.delete(f)
|
|
378
|
-
removed << f
|
|
379
|
-
rescue Errno::ENOENT
|
|
380
|
-
# Raced with another job that already removed it; count as purged.
|
|
381
|
-
removed << f
|
|
382
|
-
rescue => e
|
|
383
|
-
$stderr.puts "plastic: purge skipped #{f}: #{e.message}"
|
|
384
|
-
end
|
|
385
|
-
end
|
|
386
|
-
removed
|
|
387
|
-
rescue => e
|
|
388
|
-
$stderr.puts "plastic: purge_done_bridges failed: #{e.message}"
|
|
389
|
-
removed || []
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
# Try the per-intent path first; when it is absent and an intent_id was
|
|
393
|
-
# given, fall back to the legacy single-key path (migration + legacy
|
|
394
|
-
# tolerance, intent 131): a live `plastic-<session>.json` from before this
|
|
395
|
-
# intent keeps resolving during the transition. The legacy fallback is
|
|
396
|
-
# honored for a specific intent_id ONLY when the legacy file actually carries
|
|
397
|
-
# that intent (or carries none), so a caller asking for intent A never acts
|
|
398
|
-
# on a legacy file that still holds sibling B.
|
|
399
|
-
def self.read(session, intent_id: nil, tmp: tmp_dir)
|
|
400
|
-
p = path(session, intent_id: intent_id, tmp: tmp)
|
|
401
|
-
return JSON.parse(File.read(p)) if File.exist?(p)
|
|
402
|
-
return nil if blank?(intent_id)
|
|
403
|
-
legacy = path(session, tmp: tmp)
|
|
404
|
-
return nil unless File.exist?(legacy)
|
|
405
|
-
data = JSON.parse(File.read(legacy))
|
|
406
|
-
id = data.is_a?(Hash) ? data.dig("intent", "id") : nil
|
|
407
|
-
(blank?(id) || id.to_s == intent_id.to_s) ? data : nil
|
|
408
|
-
rescue JSON::ParserError
|
|
409
|
-
nil
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
# Self-keying (intent 131): the file `write` targets is derived from
|
|
413
|
-
# `data.dig("intent", "id")`, not a caller-supplied intent_id, so every
|
|
414
|
-
# existing `write(session, data)` call site keys itself correctly for free
|
|
415
|
-
# as long as `data["intent"]["id"]` is set (arm/derive/disarm_auto/
|
|
416
|
-
# repair_lock/hook-gate-check/plastic-lock all carry it).
|
|
417
|
-
def self.write(session, data, tmp: tmp_dir)
|
|
418
|
-
raise ArgumentError, "bridge session must be present" if blank?(session)
|
|
419
|
-
intent_id = data.is_a?(Hash) ? data.dig("intent", "id") : nil
|
|
420
|
-
p = path(session, intent_id: intent_id, tmp: tmp)
|
|
421
|
-
# Atomic write: tmp file + rename to prevent partial reads
|
|
422
|
-
tmp_file = "#{p}.tmp.#{Process.pid}"
|
|
423
|
-
File.write(tmp_file, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
|
|
424
|
-
File.rename(tmp_file, p)
|
|
425
|
-
rescue => e
|
|
426
|
-
File.delete(tmp_file) if tmp_file && File.exist?(tmp_file)
|
|
427
|
-
raise e
|
|
428
|
-
end
|
|
429
|
-
|
|
430
|
-
# True iff a lifecycle file is PRESENT AND REAL: it exists and its first line is
|
|
431
|
-
# not the placeholder sentinel. Reads only the file head (never the whole file)
|
|
432
|
-
# so the dashboard stays fast across many intents. Exact first-line match only,
|
|
433
|
-
# so a real file that merely contains an HTML comment later is unaffected, and a
|
|
434
|
-
# partially-edited sentinel reads as real rather than sticking as a placeholder.
|
|
435
|
-
def self.stage_file_present?(path)
|
|
436
|
-
return false unless File.exist?(path)
|
|
437
|
-
first = File.open(path, &:gets)
|
|
438
|
-
return true if first.nil? # empty file: present, not a sentinel
|
|
439
|
-
first.chomp != PLACEHOLDER_SENTINEL
|
|
440
|
-
rescue StandardError
|
|
441
|
-
File.exist?(path)
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
# True iff actions/ holds AT LEAST ONE real action file: a non-empty *.md whose
|
|
445
|
-
# first line is not the placeholder sentinel. A `.gitkeep` (no .md extension)
|
|
446
|
-
# never counts, an empty *.md never counts, and a sentinel-only *.md never
|
|
447
|
-
# counts. Pure and side-effect-free so the gate stays unit-testable. Fail-open:
|
|
448
|
-
# a missing actions/ dir globs to nothing and returns false (the gate then
|
|
449
|
-
# reports it needs a real action file); it never raises.
|
|
450
|
-
def self.has_real_action?(intent_dir)
|
|
451
|
-
Dir.glob("#{intent_dir}/actions/*.md").any? do |f|
|
|
452
|
-
File.file?(f) && File.size(f) > 0 && stage_file_present?(f)
|
|
453
|
-
end
|
|
454
|
-
rescue StandardError
|
|
455
|
-
false
|
|
456
|
-
end
|
|
457
|
-
|
|
458
|
-
def self.derive_stage(intent_dir)
|
|
459
|
-
return "done" if stage_file_present?("#{intent_dir}/outcome.md")
|
|
460
|
-
if stage_file_present?("#{intent_dir}/plan.md") &&
|
|
461
|
-
has_real_action?(intent_dir) &&
|
|
462
|
-
stage_file_present?("#{intent_dir}/checklist.md")
|
|
463
|
-
return "exec"
|
|
464
|
-
end
|
|
465
|
-
return "how" if stage_file_present?("#{intent_dir}/spec.md")
|
|
466
|
-
return "why" if File.exist?(intent_file(intent_dir))
|
|
467
|
-
"what"
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
def self.has_files(intent_dir)
|
|
471
|
-
files = []
|
|
472
|
-
ifile = File.basename(intent_file(intent_dir))
|
|
473
|
-
files << ifile if File.exist?("#{intent_dir}/#{ifile}")
|
|
474
|
-
["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
475
|
-
files << f if stage_file_present?("#{intent_dir}/#{f}")
|
|
476
|
-
end
|
|
477
|
-
files << "actions/" if has_real_action?(intent_dir)
|
|
478
|
-
files
|
|
479
|
-
end
|
|
480
|
-
|
|
481
|
-
def self.missing_for_stage(stage, intent_dir = nil)
|
|
482
|
-
ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
|
|
483
|
-
case stage
|
|
484
|
-
when "what" then [ifile]
|
|
485
|
-
when "why" then ["spec.md"]
|
|
486
|
-
when "how" then ["plan.md", "actions/", "checklist.md"]
|
|
487
|
-
when "exec" then ["outcome.md"]
|
|
488
|
-
else []
|
|
489
|
-
end
|
|
490
|
-
end
|
|
491
|
-
|
|
492
|
-
# --- Gate-boundary narration (intent 84, Lever 1) -------------------------
|
|
493
|
-
#
|
|
494
|
-
# ONE concise sentence that states what happened AND what's next, preserving
|
|
495
|
-
# the `Next: ...` hint the agent consumes. Pure and side-effect-free so the
|
|
496
|
-
# hook stays a thin caller and the formatter is unit-testable in isolation.
|
|
497
|
-
# No "Stage transition: X -> Y" prose, no arrow; a colon/parentheses carry the
|
|
498
|
-
# stage word. Returns a single line (no embedded newlines).
|
|
499
|
-
STAGE_LABELS = {
|
|
500
|
-
"what" => "What", "why" => "Why", "how" => "How",
|
|
501
|
-
"exec" => "Exec", "done" => "Done"
|
|
502
|
-
}.freeze
|
|
503
|
-
|
|
504
|
-
NEXT_HINTS = {
|
|
505
|
-
"why" => "write spec.md",
|
|
506
|
-
"how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
|
|
507
|
-
"exec" => "How complete. Invoke plastic-auto or plastic-intent-executing to execute, or work through the checklist manually.",
|
|
508
|
-
"done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
|
|
509
|
-
}.freeze
|
|
510
|
-
|
|
511
|
-
def self.stage_label(stage)
|
|
512
|
-
STAGE_LABELS[stage] || stage.to_s
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
# Build the gate-hook `additionalContext` sentence.
|
|
516
|
-
# transition: "PLASTIC: How reached (plan.md written). Next: <hint>"
|
|
517
|
-
# same-stage write: "PLASTIC: plan.md written (How). Next: <hint>"
|
|
518
|
-
# `new_missing` (missing files for the new stage) takes precedence over the
|
|
519
|
-
# stage hint, exactly as before, so the `Next:` content is unchanged.
|
|
520
|
-
def self.gate_narration(old_stage:, new_stage:, basename:, new_missing:, next_hints: NEXT_HINTS)
|
|
521
|
-
head = if old_stage != new_stage
|
|
522
|
-
"PLASTIC: #{stage_label(new_stage)} reached (#{basename} written)."
|
|
523
|
-
else
|
|
524
|
-
"PLASTIC: #{basename} written (#{stage_label(new_stage)})."
|
|
525
|
-
end
|
|
526
|
-
|
|
527
|
-
nxt =
|
|
528
|
-
if Array(new_missing).any?
|
|
529
|
-
"Next: #{Array(new_missing).join(", ")}"
|
|
530
|
-
elsif next_hints[new_stage]
|
|
531
|
-
"Next: #{next_hints[new_stage]}"
|
|
532
|
-
end
|
|
533
|
-
|
|
534
|
-
nxt ? "#{head} #{nxt}" : head
|
|
535
|
-
end
|
|
536
|
-
|
|
537
|
-
# --- Cycle-step savepoint ledger (intent 34) ------------------------------
|
|
538
|
-
#
|
|
539
|
-
# savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
|
|
540
|
-
# (newest at the bottom). It is sugar on top of the conventions: derived from
|
|
541
|
-
# files-on-disk, rebuildable, never a source of truth. Milestones are
|
|
542
|
-
# file-event boundaries only; action/resource files record nothing.
|
|
543
|
-
|
|
544
|
-
SAVEPOINT_FILE = "savepoint.md"
|
|
545
|
-
|
|
546
|
-
# Map a written filename to [stage_label, milestone_text], or nil if the file
|
|
547
|
-
# is not a lifecycle milestone.
|
|
548
|
-
def self.savepoint_milestone(intent_dir, basename)
|
|
549
|
-
return ["What", basename] if basename == File.basename(intent_file(intent_dir))
|
|
550
|
-
|
|
551
|
-
case basename
|
|
552
|
-
when "spec.md" then ["Why", "spec.md created"]
|
|
553
|
-
when "plan.md" then ["How", "plan.md created"]
|
|
554
|
-
when "checklist.md" then ["How", "checklist.md created"]
|
|
555
|
-
when "outcome.md" then ["Exec", "outcome.md created"]
|
|
556
|
-
end
|
|
557
|
-
end
|
|
558
|
-
|
|
559
|
-
# Milestones already recorded in the ledger (field 3 of each line).
|
|
560
|
-
def self.savepoint_recorded_milestones(intent_dir)
|
|
561
|
-
f = File.join(intent_dir, SAVEPOINT_FILE)
|
|
562
|
-
return [] unless File.exist?(f)
|
|
563
|
-
File.read(f).each_line.map do |line|
|
|
564
|
-
parts = line.strip.split(/\s{2,}/)
|
|
565
|
-
parts.length >= 3 ? parts[2] : nil
|
|
566
|
-
end.compact
|
|
567
|
-
end
|
|
568
|
-
|
|
569
|
-
# (stage, milestone) pairs already recorded in the ledger. The pair (not the
|
|
570
|
-
# milestone text alone) is the dedup key, because state-from-ledger lines like
|
|
571
|
-
# `Why started` and `How started` share the milestone text "started" while
|
|
572
|
-
# being distinct events (intent 81).
|
|
573
|
-
def self.savepoint_recorded_pairs(intent_dir)
|
|
574
|
-
f = File.join(intent_dir, SAVEPOINT_FILE)
|
|
575
|
-
return [] unless File.exist?(f)
|
|
576
|
-
File.read(f).each_line.filter_map do |line|
|
|
577
|
-
parts = line.strip.split(/\s{2,}/)
|
|
578
|
-
parts.length >= 3 ? [parts[1], parts[2]] : nil
|
|
579
|
-
end
|
|
580
|
-
end
|
|
581
|
-
|
|
582
|
-
# Append one ledger line for (stage, milestone) unless that pair is already
|
|
583
|
-
# recorded. The single append primitive shared by every line class. Returns
|
|
584
|
-
# true when a line was written, false when it was a no-op.
|
|
585
|
-
def self.append_savepoint_line(intent_dir, stage, milestone, now)
|
|
586
|
-
return false if savepoint_recorded_pairs(intent_dir).include?([stage, milestone])
|
|
587
|
-
line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
|
|
588
|
-
File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
|
|
589
|
-
true
|
|
590
|
-
end
|
|
591
|
-
|
|
592
|
-
# Append the artifact-landing milestone for file_path if (and only if) it is a
|
|
593
|
-
# milestone not already recorded. Returns true when a line was written.
|
|
594
|
-
def self.append_savepoint(intent_dir, file_path, now: Time.now)
|
|
595
|
-
basename = File.basename(file_path)
|
|
596
|
-
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
597
|
-
return false unless milestone
|
|
598
|
-
# A sentinel-marked lifecycle file logs NO milestone (the stage is not real
|
|
599
|
-
# yet). The intent file is never sentineled, so it still logs its What line.
|
|
600
|
-
return false unless stage_file_present?(File.join(intent_dir, basename))
|
|
601
|
-
|
|
602
|
-
append_savepoint_line(intent_dir, stage, milestone, now)
|
|
603
|
-
end
|
|
604
|
-
|
|
605
|
-
# --- State-from-ledger: pre-stage, exec-start, and terminal lines (81) ------
|
|
606
|
-
#
|
|
607
|
-
# On top of intent 34's artifact-landing milestones, the ledger gains:
|
|
608
|
-
# - `started` lines, one per cycle stage entry (pre-stage, written by the
|
|
609
|
-
# PreToolUse savepoint hook the moment a stage's artifact is first written);
|
|
610
|
-
# - an `Exec started` companion emitted when checklist.md lands;
|
|
611
|
-
# - a terminal `Done delivered|abandoned` line written by the completion path.
|
|
612
|
-
# None of these are derivable from files on disk, so they are deliberately NOT
|
|
613
|
-
# part of savepoint_milestone and are never regenerated by rebuild_savepoint:
|
|
614
|
-
# a rebuilt ledger is the file-landing skeleton, the live ledger is richer.
|
|
615
|
-
|
|
616
|
-
# Map a written filename to the [stage, "started"] pre-stage milestone, or nil.
|
|
617
|
-
# spec.md => entering Why, plan.md => entering How. checklist.md/outcome.md do
|
|
618
|
-
# not open a stage (checklist's Exec-start is the append_exec_started companion).
|
|
619
|
-
def self.savepoint_started_milestone(basename)
|
|
620
|
-
case basename
|
|
621
|
-
when "spec.md" then ["Why", "started"]
|
|
622
|
-
when "plan.md" then ["How", "started"]
|
|
623
|
-
end
|
|
624
|
-
end
|
|
625
|
-
|
|
626
|
-
# Append the pre-stage `started` line for file_path, iff: the basename opens a
|
|
627
|
-
# stage, the stage is genuinely starting (its artifact is not yet a REAL file,
|
|
628
|
-
# so a sentinel placeholder still counts as "starting"), and the pair is not
|
|
629
|
-
# already recorded. Returns true when a line was written.
|
|
630
|
-
def self.append_started_savepoint(intent_dir, file_path, now: Time.now)
|
|
631
|
-
basename = File.basename(file_path)
|
|
632
|
-
stage, milestone = savepoint_started_milestone(basename)
|
|
633
|
-
return false unless milestone
|
|
634
|
-
return false if stage_file_present?(File.join(intent_dir, basename))
|
|
635
|
-
|
|
636
|
-
append_savepoint_line(intent_dir, stage, milestone, now)
|
|
637
|
-
end
|
|
638
|
-
|
|
639
|
-
# Append the `Exec started` companion (emitted when checklist.md lands, in the
|
|
640
|
-
# same PostToolUse event as the `How checklist.md created` line). Idempotent.
|
|
641
|
-
def self.append_exec_started(intent_dir, now: Time.now)
|
|
642
|
-
append_savepoint_line(intent_dir, "Exec", "started", now)
|
|
643
|
-
end
|
|
644
|
-
|
|
645
|
-
TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
|
|
646
|
-
|
|
647
|
-
# Append the terminal bookend `Done delivered|abandoned`, written by the
|
|
648
|
-
# completion path when an intent transfers to INDEX's Completed/Abandoned
|
|
649
|
-
# section. Idempotent per disposition. Raises on an unknown disposition.
|
|
650
|
-
def self.append_terminal_savepoint(intent_dir, disposition, now: Time.now)
|
|
651
|
-
unless TERMINAL_DISPOSITIONS.include?(disposition)
|
|
652
|
-
raise ArgumentError,
|
|
653
|
-
"disposition must be one of #{TERMINAL_DISPOSITIONS.join(', ')}, got #{disposition.inspect}"
|
|
654
|
-
end
|
|
655
|
-
|
|
656
|
-
append_savepoint_line(intent_dir, "Done", disposition, now)
|
|
657
|
-
end
|
|
658
|
-
|
|
659
|
-
# --- Tier convenience line (intent 130, D-A) ------------------------------
|
|
660
|
-
#
|
|
661
|
-
# spec.md's top `Tier: S|M|L` line is the single authoritative record of an
|
|
662
|
-
# intent's proportional-auto-sizing tier (see PLASTIC.md `## Tiers`). This
|
|
663
|
-
# reads that line only; it never validates or enforces it (convention-only,
|
|
664
|
-
# matching the skill and agent contracts). Returns nil when spec.md is
|
|
665
|
-
# absent, empty, or its first line does not match, so a missing/malformed
|
|
666
|
-
# Tier line changes nothing about existing rebuild behavior.
|
|
667
|
-
# The grammar itself now lives in SpecHeader (scripts/lib/spec_header.rb, intent 213);
|
|
668
|
-
# this method is a thin read on top of it.
|
|
669
|
-
def self.savepoint_tier(intent_dir)
|
|
670
|
-
SpecHeader.parse_file(File.join(intent_dir, "spec.md"))[:tier]
|
|
671
|
-
end
|
|
672
|
-
|
|
673
|
-
# Reconstruct the ledger from files on disk (timestamps from mtimes), in
|
|
674
|
-
# stage order, overwriting savepoint.md. Returns the number of lines written.
|
|
675
|
-
# When spec.md carries a Tier line, one convenience `Tier <value>` line is
|
|
676
|
-
# echoed right after the spec.md milestone line (same mtime), so the tier
|
|
677
|
-
# survives a rebuild without becoming a new source of truth.
|
|
678
|
-
def self.rebuild_savepoint(intent_dir)
|
|
679
|
-
ordered = [
|
|
680
|
-
File.basename(intent_file(intent_dir)),
|
|
681
|
-
"spec.md", "plan.md", "checklist.md", "outcome.md",
|
|
682
|
-
]
|
|
683
|
-
lines = ordered.flat_map do |basename|
|
|
684
|
-
path = File.join(intent_dir, basename)
|
|
685
|
-
next [] unless stage_file_present?(path)
|
|
686
|
-
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
687
|
-
next [] unless milestone
|
|
688
|
-
stamp = File.mtime(path).utc.iso8601
|
|
689
|
-
entry = "#{stamp} #{stage} #{milestone}\n"
|
|
690
|
-
if basename == "spec.md" && (tier = savepoint_tier(intent_dir))
|
|
691
|
-
[entry, "#{stamp} Tier #{tier}\n"]
|
|
692
|
-
else
|
|
693
|
-
[entry]
|
|
694
|
-
end
|
|
695
|
-
end
|
|
696
|
-
File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
|
|
697
|
-
lines.length
|
|
698
|
-
end
|
|
699
|
-
|
|
700
|
-
# --- Phantom-line detection (intent 134) ------------------------------------
|
|
701
|
-
#
|
|
702
|
-
# A companion to the ledger, not a new writer: pure, disk-only, hermetic (no bridge or
|
|
703
|
-
# session resolution, no writes), matching intent 52's savepoint-decoupling precedent. Under
|
|
704
|
-
# a gate-routing misfire (bug 131) or an out-of-band merge (124a's precedent), a ledger line
|
|
705
|
-
# can go stale or duplicate without the file evidence agreeing. This detects, never repairs;
|
|
706
|
-
# repair is `rebuild_savepoint` (live intents) or the 124a manual Done-bookend recipe
|
|
707
|
-
# (terminal intents, human-granted only).
|
|
708
|
-
|
|
709
|
-
# (stage, milestone) -> basename, for every file-landing milestone this intent_dir could have
|
|
710
|
-
# produced (the intent file plus the four lifecycle artifacts). Reuses savepoint_milestone so
|
|
711
|
-
# the mapping never drifts from the one the writer itself uses.
|
|
712
|
-
def self.savepoint_file_landing_pairs(intent_dir)
|
|
713
|
-
basenames = [File.basename(intent_file(intent_dir)), "spec.md", "plan.md", "checklist.md", "outcome.md"]
|
|
714
|
-
basenames.each_with_object({}) do |basename, map|
|
|
715
|
-
pair = savepoint_milestone(intent_dir, basename)
|
|
716
|
-
map[pair] = basename if pair
|
|
717
|
-
end
|
|
718
|
-
end
|
|
719
|
-
|
|
720
|
-
# A `started` state line's real prerequisite is the PRECEDING stage's artifact, not its own
|
|
721
|
-
# (a `started` line legitimately fires before its own stage's file is real by design). `Exec
|
|
722
|
-
# started` additionally requires plan.md, since Exec cannot start before How produced it too.
|
|
723
|
-
SAVEPOINT_STATE_PREREQUISITES = {
|
|
724
|
-
["How", "started"] => ["spec.md"],
|
|
725
|
-
["Exec", "started"] => ["plan.md", "checklist.md"],
|
|
726
|
-
}.freeze
|
|
727
|
-
|
|
728
|
-
# Raw (stripped) ledger lines whose disk evidence contradicts them, each paired with a short
|
|
729
|
-
# reason: [line, reason]. Three phantom classes (D5):
|
|
730
|
-
# - a file-landing milestone whose file is absent or still a sentinel placeholder;
|
|
731
|
-
# - a duplicate (stage, milestone) pair (the later occurrence is the phantom);
|
|
732
|
-
# - a state line (`How started` / `Exec started`) whose stage prerequisites are absent.
|
|
733
|
-
# A clean ledger, or an absent one, returns [].
|
|
734
|
-
def self.savepoint_phantom_lines(intent_dir)
|
|
735
|
-
path = File.join(intent_dir, SAVEPOINT_FILE)
|
|
736
|
-
return [] unless File.exist?(path)
|
|
737
|
-
|
|
738
|
-
landing = savepoint_file_landing_pairs(intent_dir)
|
|
739
|
-
seen = []
|
|
740
|
-
phantoms = []
|
|
741
|
-
|
|
742
|
-
File.read(path).each_line do |raw|
|
|
743
|
-
line = raw.strip
|
|
744
|
-
next if line.empty?
|
|
745
|
-
parts = line.split(/\s{2,}/)
|
|
746
|
-
next if parts.length < 3
|
|
747
|
-
pair = [parts[1], parts[2]]
|
|
748
|
-
|
|
749
|
-
if seen.include?(pair)
|
|
750
|
-
phantoms << [line, "duplicate (stage, milestone) pair"]
|
|
751
|
-
next
|
|
752
|
-
end
|
|
753
|
-
seen << pair
|
|
754
|
-
|
|
755
|
-
if (basename = landing[pair]) && !stage_file_present?(File.join(intent_dir, basename))
|
|
756
|
-
phantoms << [line, "milestone file absent or still a sentinel placeholder"]
|
|
757
|
-
next
|
|
758
|
-
end
|
|
759
|
-
|
|
760
|
-
prereqs = SAVEPOINT_STATE_PREREQUISITES[pair]
|
|
761
|
-
if prereqs && prereqs.any? { |b| !stage_file_present?(File.join(intent_dir, b)) }
|
|
762
|
-
phantoms << [line, "state line prerequisite absent on disk"]
|
|
763
|
-
end
|
|
764
|
-
end
|
|
765
|
-
|
|
766
|
-
phantoms
|
|
767
|
-
end
|
|
768
|
-
|
|
769
|
-
# Pure compute (intent 230): build the bridge state and write NOTHING. `derive`
|
|
770
|
-
# is the writing wrapper over this; `arm` and `repair_lock` use the pure form so
|
|
771
|
-
# the single bridge write happens only after the delivery lock and the worktree
|
|
772
|
-
# have both settled. Joins the pure `derive_stage` / `derive_key` family.
|
|
773
|
-
def self.derive_data(session, intent_id:, intent_dir:, store:, name:)
|
|
774
|
-
stage = derive_stage(intent_dir)
|
|
775
|
-
has = has_files(intent_dir)
|
|
776
|
-
missing = missing_for_stage(stage, intent_dir) - has
|
|
777
|
-
|
|
778
|
-
{
|
|
779
|
-
"session" => session,
|
|
780
|
-
"intent" => {
|
|
781
|
-
"id" => intent_id,
|
|
782
|
-
"dir" => intent_dir.sub("#{store}/", ""),
|
|
783
|
-
"store" => store,
|
|
784
|
-
"name" => name
|
|
785
|
-
},
|
|
786
|
-
"build" => {
|
|
787
|
-
"stage" => stage,
|
|
788
|
-
"has" => has,
|
|
789
|
-
"missing" => missing,
|
|
790
|
-
"gate_failures" => 0,
|
|
791
|
-
"auto" => false,
|
|
792
|
-
"last_activity" => Time.now.utc.iso8601
|
|
793
|
-
},
|
|
794
|
-
"observe" => {
|
|
795
|
-
"last_transition" => nil,
|
|
796
|
-
"insights_count" => 0,
|
|
797
|
-
"chain_spawned" => []
|
|
798
|
-
},
|
|
799
|
-
"tokens" => {
|
|
800
|
-
"context_pct" => 0,
|
|
801
|
-
"warning_at" => 80,
|
|
802
|
-
"critical_at" => 90
|
|
803
|
-
},
|
|
804
|
-
# Worktree isolation block (intent 73c; store-worktree half retired by
|
|
805
|
-
# intent 178). Born unprovisioned; arm_auto calls Worktree.provision to
|
|
806
|
-
# fill it. "code" is an abs path or null.
|
|
807
|
-
"worktree" => {
|
|
808
|
-
"code" => nil,
|
|
809
|
-
"code_branch" => nil,
|
|
810
|
-
"provisioned" => false
|
|
811
|
-
},
|
|
812
|
-
# Delivery-lock CACHE block (intent 108, D2). The durable truth is the
|
|
813
|
-
# delivery.lock file in the intent dir; arm fills this cache from it.
|
|
814
|
-
"lock" => {
|
|
815
|
-
"owner_session" => nil,
|
|
816
|
-
"acquired_at" => nil,
|
|
817
|
-
"host" => nil,
|
|
818
|
-
"type" => nil,
|
|
819
|
-
"delegates" => []
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
end
|
|
823
|
-
|
|
824
|
-
# Compute AND persist. Contract unchanged (intent 230 kept it deliberately):
|
|
825
|
-
# `scripts/hook-session-start` calls this and wants the immediate write, and
|
|
826
|
-
# test/bridge_worktree_derive_test.rb pins write-on-call.
|
|
827
|
-
def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
|
|
828
|
-
data = derive_data(session, intent_id: intent_id, intent_dir: intent_dir,
|
|
829
|
-
store: store, name: name)
|
|
830
|
-
write(session, data, tmp: tmp)
|
|
831
|
-
data
|
|
832
|
-
end
|
|
833
|
-
|
|
834
|
-
# Gate check: returns nil if allowed, or an error message string if blocked
|
|
835
|
-
def self.check_gate(intent_dir, file_being_written)
|
|
836
|
-
basename = File.basename(file_being_written)
|
|
837
|
-
|
|
838
|
-
case basename
|
|
839
|
-
when "spec.md"
|
|
840
|
-
ifile = intent_file(intent_dir)
|
|
841
|
-
unless File.exist?(ifile) && File.read(ifile).include?("## Intent")
|
|
842
|
-
return "Cannot start Why — What is incomplete (#{File.basename(ifile)} missing or no ## Intent)"
|
|
843
|
-
end
|
|
844
|
-
when "plan.md"
|
|
845
|
-
unless stage_file_present?("#{intent_dir}/spec.md")
|
|
846
|
-
return "Cannot start How — Why is incomplete (spec.md missing)"
|
|
847
|
-
end
|
|
848
|
-
when "checklist.md"
|
|
849
|
-
unless stage_file_present?("#{intent_dir}/plan.md")
|
|
850
|
-
return "Cannot complete How — plan.md missing"
|
|
851
|
-
end
|
|
852
|
-
unless has_real_action?(intent_dir)
|
|
853
|
-
return "Cannot complete How — actions/ has no real action file (only .gitkeep or empty). " \
|
|
854
|
-
"The planner must write at least one actions/ACTION_N.md before checklist.md. " \
|
|
855
|
-
"See skills/intent-planning."
|
|
856
|
-
end
|
|
857
|
-
when "outcome.md"
|
|
858
|
-
checklist = "#{intent_dir}/checklist.md"
|
|
859
|
-
if stage_file_present?(checklist)
|
|
860
|
-
content = File.read(checklist)
|
|
861
|
-
unchecked = content.scan(/^- \[ \]/).length
|
|
862
|
-
if unchecked > 0
|
|
863
|
-
return "Cannot complete Exec — #{unchecked} unchecked items in checklist.md"
|
|
864
|
-
end
|
|
865
|
-
end
|
|
866
|
-
end
|
|
867
|
-
|
|
868
|
-
nil # no gate violation
|
|
869
|
-
end
|
|
76
|
+
# --- Project config -------------------------------------------------------------
|
|
870
77
|
|
|
871
78
|
PROJECT_CONFIG_DEFAULTS = {
|
|
872
79
|
"governing_docs" => ["AGENTS.md"],
|
|
@@ -889,792 +96,12 @@ module Bridge
|
|
|
889
96
|
PROJECT_CONFIG_DEFAULTS.dup
|
|
890
97
|
end
|
|
891
98
|
|
|
892
|
-
# --- Auto mode (intent 27) ---
|
|
893
|
-
|
|
894
|
-
# Intent 230: freshly composed state carries worktree.code = nil, so a failed
|
|
895
|
-
# Worktree.provision would have nothing to keep. Seed the block from the bridge
|
|
896
|
-
# already on disk when it names a code path, so provision's keep-rule (see
|
|
897
|
-
# Worktree.provision) can preserve it. Provision SUCCESS overwrites this with
|
|
898
|
-
# the freshly resolved (identical) pointer, so this only matters on failure.
|
|
899
|
-
def self.carry_prior_worktree(data, session, tmp: tmp_dir)
|
|
900
|
-
prior = read(session, intent_id: data.dig("intent", "id"), tmp: tmp)
|
|
901
|
-
block = prior && prior["worktree"]
|
|
902
|
-
data["worktree"] = block if block.is_a?(Hash) && !blank?(block["code"])
|
|
903
|
-
data
|
|
904
|
-
end
|
|
905
|
-
private_class_method :carry_prior_worktree
|
|
906
|
-
|
|
907
|
-
# Shared arming spine (intent 96): resolve the session key, derive intent state,
|
|
908
|
-
# set the caller-controlled auto flag, acquire the delivery lock, provision the
|
|
909
|
-
# per-intent worktrees, persist, and purge terminal bridges. arm_auto (auto: true)
|
|
910
|
-
# and arm_guided (auto: false) are thin delegators so the lock-stamp + provision
|
|
911
|
-
# behaviour stays identical across both modes. Works even when no bridge exists
|
|
912
|
-
# yet (mid-session intent creation).
|
|
913
|
-
def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:, harness: nil,
|
|
914
|
-
agent: nil, model: nil, thread: nil)
|
|
915
|
-
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
916
|
-
if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
|
|
917
|
-
$stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
|
|
918
|
-
end
|
|
919
|
-
# Compute only (intent 230). Nothing reaches disk until the lock is ours and
|
|
920
|
-
# the worktree has settled; a LockHeldError below must leave the previous
|
|
921
|
-
# bridge exactly as it was.
|
|
922
|
-
data = derive_data(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
|
|
923
|
-
data["build"]["auto"] = auto
|
|
924
|
-
|
|
925
|
-
# Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
|
|
926
|
-
# intent dir. The bridge lock block is a cache of the file.
|
|
927
|
-
intent_dir_abs = File.expand_path(intent_dir)
|
|
928
|
-
status, lock_data = Lock.acquire(intent_dir_abs, session: key,
|
|
929
|
-
harness: harness, agent: agent,
|
|
930
|
-
model: model, thread: thread,
|
|
931
|
-
run_mode: auto ? "auto" : "guided")
|
|
932
|
-
case status
|
|
933
|
-
when :acquired, :owned
|
|
934
|
-
data["lock"] = lock_cache(lock_data)
|
|
935
|
-
when :held
|
|
936
|
-
raise LockHeldError, "delivery lock for intent #{intent_id} is held by " \
|
|
937
|
-
"session #{lock_data && lock_data['owner_session']}; run " \
|
|
938
|
-
"#{skill_ref('plastic-doctor', harness: harness)} check the lock status"
|
|
939
|
-
when :stale
|
|
940
|
-
raise LockHeldError, "delivery lock for intent #{intent_id} is stale " \
|
|
941
|
-
"(owner #{lock_data && lock_data['owner_session']}); run " \
|
|
942
|
-
"#{skill_ref('plastic-doctor', harness: harness)} reclaim the lock to take it " \
|
|
943
|
-
"over with an audit"
|
|
944
|
-
when :excluded
|
|
945
|
-
raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
|
|
946
|
-
"intent #{intent_id}; run #{skill_ref('plastic-doctor', harness: harness)} check " \
|
|
947
|
-
"the lock status"
|
|
948
|
-
when :corrupt
|
|
949
|
-
raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
|
|
950
|
-
"run #{skill_ref('plastic-doctor', harness: harness)} fix the lock"
|
|
951
|
-
end
|
|
952
|
-
|
|
953
|
-
carry_prior_worktree(data, key)
|
|
954
|
-
|
|
955
|
-
# Provision the per-intent worktrees (mandatory code worktree for project
|
|
956
|
-
# intents; fail-open for non-git / global-only). Never let a provision error
|
|
957
|
-
# break arming: the lock and auto flag still matter.
|
|
958
|
-
begin
|
|
959
|
-
Worktree.provision(data)
|
|
960
|
-
rescue => e
|
|
961
|
-
$stderr.puts "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
|
|
962
|
-
end
|
|
963
|
-
|
|
964
|
-
write(key, data)
|
|
965
|
-
purge_done_bridges(session: key)
|
|
966
|
-
data
|
|
967
|
-
end
|
|
968
|
-
private_class_method :arm
|
|
969
|
-
|
|
970
|
-
# Arm auto mode for a session+intent. Works even when no bridge exists yet
|
|
971
|
-
# (mid-session intent creation). Re-derives intent state, then sets build.auto.
|
|
972
|
-
def self.arm_auto(session, intent_id:, intent_dir:, store:, name:, harness: nil,
|
|
973
|
-
agent: nil, model: nil, thread: nil)
|
|
974
|
-
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name,
|
|
975
|
-
auto: true, harness: harness, agent: agent, model: model, thread: thread)
|
|
976
|
-
end
|
|
977
|
-
|
|
978
|
-
# Acquire the delivery lock WITHOUT auto mode (intent 96 / Start guided branch).
|
|
979
|
-
# Mirrors arm_auto's lock-stamp + worktree provision but leaves build.auto = false.
|
|
980
|
-
# Same signature as arm_auto; disarm_auto (mode-agnostic) releases a guided lock.
|
|
981
|
-
def self.arm_guided(session, intent_id:, intent_dir:, store:, name:, harness: nil,
|
|
982
|
-
agent: nil, model: nil, thread: nil)
|
|
983
|
-
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name,
|
|
984
|
-
auto: false, harness: harness, agent: agent, model: model, thread: thread)
|
|
985
|
-
end
|
|
986
|
-
|
|
987
|
-
# Degrade path for disarm_auto when no intent_id is given (intent 131): the
|
|
988
|
-
# session's sole per-intent bridge when there is exactly one, else the
|
|
989
|
-
# legacy single-key file. Keeps the common single-intent auto path working
|
|
990
|
-
# without every caller having to name the intent id explicitly. With TWO or
|
|
991
|
-
# more per-intent bridges it refuses to guess (intent 233); see below.
|
|
992
|
-
def self.sole_bridge_data(session, tmp: tmp_dir)
|
|
993
|
-
matches = Dir.glob(File.join(tmp, "plastic-#{session}--*.json")).reject { |f| f.end_with?(".tmp") }
|
|
994
|
-
if matches.length == 1
|
|
995
|
-
data = (JSON.parse(File.read(matches.first)) rescue nil)
|
|
996
|
-
return data if data
|
|
997
|
-
end
|
|
998
|
-
# Refuse to guess among siblings (intent 233): with 2+ per-intent bridges
|
|
999
|
-
# the legacy single-key file below can carry EITHER sibling, so falling
|
|
1000
|
-
# through would let a no-id disarm release the wrong intent's lock. A
|
|
1001
|
-
# disarm that does nothing is recoverable; one that unlocks a live
|
|
1002
|
-
# delivery is not. Zero matches keeps the legacy fallback (131 migration).
|
|
1003
|
-
if matches.length > 1
|
|
1004
|
-
$stderr.puts "plastic: session #{session} has #{matches.length} bridges; " \
|
|
1005
|
-
"disarm needs an explicit intent_id (refusing to guess)"
|
|
1006
|
-
return nil
|
|
1007
|
-
end
|
|
1008
|
-
read(session, tmp: tmp)
|
|
1009
|
-
end
|
|
1010
|
-
|
|
1011
|
-
# Disarm. No-op if no bridge exists for the session. End-tail order (D6):
|
|
1012
|
-
# worktrees are merged/removed FIRST (the verify step is the caller's,
|
|
1013
|
-
# before disarm), then the delivery lock is cleared, and only then does the
|
|
1014
|
-
# bridge become purge-eligible. purge_done_bridges enforces the same order
|
|
1015
|
-
# defensively by skipping any bridge whose intent still holds a lock.
|
|
1016
|
-
#
|
|
1017
|
-
# Now takes intent_id (intent 131): a session can own SEVERAL live bridges
|
|
1018
|
-
# (one per concurrent intent), so disarm must target ONE of them. When
|
|
1019
|
-
# intent_id is nil, degrades to the session's sole bridge (see
|
|
1020
|
-
# sole_bridge_data) so the common single-intent path keeps working.
|
|
1021
|
-
#
|
|
1022
|
-
# The lock clear is conditional (intent 233): a failed release leaves the
|
|
1023
|
-
# cached lock fields alone so the orphan stays findable.
|
|
1024
|
-
def self.disarm_auto(session, intent_id: nil)
|
|
1025
|
-
data = blank?(intent_id) ? sole_bridge_data(session) : read(session, intent_id: intent_id)
|
|
1026
|
-
return nil unless data
|
|
1027
|
-
data["build"] ||= {}
|
|
1028
|
-
data["build"]["auto"] = false
|
|
1029
|
-
|
|
1030
|
-
# Release the worktrees the matching arm provisioned (intent 73c). Non-fatal:
|
|
1031
|
-
# a release error must not block disarming. CLEANUP (73c3) refines the
|
|
1032
|
-
# merge-vs-remove policy on the completion/release path.
|
|
1033
|
-
begin
|
|
1034
|
-
Worktree.release(data)
|
|
1035
|
-
rescue => e
|
|
1036
|
-
$stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
|
|
1037
|
-
end
|
|
1038
|
-
|
|
1039
|
-
# Check what the release actually DID before touching the cache (intent
|
|
1040
|
-
# 233). Blanking the cache after a failed release orphans the durable
|
|
1041
|
-
# delivery.lock: the file stays on disk and nothing points at it any more.
|
|
1042
|
-
# Rescue mirrors the Worktree.release rescue above: warn, never raise,
|
|
1043
|
-
# never abort the rest of the tail (the guard fails milder than the bug).
|
|
1044
|
-
dir = bridge_intent_dir(data)
|
|
1045
|
-
release_status = nil
|
|
1046
|
-
if dir
|
|
1047
|
-
owner = data.dig("lock", "owner_session")
|
|
1048
|
-
owner = session if blank?(owner)
|
|
1049
|
-
begin
|
|
1050
|
-
release_status = Lock.release(dir, session: owner)
|
|
1051
|
-
rescue => e
|
|
1052
|
-
release_status = :raised
|
|
1053
|
-
$stderr.puts "plastic: delivery lock release raised for #{dir}, continuing: #{e.message}"
|
|
1054
|
-
end
|
|
1055
|
-
end
|
|
1056
|
-
|
|
1057
|
-
# Success means "no lock left on disk": :released (we deleted it), :none
|
|
1058
|
-
# (there was none), and nil (no intent dir, so no release was attempted).
|
|
1059
|
-
# Only :not_owner and a raised release keep the cache pointing at the lock
|
|
1060
|
-
# so plastic-lock fix / reclaim / doctor can still find and repair it.
|
|
1061
|
-
if release_status.nil? || release_status == :released || release_status == :none
|
|
1062
|
-
data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
|
|
1063
|
-
"host" => nil, "type" => nil, "delegates" => [] }
|
|
1064
|
-
else
|
|
1065
|
-
$stderr.puts "plastic: delivery lock NOT released for #{dir} (#{release_status}); " \
|
|
1066
|
-
"bridge lock cache preserved for repair"
|
|
1067
|
-
data["lock"] = {} unless data["lock"].is_a?(Hash)
|
|
1068
|
-
end
|
|
1069
|
-
# Diagnostic only (D5): the primary contract stays "owner_session non-nil
|
|
1070
|
-
# after disarm means the lock was not released".
|
|
1071
|
-
data["lock"]["release_status"] = release_status.nil? ? nil : release_status.to_s
|
|
1072
|
-
|
|
1073
|
-
write(session, data)
|
|
1074
|
-
purge_done_bridges(session: session)
|
|
1075
|
-
data
|
|
1076
|
-
end
|
|
1077
|
-
|
|
1078
|
-
# One deterministic, idempotent repair (intent 108, D5): diagnose, remove
|
|
1079
|
-
# faulty own-side state, rebuild the durable lock AND the bridge cache from
|
|
1080
|
-
# disk truth for the current session. Legacy /tmp-only pid locks are
|
|
1081
|
-
# migrated here: the delivery.lock file is created and the cache rebuilt
|
|
1082
|
-
# without a pid. NEVER touches a fresh foreign lock (reports "held"); a
|
|
1083
|
-
# stale foreign lock reports "stale" and is taken only by the explicit
|
|
1084
|
-
# reclaim verb (Lock.takeover). Two entry points call this: the
|
|
1085
|
-
# plastic-lock CLI and /plastic-intent-starting (self-healing boarding).
|
|
1086
|
-
def self.repair_lock(session, intent_id:, intent_dir:, store:, name:,
|
|
1087
|
-
now: Time.now, tmp: tmp_dir, harness: nil,
|
|
1088
|
-
agent: nil, model: nil, thread: nil, run_mode: nil,
|
|
1089
|
-
hint_harness: nil)
|
|
1090
|
-
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
1091
|
-
dir = File.expand_path(intent_dir)
|
|
1092
|
-
actions = []
|
|
1093
|
-
previous = read(key, intent_id: intent_id, tmp: tmp)
|
|
1094
|
-
auto = !!(previous && previous.dig("build", "auto"))
|
|
1095
|
-
derived_mode = if previous && previous.dig("build").is_a?(Hash) &&
|
|
1096
|
-
previous["build"].key?("auto")
|
|
1097
|
-
auto ? "auto" : "guided"
|
|
1098
|
-
end
|
|
1099
|
-
identity = { harness: harness, agent: agent, model: model, thread: thread,
|
|
1100
|
-
run_mode: blank?(run_mode) ? derived_mode : run_mode.to_s }
|
|
1101
|
-
|
|
1102
|
-
if Lock.corrupt?(dir)
|
|
1103
|
-
File.delete(Lock.path(dir))
|
|
1104
|
-
actions << "removed corrupt delivery.lock"
|
|
1105
|
-
end
|
|
1106
|
-
|
|
1107
|
-
lock = Lock.read(dir)
|
|
1108
|
-
if lock && !Lock.authorized?(lock, key)
|
|
1109
|
-
if Lock.fresh?(dir, now: now)
|
|
1110
|
-
return { "status" => "held", "owner" => lock["owner_session"],
|
|
1111
|
-
"actions" => actions, "session" => key }
|
|
1112
|
-
end
|
|
1113
|
-
return { "status" => "stale", "owner" => lock["owner_session"],
|
|
1114
|
-
"actions" => actions, "session" => key,
|
|
1115
|
-
"hint" => "run #{skill_ref('plastic-doctor', harness: hint_harness || harness)} reclaim the " \
|
|
1116
|
-
"lock to take over with an audit" }
|
|
1117
|
-
end
|
|
1118
|
-
|
|
1119
|
-
if lock
|
|
1120
|
-
if lock["owner_session"].to_s == key.to_s
|
|
1121
|
-
lock_data = lock.dup
|
|
1122
|
-
{ "owner_harness" => harness, "owner_agent" => agent,
|
|
1123
|
-
"owner_model" => model, "owner_thread" => thread,
|
|
1124
|
-
"run_mode" => identity[:run_mode] }.each do |field, value|
|
|
1125
|
-
lock_data[field] = value.to_s unless blank?(value)
|
|
1126
|
-
end
|
|
1127
|
-
Lock.write(dir, lock_data)
|
|
1128
|
-
Lock.heartbeat(dir, session: key, now: now)
|
|
1129
|
-
else
|
|
1130
|
-
Lock.heartbeat(dir, session: key, now: now)
|
|
1131
|
-
lock_data = Lock.read(dir)
|
|
1132
|
-
end
|
|
1133
|
-
role = lock_data["owner_session"].to_s == key ? "owner" : "delegate"
|
|
1134
|
-
actions << "lock kept (#{role})"
|
|
1135
|
-
else
|
|
1136
|
-
status, lock_data = Lock.acquire(dir, session: key, now: now, **identity)
|
|
1137
|
-
actions << "lock #{status}"
|
|
1138
|
-
end
|
|
1139
|
-
|
|
1140
|
-
data = derive_data(key, intent_id: intent_id, intent_dir: dir, store: store,
|
|
1141
|
-
name: name)
|
|
1142
|
-
data["build"]["auto"] = auto
|
|
1143
|
-
data["lock"] = lock_cache(lock_data)
|
|
1144
|
-
|
|
1145
|
-
carry_prior_worktree(data, key, tmp: tmp)
|
|
1146
|
-
|
|
1147
|
-
# Provision the per-intent worktrees so the rebuilt bridge carries
|
|
1148
|
-
# worktree.code (intent 136). Without it, cwd/edited-path selection has no
|
|
1149
|
-
# key: the repaired intent loses its own code gate and a concurrent sibling
|
|
1150
|
-
# wins the tie-break. Idempotent (reuse dir / reattach branch) and fail-open
|
|
1151
|
-
# for non-git / global-only, exactly as `arm` does; never break the repair.
|
|
1152
|
-
begin
|
|
1153
|
-
Worktree.provision(data)
|
|
1154
|
-
rescue => e
|
|
1155
|
-
$stderr.puts "plastic: worktree provision raised during repair, continuing unprovisioned: #{e.message}"
|
|
1156
|
-
end
|
|
1157
|
-
|
|
1158
|
-
write(key, data, tmp: tmp)
|
|
1159
|
-
actions << "bridge rebuilt from disk (stage #{data['build']['stage']})"
|
|
1160
|
-
|
|
1161
|
-
{ "status" => "repaired", "actions" => actions,
|
|
1162
|
-
"lock" => lock_data, "session" => key }
|
|
1163
|
-
end
|
|
1164
|
-
|
|
1165
|
-
# Decide whether a code edit should be blocked while auto mode is armed.
|
|
1166
|
-
# Returns a reason string to BLOCK, or nil to ALLOW.
|
|
1167
|
-
#
|
|
1168
|
-
# Blocks iff: auto armed AND intent hasn't reached How (stage what/why) AND the
|
|
1169
|
-
# target is project code — i.e. NOT under ~/.plastic and NOT inside the intent dir.
|
|
1170
|
-
def self.code_gate_decision(bridge_data, file_path, home: Dir.home)
|
|
1171
|
-
return nil unless bridge_data.is_a?(Hash)
|
|
1172
|
-
build = bridge_data["build"] || {}
|
|
1173
|
-
return nil unless build["auto"] == true
|
|
1174
|
-
|
|
1175
|
-
intent_info = bridge_data["intent"] || {}
|
|
1176
|
-
store = intent_info["store"]
|
|
1177
|
-
dir = intent_info["dir"]
|
|
1178
|
-
return nil unless store && dir
|
|
1179
|
-
intent_dir_abs = File.expand_path("#{store}/#{dir}")
|
|
1180
|
-
|
|
1181
|
-
# "How reached" = plan.md + checklist.md are both present AND actions/ holds at
|
|
1182
|
-
# least one real action file. Gate by artifact presence, not the stage label
|
|
1183
|
-
# (derive_stage returns "how" as soon as spec.md exists, before any plan). Code
|
|
1184
|
-
# edits stay blocked until the planner has written a real action file, so an
|
|
1185
|
-
# empty or .gitkeep-only actions/ never opens the code gate.
|
|
1186
|
-
reached_how = stage_file_present?("#{intent_dir_abs}/plan.md") &&
|
|
1187
|
-
stage_file_present?("#{intent_dir_abs}/checklist.md") &&
|
|
1188
|
-
has_real_action?(intent_dir_abs)
|
|
1189
|
-
return nil if reached_how
|
|
1190
|
-
|
|
1191
|
-
file_abs = File.expand_path(file_path.to_s)
|
|
1192
|
-
plastic_home = File.expand_path(File.join(home, ".plastic"))
|
|
1193
|
-
return nil if file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
|
|
1194
|
-
return nil if file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/")
|
|
1195
|
-
|
|
1196
|
-
id = intent_info["id"]
|
|
1197
|
-
"intent #{id} has not reached How — write plan.md + checklist.md and at least " \
|
|
1198
|
-
"one real actions/ACTION_N.md before editing project code. Run plastic-auto or " \
|
|
1199
|
-
"plastic-intent-planning first. (blocked edit: #{file_abs})"
|
|
1200
|
-
end
|
|
1201
|
-
|
|
1202
|
-
# --- Solo-mode detection (intent 128) ---------------------------------------
|
|
1203
|
-
#
|
|
1204
|
-
# Positive-only confirmation that exactly one session is delivering, from the
|
|
1205
|
-
# durable delivery.lock files (never the /tmp bridge cache, D2). Used to relax
|
|
1206
|
-
# the two ARBITRATION gates (lock_gate_decision, worktree_gate_decision) from
|
|
1207
|
-
# a hard deny to an advisory allow when there is nothing to arbitrate.
|
|
1208
|
-
#
|
|
1209
|
-
# SOLO iff exactly ONE fresh delivery.lock exists across scan_roots, that
|
|
1210
|
-
# lock's owner_session equals the resolved session, and its delegates array
|
|
1211
|
-
# is empty. Any ambiguity (more than one fresh lock, including several under
|
|
1212
|
-
# the SAME owner_session, which reads as parallel-in-play), a foreign owner,
|
|
1213
|
-
# a non-empty delegates array, a blank/unresolvable session, or any error
|
|
1214
|
-
# during the scan all return false (fail-closed direction preserved).
|
|
1215
|
-
def self.solo_delivery?(scan_roots:, session:, ttl: Lock::TTL_SECONDS, now: Time.now)
|
|
1216
|
-
return false if blank?(session)
|
|
1217
|
-
|
|
1218
|
-
lock_dirs = Array(scan_roots).compact.flat_map { |root|
|
|
1219
|
-
Dir.glob(File.join(File.expand_path(root), "*", "delivery.lock"))
|
|
1220
|
-
}.uniq.map { |lock_file| File.dirname(lock_file) }
|
|
1221
|
-
|
|
1222
|
-
fresh_dirs = lock_dirs.select { |dir| Lock.fresh?(dir, ttl: ttl, now: now) }
|
|
1223
|
-
fresh_locks = fresh_dirs.map { |dir| Lock.read(dir) }
|
|
1224
|
-
|
|
1225
|
-
# A fresh-but-unreadable (corrupt) lock is real ambiguity, not an absence:
|
|
1226
|
-
# dropping it via filter_map could leave exactly one READABLE lock and
|
|
1227
|
-
# misconfirm solo while a second, unreadable-but-live lock is in play.
|
|
1228
|
-
# Any unreadable fresh lock keeps this fail-closed (review finding 2).
|
|
1229
|
-
return false if fresh_locks.any?(&:nil?)
|
|
1230
|
-
return false unless fresh_locks.length == 1
|
|
1231
|
-
|
|
1232
|
-
lock = fresh_locks.first
|
|
1233
|
-
lock["owner_session"].to_s == session.to_s && Array(lock["delegates"]).empty?
|
|
1234
|
-
rescue StandardError
|
|
1235
|
-
false
|
|
1236
|
-
end
|
|
1237
|
-
|
|
1238
|
-
# One terse advisory line (no em-dashes), then ALLOW (nil). Shared by both
|
|
1239
|
-
# arbitration gates so a relaxed deny always logs the same shape.
|
|
1240
|
-
def self.solo_allow(id, reason)
|
|
1241
|
-
$stderr.puts "plastic: solo delivery confirmed for intent #{id} (#{reason}); allowing"
|
|
1242
|
-
nil
|
|
1243
|
-
end
|
|
1244
|
-
|
|
1245
|
-
# --- Fail-closed lock gate (intent 96) -------------------------------------
|
|
1246
|
-
|
|
1247
|
-
# Returns a reason String to BLOCK, or nil to ALLOW. Decides from the
|
|
1248
|
-
# durable delivery.lock in the TARGET intent dir (D2): the bridge argument
|
|
1249
|
-
# only supplies a fallback session id, so a missing or disagreeing bridge
|
|
1250
|
-
# never changes the verdict. Every deny names the exact resolving command
|
|
1251
|
-
# (D5). ALLOW: non-intent paths, not-yet-active intents, and any session the
|
|
1252
|
-
# target's lock names as owner or delegate (even when stale: a stale lock is
|
|
1253
|
-
# still its owner's until an explicit takeover).
|
|
1254
|
-
def self.lock_gate_decision(bridge_data, file_path, session: nil,
|
|
1255
|
-
ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home,
|
|
1256
|
-
harness: :claude)
|
|
1257
|
-
return nil if blank?(file_path)
|
|
1258
|
-
|
|
1259
|
-
target_dir = intent_dir_for(file_path)
|
|
1260
|
-
return nil unless target_dir
|
|
1261
|
-
id = intent_id_from_dir(target_dir)
|
|
1262
|
-
store = File.dirname(target_dir)
|
|
1263
|
-
return nil unless id && intent_active?(id, store: store)
|
|
1264
|
-
|
|
1265
|
-
sess = session
|
|
1266
|
-
sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
|
|
1267
|
-
|
|
1268
|
-
# Solo-mode detection (intent 128): scan this intent's store plus the
|
|
1269
|
-
# global store under `home` for fresh delivery locks. Computed once; used
|
|
1270
|
-
# at every arbitration deny below to relax a hard deny to an advisory
|
|
1271
|
-
# allow when solo delivery is positively confirmed.
|
|
1272
|
-
scan_roots = [store, File.join(File.expand_path(home), ".plastic", "store")]
|
|
1273
|
-
solo = solo_delivery?(scan_roots: scan_roots, session: sess, ttl: ttl, now: now)
|
|
1274
|
-
|
|
1275
|
-
lock = Lock.read(target_dir)
|
|
1276
|
-
if lock
|
|
1277
|
-
return nil if Lock.authorized?(lock, sess)
|
|
1278
|
-
if Lock.fresh?(target_dir, ttl: ttl, now: now)
|
|
1279
|
-
return solo_allow(id, "fresh delivery lock") if solo
|
|
1280
|
-
return "intent #{id} delivery lock is held by session " \
|
|
1281
|
-
"#{lock['owner_session']}. Back off; if you are the owner's " \
|
|
1282
|
-
"subagent, the owner must run: plastic-lock delegate " \
|
|
1283
|
-
"--intent-dir #{target_dir} --session <your-session-id>. " \
|
|
1284
|
-
"Inspect with #{skill_ref('plastic-doctor', harness: harness)} check the " \
|
|
1285
|
-
"lock status"
|
|
1286
|
-
end
|
|
1287
|
-
return solo_allow(id, "stale delivery lock") if solo
|
|
1288
|
-
return "intent #{id} has a stale delivery lock (owner " \
|
|
1289
|
-
"#{lock['owner_session']}); run #{skill_ref('plastic-doctor', harness: harness)} " \
|
|
1290
|
-
"reclaim the lock to take it over, or " \
|
|
1291
|
-
"#{skill_ref('plastic-doctor', harness: harness)} fix the lock"
|
|
1292
|
-
end
|
|
1293
|
-
if Lock.corrupt?(target_dir)
|
|
1294
|
-
return solo_allow(id, "unreadable delivery.lock") if solo
|
|
1295
|
-
return "delivery.lock for intent #{id} is unreadable; run " \
|
|
1296
|
-
"#{skill_ref('plastic-doctor', harness: harness)} fix the lock"
|
|
1297
|
-
end
|
|
1298
|
-
return solo_allow(id, "no delivery lock") if solo
|
|
1299
|
-
"no delivery lock held for intent #{id}; run " \
|
|
1300
|
-
"#{skill_ref('plastic-intent-starting', harness: harness)} to lock and begin"
|
|
1301
|
-
end
|
|
1302
|
-
|
|
1303
|
-
# A session holds an intent's lock iff the durable delivery.lock in the
|
|
1304
|
-
# intent dir names it as owner or delegate (D1/D4). The bridge is only a
|
|
1305
|
-
# cache: the lock FILE decides, so a wiped /tmp or a clobbered bridge never
|
|
1306
|
-
# strands the owner. No pid is consulted anywhere.
|
|
1307
|
-
def self.holds_live_lock?(bridge_data, session: nil)
|
|
1308
|
-
sess = session
|
|
1309
|
-
sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
|
|
1310
|
-
return false if blank?(sess)
|
|
1311
|
-
dir = bridge_intent_dir(bridge_data)
|
|
1312
|
-
return false unless dir
|
|
1313
|
-
Lock.holds?(dir, session: sess)
|
|
1314
|
-
end
|
|
1315
|
-
|
|
1316
99
|
# "<id>" from a ".../store/<id>--<slug>" dir, else nil.
|
|
1317
100
|
def self.intent_id_from_dir(dir)
|
|
1318
101
|
base = File.basename(dir.to_s)
|
|
1319
102
|
base.include?("--") ? base.split("--", 2).first : nil
|
|
1320
103
|
end
|
|
1321
104
|
|
|
1322
|
-
# --- Worktree isolation gate (intent 73c2) ---
|
|
1323
|
-
|
|
1324
|
-
# Returns a reason String to BLOCK, or nil to ALLOW. Two independent rules,
|
|
1325
|
-
# both fail-open by construction:
|
|
1326
|
-
#
|
|
1327
|
-
# 1. When the bridge has a provisioned code worktree, a code edit (a target
|
|
1328
|
-
# outside ~/.plastic and outside this intent's store dir) MUST land inside
|
|
1329
|
-
# worktree["code"]; otherwise BLOCK and name the expected worktree path.
|
|
1330
|
-
# 2. When the target lives inside ANOTHER intent's store dir whose bridge lock
|
|
1331
|
-
# is held by a LIVE non-owner session, BLOCK (non-owner edit to an active
|
|
1332
|
-
# intent).
|
|
1333
|
-
#
|
|
1334
|
-
# Fails open (returns nil) when provisioned is false (non-git / global-only) or
|
|
1335
|
-
# the bridge carries no worktree/lock blocks. Logs nothing on the allow path.
|
|
1336
|
-
def self.worktree_gate_decision(bridge_data, file_path, home: Dir.home, current_session: nil)
|
|
1337
|
-
return nil unless bridge_data.is_a?(Hash)
|
|
1338
|
-
return nil if blank?(file_path)
|
|
1339
|
-
|
|
1340
|
-
file_abs = File.expand_path(file_path.to_s)
|
|
1341
|
-
plastic_home = File.expand_path(File.join(home, ".plastic"))
|
|
1342
|
-
under_plastic = file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
|
|
1343
|
-
|
|
1344
|
-
intent_info = bridge_data["intent"] || {}
|
|
1345
|
-
store = intent_info["store"]
|
|
1346
|
-
dir = intent_info["dir"]
|
|
1347
|
-
intent_dir_abs = (store && dir) ? File.expand_path("#{store}/#{dir}") : nil
|
|
1348
|
-
under_own_intent = intent_dir_abs &&
|
|
1349
|
-
(file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/"))
|
|
1350
|
-
|
|
1351
|
-
# Solo-mode detection (intent 128): current session first, else the
|
|
1352
|
-
# bridge's own session; scan roots are this intent's store, the global
|
|
1353
|
-
# store under `home`, AND the EDIT TARGET's own store (when the target
|
|
1354
|
-
# lives inside a store dir), so a live foreign lock on the intent being
|
|
1355
|
-
# edited is never invisible to the scan just because it belongs to a
|
|
1356
|
-
# different project than the acting bridge's own store (review finding 1;
|
|
1357
|
-
# duplicate roots are harmless, solo_delivery? dedupes). Computed once;
|
|
1358
|
-
# used by both rules below.
|
|
1359
|
-
sess = blank?(current_session) ? bridge_data["session"] : current_session
|
|
1360
|
-
target_store = parse_store_target(file_abs, plastic_home)&.fetch(:store, nil)
|
|
1361
|
-
scan_roots = [store, File.join(plastic_home, "store"), target_store]
|
|
1362
|
-
solo = solo_delivery?(scan_roots: scan_roots, session: sess)
|
|
1363
|
-
|
|
1364
|
-
# Rule 1 (fixed in intent 108, D7): confinement applies ONLY to paths
|
|
1365
|
-
# inside the project repo. The repo root is derived from the provisioned
|
|
1366
|
-
# code worktree path, which is <repo>/.claude/worktrees/{id}--{slug} by
|
|
1367
|
-
# construction, so no git call is needed. Paths outside the repo (agent
|
|
1368
|
-
# memory dirs, scratch files, unrelated checkouts) are not this gate's
|
|
1369
|
-
# business; the 2026-07-02 memory-dir denial came from treating everything
|
|
1370
|
-
# outside the worktree as the shared checkout.
|
|
1371
|
-
worktree = bridge_data["worktree"] || {}
|
|
1372
|
-
if worktree["provisioned"] == true
|
|
1373
|
-
code = worktree["code"].to_s
|
|
1374
|
-
if !blank?(code) && !under_plastic && !under_own_intent
|
|
1375
|
-
code_abs = File.expand_path(code)
|
|
1376
|
-
repo_abs = File.expand_path(File.join(code_abs, "..", "..", ".."))
|
|
1377
|
-
inside_repo = file_abs == repo_abs || file_abs.start_with?("#{repo_abs}/")
|
|
1378
|
-
inside_code = file_abs == code_abs || file_abs.start_with?("#{code_abs}/")
|
|
1379
|
-
if inside_repo && !inside_code
|
|
1380
|
-
id = intent_info["id"]
|
|
1381
|
-
return solo_allow(id, "worktree confinement") if solo
|
|
1382
|
-
return "intent #{id} is isolated to its worktree - edit project code " \
|
|
1383
|
-
"inside #{code_abs}, not the shared checkout. (blocked edit: #{file_abs})"
|
|
1384
|
-
end
|
|
1385
|
-
end
|
|
1386
|
-
end
|
|
1387
|
-
|
|
1388
|
-
# Rule 2: do not edit another intent's locked, live store dir.
|
|
1389
|
-
if under_plastic
|
|
1390
|
-
reason = non_owner_store_edit_reason(file_abs, plastic_home, intent_dir_abs,
|
|
1391
|
-
home: home, current_session: current_session,
|
|
1392
|
-
own_session: bridge_data["session"])
|
|
1393
|
-
if reason
|
|
1394
|
-
return solo_allow(intent_info["id"], "non-owner store edit") if solo
|
|
1395
|
-
return reason
|
|
1396
|
-
end
|
|
1397
|
-
end
|
|
1398
|
-
|
|
1399
|
-
nil
|
|
1400
|
-
end
|
|
1401
|
-
|
|
1402
|
-
# Helper for rule 2. A store dir is `<plastic_home>/store/{id}--{slug}` (global)
|
|
1403
|
-
# or `<plastic_home>/projects/{slug}/store/{id}--{slug}` (project). When the
|
|
1404
|
-
# edit target sits inside such a dir that is NOT this intent's own dir, and a
|
|
1405
|
-
# live non-owner session holds that intent's bridge lock, BLOCK.
|
|
1406
|
-
def self.non_owner_store_edit_reason(file_abs, plastic_home, own_intent_dir_abs,
|
|
1407
|
-
home:, current_session:, own_session:)
|
|
1408
|
-
return nil if own_intent_dir_abs &&
|
|
1409
|
-
(file_abs == own_intent_dir_abs || file_abs.start_with?("#{own_intent_dir_abs}/"))
|
|
1410
|
-
|
|
1411
|
-
parsed = parse_store_target(file_abs, plastic_home)
|
|
1412
|
-
return nil unless parsed
|
|
1413
|
-
|
|
1414
|
-
session = blank?(current_session) ? own_session : current_session
|
|
1415
|
-
held = Worktree.lock_held_by_other?(
|
|
1416
|
-
intent_id: parsed[:id], store: parsed[:store],
|
|
1417
|
-
current_session: session, home: home,
|
|
1418
|
-
)
|
|
1419
|
-
return nil unless held
|
|
1420
|
-
|
|
1421
|
-
"intent #{parsed[:id]} is owned by another live session — its delivery lock " \
|
|
1422
|
-
"is held elsewhere. Back off; do not edit #{file_abs}."
|
|
1423
|
-
end
|
|
1424
|
-
|
|
1425
|
-
# Resolve an edit target inside a store to {id:, store:} for the intent dir it
|
|
1426
|
-
# belongs to, or nil if the path is not inside an `{id}--{slug}` intent dir.
|
|
1427
|
-
def self.parse_store_target(file_abs, plastic_home)
|
|
1428
|
-
rels = []
|
|
1429
|
-
global_store = File.join(plastic_home, "store")
|
|
1430
|
-
if file_abs.start_with?("#{global_store}/")
|
|
1431
|
-
rels << [file_abs[(global_store.length + 1)..], global_store]
|
|
1432
|
-
end
|
|
1433
|
-
projects = File.join(plastic_home, "projects")
|
|
1434
|
-
if file_abs.start_with?("#{projects}/")
|
|
1435
|
-
tail = file_abs[(projects.length + 1)..].to_s
|
|
1436
|
-
parts = tail.split(File::SEPARATOR)
|
|
1437
|
-
if parts.length >= 2 && parts[1] == "store"
|
|
1438
|
-
pstore = File.join(projects, parts[0], "store")
|
|
1439
|
-
rels << [file_abs[(pstore.length + 1)..], pstore]
|
|
1440
|
-
end
|
|
1441
|
-
end
|
|
1442
|
-
|
|
1443
|
-
rels.each do |rel, store_dir|
|
|
1444
|
-
next if blank?(rel)
|
|
1445
|
-
first = rel.split(File::SEPARATOR).first.to_s
|
|
1446
|
-
idx = first.index("--")
|
|
1447
|
-
next unless idx && idx > 0
|
|
1448
|
-
return { id: first[0...idx], store: store_dir }
|
|
1449
|
-
end
|
|
1450
|
-
nil
|
|
1451
|
-
end
|
|
1452
|
-
|
|
1453
|
-
# --- Bash-edit gate (intent 27a) ---
|
|
1454
|
-
|
|
1455
|
-
# Extract the set of file paths a Bash command writes to. Conservative by
|
|
1456
|
-
# design: it is acceptable to miss exotic forms, but it must NOT flag reads
|
|
1457
|
-
# or /dev/null. Returns an Array of path strings (possibly relative).
|
|
1458
|
-
#
|
|
1459
|
-
# Covered write vectors: redirection (>, >>, including heredoc `cat > f <<EOF`),
|
|
1460
|
-
# tee / tee -a, sed -i / sed -i.bak, cp/mv (last non-flag arg), dd of=.
|
|
1461
|
-
def self.bash_write_targets(command)
|
|
1462
|
-
return [] unless command.is_a?(String)
|
|
1463
|
-
|
|
1464
|
-
targets = []
|
|
1465
|
-
targets.concat(bash_redirect_targets(command))
|
|
1466
|
-
# Split on command separators for per-segment utility parsing.
|
|
1467
|
-
command.split(/[;\n]|&&|\|\||\|/).each do |segment|
|
|
1468
|
-
targets.concat(bash_utility_targets(segment))
|
|
1469
|
-
targets.concat(interpreter_write_targets(segment))
|
|
1470
|
-
end
|
|
1471
|
-
targets.uniq
|
|
1472
|
-
end
|
|
1473
|
-
|
|
1474
|
-
# Redirections: `> path` / `>> path`, but not fd dups (`2>&1`) or /dev/null.
|
|
1475
|
-
# A leading digit (fd number) before > is fine; `>&` is a dup and excluded.
|
|
1476
|
-
# Quote- and heredoc-aware: a `>` inside a single/double-quoted span or inside a
|
|
1477
|
-
# heredoc body is NOT a redirect. Fails OPEN (returns []) on an ambiguous parse
|
|
1478
|
-
# (unbalanced quote or unterminated heredoc) rather than guessing a target.
|
|
1479
|
-
def self.bash_redirect_targets(command)
|
|
1480
|
-
return [] unless command.is_a?(String)
|
|
1481
|
-
scannable = scannable_redirect_text(command)
|
|
1482
|
-
return [] if scannable.nil? # ambiguous parse -> fail open
|
|
1483
|
-
targets = []
|
|
1484
|
-
scannable.scan(/\d*>>?(?!&)\s*([^\s;|&<>]+)/) do |m|
|
|
1485
|
-
path = m[0]
|
|
1486
|
-
next if path.nil? || path.empty?
|
|
1487
|
-
next if dev_null?(path)
|
|
1488
|
-
targets << path
|
|
1489
|
-
end
|
|
1490
|
-
targets
|
|
1491
|
-
end
|
|
1492
|
-
|
|
1493
|
-
# Return a copy of `command` in which single-quoted spans, double-quoted spans,
|
|
1494
|
-
# and heredoc bodies are blanked to spaces, so the redirect regex only ever sees
|
|
1495
|
-
# operators that are genuinely outside quotes and heredoc bodies. Returns nil on
|
|
1496
|
-
# an ambiguous parse (a line ends inside a quote, or a heredoc is never closed).
|
|
1497
|
-
def self.scannable_redirect_text(command)
|
|
1498
|
-
out = +""
|
|
1499
|
-
pending = [] # queue of {word:, dash:} heredoc terminators awaiting bodies
|
|
1500
|
-
command.split("\n", -1).each do |line|
|
|
1501
|
-
if pending.any?
|
|
1502
|
-
term = pending.first
|
|
1503
|
-
probe = term[:dash] ? line.sub(/\A\t+/, "") : line
|
|
1504
|
-
pending.shift if probe == term[:word]
|
|
1505
|
-
out << "\n" # heredoc body/terminator line contributes nothing scannable
|
|
1506
|
-
next
|
|
1507
|
-
end
|
|
1508
|
-
masked, openers, balanced = mask_redirect_line(line)
|
|
1509
|
-
return nil unless balanced # unbalanced quote on this line -> ambiguous
|
|
1510
|
-
out << masked << "\n"
|
|
1511
|
-
pending.concat(openers)
|
|
1512
|
-
end
|
|
1513
|
-
return nil if pending.any? # unterminated heredoc -> ambiguous
|
|
1514
|
-
out
|
|
1515
|
-
end
|
|
1516
|
-
|
|
1517
|
-
# Walk one normal (non-heredoc-body) line, masking quoted spans to spaces and
|
|
1518
|
-
# recognizing heredoc openers. Returns [masked_line, [heredoc_openers], balanced?].
|
|
1519
|
-
def self.mask_redirect_line(line)
|
|
1520
|
-
out = +""
|
|
1521
|
-
openers = []
|
|
1522
|
-
state = :normal
|
|
1523
|
-
i = 0
|
|
1524
|
-
n = line.length
|
|
1525
|
-
while i < n
|
|
1526
|
-
c = line[i]
|
|
1527
|
-
case state
|
|
1528
|
-
when :single
|
|
1529
|
-
if c == "'" || c == "<" || c == ">"
|
|
1530
|
-
out << " "
|
|
1531
|
-
else
|
|
1532
|
-
out << c
|
|
1533
|
-
end
|
|
1534
|
-
state = :normal if c == "'"
|
|
1535
|
-
i += 1
|
|
1536
|
-
when :double
|
|
1537
|
-
if c == "\\" && i + 1 < n
|
|
1538
|
-
out << " "
|
|
1539
|
-
i += 2
|
|
1540
|
-
else
|
|
1541
|
-
if c == '"' || c == "<" || c == ">"
|
|
1542
|
-
out << " "
|
|
1543
|
-
else
|
|
1544
|
-
out << c
|
|
1545
|
-
end
|
|
1546
|
-
state = :normal if c == '"'
|
|
1547
|
-
i += 1
|
|
1548
|
-
end
|
|
1549
|
-
else # :normal
|
|
1550
|
-
if c == "'"
|
|
1551
|
-
out << " "; state = :single; i += 1
|
|
1552
|
-
elsif c == '"'
|
|
1553
|
-
out << " "; state = :double; i += 1
|
|
1554
|
-
elsif c == "<" && line[i + 1] == "<"
|
|
1555
|
-
m = line[i..].match(/\A<<(-?)\s*("|')?([A-Za-z0-9_][A-Za-z0-9_]*)\2?/)
|
|
1556
|
-
if m
|
|
1557
|
-
openers << { word: m[3], dash: m[1] == "-" }
|
|
1558
|
-
out << (" " * m[0].length)
|
|
1559
|
-
i += m[0].length
|
|
1560
|
-
else
|
|
1561
|
-
out << "<<"; i += 2 # here-string / no valid word: leave as-is
|
|
1562
|
-
end
|
|
1563
|
-
else
|
|
1564
|
-
out << c; i += 1
|
|
1565
|
-
end
|
|
1566
|
-
end
|
|
1567
|
-
end
|
|
1568
|
-
[out, openers, state == :normal]
|
|
1569
|
-
end
|
|
1570
|
-
|
|
1571
|
-
def self.bash_utility_targets(segment)
|
|
1572
|
-
tokens = segment.strip.split(/\s+/)
|
|
1573
|
-
return [] if tokens.empty?
|
|
1574
|
-
|
|
1575
|
-
# Find the utility name, skipping env-style assignments.
|
|
1576
|
-
idx = 0
|
|
1577
|
-
idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /^-/ && !tokens[idx].start_with?("of=")
|
|
1578
|
-
util = File.basename(tokens[idx].to_s)
|
|
1579
|
-
args = tokens[(idx + 1)..] || []
|
|
1580
|
-
|
|
1581
|
-
case util
|
|
1582
|
-
when "tee"
|
|
1583
|
-
tee_targets(args)
|
|
1584
|
-
when "sed"
|
|
1585
|
-
sed_targets(args)
|
|
1586
|
-
when "cp", "mv"
|
|
1587
|
-
copy_move_targets(args)
|
|
1588
|
-
when "dd"
|
|
1589
|
-
dd_targets(tokens)
|
|
1590
|
-
else
|
|
1591
|
-
[]
|
|
1592
|
-
end
|
|
1593
|
-
end
|
|
1594
|
-
|
|
1595
|
-
def self.tee_targets(args)
|
|
1596
|
-
args.reject { |a| a.start_with?("-") || dev_null?(a) }
|
|
1597
|
-
end
|
|
1598
|
-
|
|
1599
|
-
def self.sed_targets(args)
|
|
1600
|
-
# In-place only: -i or -i.bak (suffix attached). Otherwise sed reads.
|
|
1601
|
-
inplace = args.any? { |a| a == "-i" || a.start_with?("-i") }
|
|
1602
|
-
return [] unless inplace
|
|
1603
|
-
files = args.reject { |a| a.start_with?("-") }
|
|
1604
|
-
# sed args: script then file(s). First non-flag is the script expression
|
|
1605
|
-
# unless an -e/-f was used; conservatively treat the LAST non-flag as file.
|
|
1606
|
-
files.empty? ? [] : [files.last].reject { |f| dev_null?(f) }
|
|
1607
|
-
end
|
|
1608
|
-
|
|
1609
|
-
def self.copy_move_targets(args)
|
|
1610
|
-
files = args.reject { |a| a.start_with?("-") }
|
|
1611
|
-
return [] if files.length < 2
|
|
1612
|
-
dest = files.last
|
|
1613
|
-
dev_null?(dest) ? [] : [dest]
|
|
1614
|
-
end
|
|
1615
|
-
|
|
1616
|
-
def self.dd_targets(tokens)
|
|
1617
|
-
tokens.each_with_object([]) do |t, acc|
|
|
1618
|
-
next unless t.start_with?("of=")
|
|
1619
|
-
path = t.sub("of=", "")
|
|
1620
|
-
acc << path unless path.empty? || dev_null?(path)
|
|
1621
|
-
end
|
|
1622
|
-
end
|
|
1623
|
-
|
|
1624
|
-
def self.dev_null?(path)
|
|
1625
|
-
path == "/dev/null" || path.start_with?("/dev/")
|
|
1626
|
-
end
|
|
1627
|
-
|
|
1628
|
-
# --- Interpreter inline-code writes (intent 108, D7) ---
|
|
1629
|
-
|
|
1630
|
-
INTERPRETER_RE = /\b(ruby|python3?|perl|node)\b(?:\s+\S+)*?\s+(-e|-c)\s+(.+)\z/m.freeze
|
|
1631
|
-
|
|
1632
|
-
# Write verbs that mark inline code as file-mutating. Conservative: reads
|
|
1633
|
-
# (File.read, puts) never match.
|
|
1634
|
-
WRITE_VERB_RE = /File\.(?:write|binwrite|open)|IO\.write|FileUtils\.|
|
|
1635
|
-
open\s*\([^)]*["'][wa]|writeFileSync|fs\.write/x.freeze
|
|
1636
|
-
|
|
1637
|
-
# Quoted absolute or ~/ paths inside the inline code.
|
|
1638
|
-
INLINE_PATH_RE = %r{["']((?:/|~/)[^"']+)["']}.freeze
|
|
1639
|
-
|
|
1640
|
-
# Paths an interpreter one-liner writes. Flagged only when the inline code
|
|
1641
|
-
# has BOTH a write verb AND a quoted absolute path; everything else (reads,
|
|
1642
|
-
# ARGV-driven paths, the sanctioned arm one-liners) yields no targets.
|
|
1643
|
-
def self.interpreter_write_targets(segment)
|
|
1644
|
-
m = INTERPRETER_RE.match(segment.to_s)
|
|
1645
|
-
return [] unless m
|
|
1646
|
-
util, flag, code = m[1], m[2], m[3]
|
|
1647
|
-
expected = { "ruby" => "-e", "python" => "-c", "python3" => "-c",
|
|
1648
|
-
"perl" => "-e", "node" => "-e" }[util]
|
|
1649
|
-
return [] unless flag == expected
|
|
1650
|
-
return [] unless WRITE_VERB_RE.match?(code)
|
|
1651
|
-
code.scan(INLINE_PATH_RE).flatten.map { |p| File.expand_path(p) }
|
|
1652
|
-
end
|
|
1653
|
-
|
|
1654
|
-
# Decide whether a Bash command should be blocked. Every write target runs
|
|
1655
|
-
# through the SAME policy stack as a direct tool write: the auto-mode code
|
|
1656
|
-
# gate AND the delivery-lock gate (intent 108, D7), so bash and interpreter
|
|
1657
|
-
# writes cannot bypass the lock. Returns the first block reason, or nil.
|
|
1658
|
-
def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home, session: nil)
|
|
1659
|
-
bash_write_targets(command).each do |target|
|
|
1660
|
-
abs = File.absolute_path?(target) ? target : File.join(cwd, target)
|
|
1661
|
-
abs = File.expand_path(abs)
|
|
1662
|
-
reason = code_gate_decision(bridge_data, abs, home: home) ||
|
|
1663
|
-
lock_gate_decision(bridge_data, abs, session: session)
|
|
1664
|
-
return reason if reason
|
|
1665
|
-
end
|
|
1666
|
-
nil
|
|
1667
|
-
end
|
|
1668
|
-
|
|
1669
|
-
# A TRAILING `# plastic-ok` shell comment: the auditable escape for
|
|
1670
|
-
# sanctioned bash/interpreter writes. The hook logs every use to
|
|
1671
|
-
# ~/.plastic/.cache/gate-escapes.log.
|
|
1672
|
-
PLASTIC_OK_RE = /(?:\A|\s)#\s*plastic-ok\s*\z/.freeze
|
|
1673
|
-
|
|
1674
|
-
def self.bash_escape?(command)
|
|
1675
|
-
PLASTIC_OK_RE.match?(command.to_s.chomp)
|
|
1676
|
-
end
|
|
1677
|
-
|
|
1678
105
|
def self.deep_merge(base, overlay)
|
|
1679
106
|
result = base.dup
|
|
1680
107
|
overlay.each do |key, value|
|