@zalom/plastic 1.3.0 → 1.4.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-reference.md +8 -6
- package/PLASTIC.md +52 -14
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/package.json +1 -1
- package/scripts/codex-hook +122 -8
- package/scripts/dashboard.rb +323 -71
- package/scripts/doctor.rb +393 -58
- package/scripts/end-intent +347 -43
- package/scripts/hook-links-gate +74 -0
- package/scripts/hook-lock-gate +8 -3
- package/scripts/install.rb +51 -6
- package/scripts/lib/bridge.rb +105 -27
- package/scripts/lib/config_asks.rb +110 -0
- package/scripts/lib/graph_rebuild.rb +30 -6
- package/scripts/lib/hook_registry.rb +34 -3
- package/scripts/lib/installer_core.rb +70 -13
- package/scripts/lib/intent_validator.rb +38 -10
- package/scripts/lib/links_gate.rb +140 -0
- package/scripts/lib/links_projection.rb +71 -12
- package/scripts/lib/lock.rb +186 -11
- package/scripts/lib/power_tools.rb +57 -14
- package/scripts/lib/project_validator.rb +113 -0
- package/scripts/lib/qmd_hook.rb +12 -8
- package/scripts/lib/restore_intent_v1.rb +154 -0
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +38 -10
- package/scripts/lib/store_discovery.rb +77 -0
- package/scripts/lib/store_provisioning.rb +21 -12
- package/scripts/new-intent +10 -12
- package/scripts/plastic-lock +76 -9
- package/scripts/project-links +132 -35
- package/scripts/provision-project-store +18 -5
- package/scripts/read-config +1 -0
- package/scripts/rebuild-graph +42 -17
- package/scripts/restore-intent-v1 +288 -0
- package/scripts/roadmap-next +9 -2
- package/scripts/roadmap-savepoint +9 -1
- package/scripts/update.rb +50 -1
- package/scripts/validate-intent +3 -1
- package/scripts/validate-project +53 -0
- package/scripts/write-config +105 -0
- package/skills/auto/SKILL.md +45 -16
- package/skills/auto/references/agent-architecture.md +7 -0
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/dashboard/SKILL.md +48 -25
- package/skills/dashboard/evals/evals.json +4 -4
- package/skills/dashboard/templates/dashboard-global.md +3 -5
- package/skills/dashboard/templates/dashboard-project.md +6 -18
- package/skills/install/SKILL.md +4 -4
- package/skills/intent-creating/SKILL.md +5 -0
- package/skills/intent-ending/SKILL.md +49 -36
- package/skills/intent-locking/SKILL.md +20 -2
- package/skills/intent-starting/SKILL.md +6 -4
- package/skills/project-continuing/SKILL.md +10 -0
- package/skills/project-continuing/evals/evals.json +3 -3
- package/skills/project-continuing/references/board-fill.md +13 -11
- package/skills/project-creating/SKILL.md +29 -1
- package/skills/releasing/SKILL.md +37 -19
- package/skills/roadmap/SKILL.md +9 -7
- package/skills/roadmap/references/file-format.md +14 -10
- package/skills/roadmap/references/operations.md +22 -18
- package/skills/roadmap-continuing/SKILL.md +5 -5
- package/skills/roadmap-continuing/evals/evals.json +3 -3
- package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
- package/skills/tutorial/SKILL.md +4 -4
- package/skills/tutorial/references/track-1-guided.md +2 -1
- package/skills/tutorial/references/track-2-auto.md +2 -1
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +12 -11
- package/skills/update/SKILL.md +30 -17
- package/templates/roadmap.md +8 -8
package/scripts/install.rb
CHANGED
|
@@ -40,10 +40,26 @@ class Install < InstallerCore
|
|
|
40
40
|
puts "\n\u{1f9e0} Plastic v#{version}\n\n"
|
|
41
41
|
|
|
42
42
|
if installed? && !reinstall
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
# `installed?` is a GLOBAL check (is Plastic core installed for ANY
|
|
44
|
+
# harness), so it cannot by itself decide whether to refuse: once any
|
|
45
|
+
# agent is present, a machine that has NEVER installed a second harness
|
|
46
|
+
# (e.g. Codex, with no ~/.agents, no ~/.codex/hooks.json) must still be
|
|
47
|
+
# able to add it. Refuse only when EVERY selected agent already has its
|
|
48
|
+
# own registration (intent 198, D7); otherwise proceed with just the
|
|
49
|
+
# unregistered ones, and report the already-registered ones without
|
|
50
|
+
# silently re-syncing them (that is what --reinstall is for).
|
|
51
|
+
new_agents = selected.reject { |key| agent_installed?(key) }
|
|
52
|
+
if new_agents.empty?
|
|
53
|
+
warn "Plastic v#{installed_version} is already installed."
|
|
54
|
+
warn " - To upgrade: npx @zalom/plastic update"
|
|
55
|
+
warn " - To re-sync files: npx @zalom/plastic install --reinstall"
|
|
56
|
+
return 1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
already_registered = selected - new_agents
|
|
60
|
+
run(selected: new_agents, force: force, reinstall: reinstall, ledger_action: ledger_action, argv: argv,
|
|
61
|
+
already_registered: already_registered)
|
|
62
|
+
return 0
|
|
47
63
|
end
|
|
48
64
|
|
|
49
65
|
run(selected: selected, force: force, reinstall: reinstall, ledger_action: ledger_action, argv: argv)
|
|
@@ -51,7 +67,11 @@ class Install < InstallerCore
|
|
|
51
67
|
end
|
|
52
68
|
|
|
53
69
|
# Hermetic entrypoint (no prompting / no exit). Returns the per-agent results array.
|
|
54
|
-
|
|
70
|
+
# `already_registered` names agents the gate above found already registered:
|
|
71
|
+
# they are reported, never re-installed, so the summary line and the Codex
|
|
72
|
+
# trust reminder only ever count agents that actually changed this run.
|
|
73
|
+
def run(selected:, force: false, reinstall: false, ledger_action: nil, argv: ARGV, input: $stdin,
|
|
74
|
+
already_registered: [])
|
|
55
75
|
fresh = !installed?
|
|
56
76
|
mode = fresh ? :install : :update # :update here means "re-sync, skip bootstrap"
|
|
57
77
|
|
|
@@ -60,6 +80,7 @@ class Install < InstallerCore
|
|
|
60
80
|
apply_config_flags(argv)
|
|
61
81
|
|
|
62
82
|
results = selected.map { |key| install_for_agent(key, force, argv: argv, input: input, reinstall: reinstall) }
|
|
83
|
+
results += already_registered.map { |key| already_registered_result(key) }
|
|
63
84
|
|
|
64
85
|
action = ledger_action || (fresh ? "install" : "reinstall")
|
|
65
86
|
ledger_append(version, action)
|
|
@@ -114,10 +135,17 @@ class Install < InstallerCore
|
|
|
114
135
|
argv[i + 1]
|
|
115
136
|
end
|
|
116
137
|
|
|
138
|
+
def already_registered_result(key)
|
|
139
|
+
config = agent_config(key)
|
|
140
|
+
{ agent: config[:name], success: false, already_registered: true }
|
|
141
|
+
end
|
|
142
|
+
|
|
117
143
|
def print_results(results, mode)
|
|
118
144
|
puts "\n\u{2014} Results \u{2014}\n\n"
|
|
119
145
|
results.each do |r|
|
|
120
|
-
if r[:
|
|
146
|
+
if r[:already_registered]
|
|
147
|
+
puts " \u{2139} #{r[:agent]}: already registered, not re-synced (use --reinstall to re-sync)"
|
|
148
|
+
elsif r[:success]
|
|
121
149
|
puts " \u{2705} #{r[:agent]}: #{r[:files]} files installed"
|
|
122
150
|
else
|
|
123
151
|
puts " \u{26a0}\u{fe0f} #{r[:agent]}: #{r[:reason]}"
|
|
@@ -132,6 +160,23 @@ class Install < InstallerCore
|
|
|
132
160
|
puts " Registered for: #{installed.map { |r| r[:agent] }.join(", ")}"
|
|
133
161
|
puts " Run /clear (or restart your agent) to pick up new conventions."
|
|
134
162
|
puts " Next: read docs/guides/your-first-intent-in-10-minutes.md\n\n"
|
|
163
|
+
|
|
164
|
+
print_codex_hook_trust_reminder(installed)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Codex hooks are installed but INERT until a human reviews and trusts each
|
|
168
|
+
# hook definition via /hooks (intent 198, Decision D2); Codex keys trust to
|
|
169
|
+
# the hook's current command hash, so a future release that changes a hook
|
|
170
|
+
# command re-arms the review. Printed only when a harness that declares its
|
|
171
|
+
# own home_dir (Codex today) actually installed successfully in this run.
|
|
172
|
+
# Data-driven from `agents`, never a hardcoded harness name, mirroring the
|
|
173
|
+
# same reasoning as the D1 presence-probe fix.
|
|
174
|
+
def print_codex_hook_trust_reminder(installed)
|
|
175
|
+
codex_like = agents.select { |a| a.key?(:home_dir) }
|
|
176
|
+
return if codex_like.none? { |a| installed.any? { |r| r[:agent] == a[:name] } }
|
|
177
|
+
|
|
178
|
+
puts " Codex: open Codex, run /hooks, and trust the Plastic hook definitions."
|
|
179
|
+
puts " Plastic's gates will not fire until you do.\n\n"
|
|
135
180
|
end
|
|
136
181
|
|
|
137
182
|
def show_help
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -21,6 +21,16 @@ module Bridge
|
|
|
21
21
|
# (<id>--<slug>.md) is never sentineled; it is born complete.
|
|
22
22
|
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
23
23
|
|
|
24
|
+
# Single place a skill-reference string gets built (intent 201, D3): every
|
|
25
|
+
# message that used to write "/plastic-something" by hand calls this
|
|
26
|
+
# instead, so a fourth harness only teaches ITS prefix once instead of
|
|
27
|
+
# hunting the codebase for hardcoded slashes. The actual prefix table lives
|
|
28
|
+
# on Lock (see lock.rb), which bridge.rb already requires; this is a thin
|
|
29
|
+
# delegator so every call site in this file reads Bridge.skill_ref.
|
|
30
|
+
def self.skill_ref(name, harness: :claude)
|
|
31
|
+
Lock.skill_ref(name, harness: harness)
|
|
32
|
+
end
|
|
33
|
+
|
|
24
34
|
# Bridge cleanup is terminal-state, not age-based (intent 80). A bridge is dead
|
|
25
35
|
# weight ONLY once its intent is terminal (no longer in its store's INDEX.md
|
|
26
36
|
# `## Active` block); such bridges are purged. An Active intent's bridge is kept
|
|
@@ -87,6 +97,12 @@ module Bridge
|
|
|
87
97
|
"host" => lock_data["host"],
|
|
88
98
|
"type" => lock_data["type"],
|
|
89
99
|
"delegates" => Array(lock_data["delegates"]),
|
|
100
|
+
"owner_harness" => lock_data["owner_harness"],
|
|
101
|
+
"owner_agent" => lock_data["owner_agent"],
|
|
102
|
+
"owner_model" => lock_data["owner_model"],
|
|
103
|
+
"owner_thread" => lock_data["owner_thread"],
|
|
104
|
+
"run_mode" => lock_data["run_mode"],
|
|
105
|
+
"delegate_activity" => Array(lock_data["delegate_activity"]),
|
|
90
106
|
}
|
|
91
107
|
end
|
|
92
108
|
|
|
@@ -259,6 +275,34 @@ module Bridge
|
|
|
259
275
|
auto_pool.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
260
276
|
end
|
|
261
277
|
|
|
278
|
+
# --- Shared INDEX entry matcher (intent 188, D12/D13) -----------------------
|
|
279
|
+
#
|
|
280
|
+
# ONE definition site for the "- [ID <sep> Title](link)" shape both
|
|
281
|
+
# `intent_active?` (below) and `scripts/end-intent`'s own INDEX-move parser
|
|
282
|
+
# depend on, so the two regexes can never drift apart again (the em-dash-only
|
|
283
|
+
# bug was flagged and deferred at intents 96 and 169, then independently
|
|
284
|
+
# rediscovered as a SEPARATE end-intent defect at intent 188). Accepts a real
|
|
285
|
+
# em dash (U+2014) OR a plain hyphen as the id/title separator on READ.
|
|
286
|
+
# Hardening this widens intent_active?'s fail-open case: a hyphen-formatted
|
|
287
|
+
# `## Active` line used to read as not-active (lock gate failed open); it now
|
|
288
|
+
# reads as active (gate correctly blocks). Accepted as a bug fix (D13): no
|
|
289
|
+
# passing test relied on the old fail-open behavior. Every WRITE still emits
|
|
290
|
+
# the real em dash (D10); only what this matcher can PARSE has widened.
|
|
291
|
+
#
|
|
292
|
+
# The separator is built from the codepoint, not a literal byte in this
|
|
293
|
+
# source file, so this new code stays em-dash free (the shipped-file
|
|
294
|
+
# convention; store files like INDEX.md are the exempt surface this matcher
|
|
295
|
+
# READS, not where this constant lives). Matches the existing convention in
|
|
296
|
+
# scripts/end-intent.
|
|
297
|
+
EM_DASH = "\u2014".freeze
|
|
298
|
+
INDEX_ENTRY_RE = /\A- \[(\S+)\s+(?:#{Regexp.escape(EM_DASH)}|-)\s+(.*?)\]\(([^)]+)\)/.freeze
|
|
299
|
+
|
|
300
|
+
# Match `line` (already chomped) against the shared INDEX entry shape.
|
|
301
|
+
# Returns a MatchData (captures: 1 = id, 2 = title, 3 = link) or nil.
|
|
302
|
+
def self.index_entry_match(line)
|
|
303
|
+
line.to_s.match(INDEX_ENTRY_RE)
|
|
304
|
+
end
|
|
305
|
+
|
|
262
306
|
# --- Terminal-state bridge purge (intent 80) -------------------------------
|
|
263
307
|
|
|
264
308
|
# True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
|
|
@@ -284,7 +328,7 @@ module Bridge
|
|
|
284
328
|
end
|
|
285
329
|
next unless in_active
|
|
286
330
|
break if stripped.start_with?("## ") # next section ends the Active block
|
|
287
|
-
m = stripped
|
|
331
|
+
m = index_entry_match(stripped)
|
|
288
332
|
return true if m && m[1] == target
|
|
289
333
|
end
|
|
290
334
|
false
|
|
@@ -844,7 +888,8 @@ module Bridge
|
|
|
844
888
|
# and arm_guided (auto: false) are thin delegators so the lock-stamp + provision
|
|
845
889
|
# behaviour stays identical across both modes. Works even when no bridge exists
|
|
846
890
|
# yet (mid-session intent creation).
|
|
847
|
-
def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:
|
|
891
|
+
def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:, harness: nil,
|
|
892
|
+
agent: nil, model: nil, thread: nil)
|
|
848
893
|
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
849
894
|
if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
|
|
850
895
|
$stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
|
|
@@ -855,24 +900,29 @@ module Bridge
|
|
|
855
900
|
# Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
|
|
856
901
|
# intent dir. The bridge lock block is a cache of the file.
|
|
857
902
|
intent_dir_abs = File.expand_path(intent_dir)
|
|
858
|
-
status, lock_data = Lock.acquire(intent_dir_abs, session: key
|
|
903
|
+
status, lock_data = Lock.acquire(intent_dir_abs, session: key,
|
|
904
|
+
harness: harness, agent: agent,
|
|
905
|
+
model: model, thread: thread,
|
|
906
|
+
run_mode: auto ? "auto" : "guided")
|
|
859
907
|
case status
|
|
860
908
|
when :acquired, :owned
|
|
861
909
|
data["lock"] = lock_cache(lock_data)
|
|
862
910
|
when :held
|
|
863
911
|
raise LockHeldError, "delivery lock for intent #{intent_id} is held by " \
|
|
864
|
-
"session #{lock_data && lock_data['owner_session']}; run
|
|
865
|
-
"check the lock status"
|
|
912
|
+
"session #{lock_data && lock_data['owner_session']}; run " \
|
|
913
|
+
"#{skill_ref('plastic-doctor', harness: harness)} check the lock status"
|
|
866
914
|
when :stale
|
|
867
915
|
raise LockHeldError, "delivery lock for intent #{intent_id} is stale " \
|
|
868
|
-
"(owner #{lock_data && lock_data['owner_session']}); run
|
|
869
|
-
"reclaim the lock to take it
|
|
916
|
+
"(owner #{lock_data && lock_data['owner_session']}); run " \
|
|
917
|
+
"#{skill_ref('plastic-doctor', harness: harness)} reclaim the lock to take it " \
|
|
918
|
+
"over with an audit"
|
|
870
919
|
when :excluded
|
|
871
920
|
raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
|
|
872
|
-
"intent #{intent_id}; run
|
|
921
|
+
"intent #{intent_id}; run #{skill_ref('plastic-doctor', harness: harness)} check " \
|
|
922
|
+
"the lock status"
|
|
873
923
|
when :corrupt
|
|
874
924
|
raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
|
|
875
|
-
"run
|
|
925
|
+
"run #{skill_ref('plastic-doctor', harness: harness)} fix the lock"
|
|
876
926
|
end
|
|
877
927
|
|
|
878
928
|
# Provision the per-intent worktrees (mandatory code worktree for project
|
|
@@ -892,15 +942,19 @@ module Bridge
|
|
|
892
942
|
|
|
893
943
|
# Arm auto mode for a session+intent. Works even when no bridge exists yet
|
|
894
944
|
# (mid-session intent creation). Re-derives intent state, then sets build.auto.
|
|
895
|
-
def self.arm_auto(session, intent_id:, intent_dir:, store:, name:
|
|
896
|
-
|
|
945
|
+
def self.arm_auto(session, intent_id:, intent_dir:, store:, name:, harness: nil,
|
|
946
|
+
agent: nil, model: nil, thread: nil)
|
|
947
|
+
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name,
|
|
948
|
+
auto: true, harness: harness, agent: agent, model: model, thread: thread)
|
|
897
949
|
end
|
|
898
950
|
|
|
899
951
|
# Acquire the delivery lock WITHOUT auto mode (intent 96 / Start guided branch).
|
|
900
952
|
# Mirrors arm_auto's lock-stamp + worktree provision but leaves build.auto = false.
|
|
901
953
|
# Same signature as arm_auto; disarm_auto (mode-agnostic) releases a guided lock.
|
|
902
|
-
def self.arm_guided(session, intent_id:, intent_dir:, store:, name:
|
|
903
|
-
|
|
954
|
+
def self.arm_guided(session, intent_id:, intent_dir:, store:, name:, harness: nil,
|
|
955
|
+
agent: nil, model: nil, thread: nil)
|
|
956
|
+
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name,
|
|
957
|
+
auto: false, harness: harness, agent: agent, model: model, thread: thread)
|
|
904
958
|
end
|
|
905
959
|
|
|
906
960
|
# Degrade path for disarm_auto when no intent_id is given (intent 131): the
|
|
@@ -964,10 +1018,20 @@ module Bridge
|
|
|
964
1018
|
# reclaim verb (Lock.takeover). Two entry points call this: the
|
|
965
1019
|
# plastic-lock CLI and /plastic-intent-starting (self-healing boarding).
|
|
966
1020
|
def self.repair_lock(session, intent_id:, intent_dir:, store:, name:,
|
|
967
|
-
now: Time.now, tmp: tmp_dir
|
|
1021
|
+
now: Time.now, tmp: tmp_dir, harness: nil,
|
|
1022
|
+
agent: nil, model: nil, thread: nil, run_mode: nil,
|
|
1023
|
+
hint_harness: nil)
|
|
968
1024
|
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
969
1025
|
dir = File.expand_path(intent_dir)
|
|
970
1026
|
actions = []
|
|
1027
|
+
previous = read(key, intent_id: intent_id, tmp: tmp)
|
|
1028
|
+
auto = !!(previous && previous.dig("build", "auto"))
|
|
1029
|
+
derived_mode = if previous && previous.dig("build").is_a?(Hash) &&
|
|
1030
|
+
previous["build"].key?("auto")
|
|
1031
|
+
auto ? "auto" : "guided"
|
|
1032
|
+
end
|
|
1033
|
+
identity = { harness: harness, agent: agent, model: model, thread: thread,
|
|
1034
|
+
run_mode: blank?(run_mode) ? derived_mode : run_mode.to_s }
|
|
971
1035
|
|
|
972
1036
|
if Lock.corrupt?(dir)
|
|
973
1037
|
File.delete(Lock.path(dir))
|
|
@@ -982,21 +1046,31 @@ module Bridge
|
|
|
982
1046
|
end
|
|
983
1047
|
return { "status" => "stale", "owner" => lock["owner_session"],
|
|
984
1048
|
"actions" => actions, "session" => key,
|
|
985
|
-
"hint" => "run
|
|
1049
|
+
"hint" => "run #{skill_ref('plastic-doctor', harness: hint_harness || harness)} reclaim the " \
|
|
1050
|
+
"lock to take over with an audit" }
|
|
986
1051
|
end
|
|
987
1052
|
|
|
988
1053
|
if lock
|
|
989
|
-
|
|
990
|
-
|
|
1054
|
+
if lock["owner_session"].to_s == key.to_s
|
|
1055
|
+
lock_data = lock.dup
|
|
1056
|
+
{ "owner_harness" => harness, "owner_agent" => agent,
|
|
1057
|
+
"owner_model" => model, "owner_thread" => thread,
|
|
1058
|
+
"run_mode" => identity[:run_mode] }.each do |field, value|
|
|
1059
|
+
lock_data[field] = value.to_s unless blank?(value)
|
|
1060
|
+
end
|
|
1061
|
+
Lock.write(dir, lock_data)
|
|
1062
|
+
Lock.heartbeat(dir, session: key, now: now)
|
|
1063
|
+
else
|
|
1064
|
+
Lock.heartbeat(dir, session: key, now: now)
|
|
1065
|
+
lock_data = Lock.read(dir)
|
|
1066
|
+
end
|
|
991
1067
|
role = lock_data["owner_session"].to_s == key ? "owner" : "delegate"
|
|
992
1068
|
actions << "lock kept (#{role})"
|
|
993
1069
|
else
|
|
994
|
-
status, lock_data = Lock.acquire(dir, session: key, now: now)
|
|
1070
|
+
status, lock_data = Lock.acquire(dir, session: key, now: now, **identity)
|
|
995
1071
|
actions << "lock #{status}"
|
|
996
1072
|
end
|
|
997
1073
|
|
|
998
|
-
previous = read(key, intent_id: intent_id, tmp: tmp)
|
|
999
|
-
auto = !!(previous && previous.dig("build", "auto"))
|
|
1000
1074
|
data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
|
|
1001
1075
|
name: name, tmp: tmp)
|
|
1002
1076
|
data["build"]["auto"] = auto
|
|
@@ -1110,7 +1184,8 @@ module Bridge
|
|
|
1110
1184
|
# target's lock names as owner or delegate (even when stale: a stale lock is
|
|
1111
1185
|
# still its owner's until an explicit takeover).
|
|
1112
1186
|
def self.lock_gate_decision(bridge_data, file_path, session: nil,
|
|
1113
|
-
ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home
|
|
1187
|
+
ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home,
|
|
1188
|
+
harness: :claude)
|
|
1114
1189
|
return nil if blank?(file_path)
|
|
1115
1190
|
|
|
1116
1191
|
target_dir = intent_dir_for(file_path)
|
|
@@ -1138,20 +1213,23 @@ module Bridge
|
|
|
1138
1213
|
"#{lock['owner_session']}. Back off; if you are the owner's " \
|
|
1139
1214
|
"subagent, the owner must run: plastic-lock delegate " \
|
|
1140
1215
|
"--intent-dir #{target_dir} --session <your-session-id>. " \
|
|
1141
|
-
"Inspect with
|
|
1216
|
+
"Inspect with #{skill_ref('plastic-doctor', harness: harness)} check the " \
|
|
1217
|
+
"lock status"
|
|
1142
1218
|
end
|
|
1143
1219
|
return solo_allow(id, "stale delivery lock") if solo
|
|
1144
1220
|
return "intent #{id} has a stale delivery lock (owner " \
|
|
1145
|
-
"#{lock['owner_session']}); run
|
|
1146
|
-
"take it over, or
|
|
1221
|
+
"#{lock['owner_session']}); run #{skill_ref('plastic-doctor', harness: harness)} " \
|
|
1222
|
+
"reclaim the lock to take it over, or " \
|
|
1223
|
+
"#{skill_ref('plastic-doctor', harness: harness)} fix the lock"
|
|
1147
1224
|
end
|
|
1148
1225
|
if Lock.corrupt?(target_dir)
|
|
1149
1226
|
return solo_allow(id, "unreadable delivery.lock") if solo
|
|
1150
|
-
return "delivery.lock for intent #{id} is unreadable; run
|
|
1227
|
+
return "delivery.lock for intent #{id} is unreadable; run " \
|
|
1228
|
+
"#{skill_ref('plastic-doctor', harness: harness)} fix the lock"
|
|
1151
1229
|
end
|
|
1152
1230
|
return solo_allow(id, "no delivery lock") if solo
|
|
1153
|
-
"no delivery lock held for intent #{id}; run
|
|
1154
|
-
"to lock and begin"
|
|
1231
|
+
"no delivery lock held for intent #{id}; run " \
|
|
1232
|
+
"#{skill_ref('plastic-intent-starting', harness: harness)} to lock and begin"
|
|
1155
1233
|
end
|
|
1156
1234
|
|
|
1157
1235
|
# A session holds an intent's lock iff the durable delivery.lock in the
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
# Shared resolution for config_asks.yml: a shipped, declarative manifest that
|
|
7
|
+
# lets a release announce a brand new config question without editing
|
|
8
|
+
# update.rb, doctor.rb, or any skill. Sibling of deprecations.yml (announce
|
|
9
|
+
# only); this one also tracks whether the user has answered or dismissed each
|
|
10
|
+
# entry, via config_asks_dismissed (mirrors deprecations_dismissed).
|
|
11
|
+
#
|
|
12
|
+
# Footgun for whoever adds the next entry: pending reads config.yml directly
|
|
13
|
+
# and never merges in read-config's DEFAULTS, so keying a new entry on
|
|
14
|
+
# something that already has a non-nil value in read-config's DEFAULTS would
|
|
15
|
+
# make that entry look unset, and therefore pending, forever, even though the
|
|
16
|
+
# rest of Plastic already treats it as answered by that default. See the
|
|
17
|
+
# matching note in config_asks.yml's schema header.
|
|
18
|
+
module ConfigAsks
|
|
19
|
+
FILENAME = "config_asks.yml"
|
|
20
|
+
|
|
21
|
+
# nil if the manifest is absent (a legitimate no-op: no release has declared
|
|
22
|
+
# a config question yet) or valid. A short description of the problem if the
|
|
23
|
+
# file exists but could not be read or parsed, or does not declare a
|
|
24
|
+
# config_asks array. Callers use this to tell "nothing declared" apart from
|
|
25
|
+
# "declared but broken" -- the manifest being unreadable must never look
|
|
26
|
+
# like a clean pass.
|
|
27
|
+
def self.manifest_error(plastic_home)
|
|
28
|
+
path = File.join(plastic_home, FILENAME)
|
|
29
|
+
return nil unless File.exist?(path)
|
|
30
|
+
|
|
31
|
+
data = YAML.safe_load(File.read(path))
|
|
32
|
+
return "#{FILENAME} does not declare a config_asks list" unless data.is_a?(Hash) && data["config_asks"].is_a?(Array)
|
|
33
|
+
|
|
34
|
+
nil
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
"#{FILENAME} could not be read: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# All declared entries, or [] if the manifest is missing or malformed. Use
|
|
40
|
+
# manifest_error alongside this when the difference between "no entries"
|
|
41
|
+
# and "could not read the manifest" matters (it always does for a health
|
|
42
|
+
# check or an announcement -- see manifest_error above).
|
|
43
|
+
def self.load_entries(plastic_home)
|
|
44
|
+
path = File.join(plastic_home, FILENAME)
|
|
45
|
+
return [] unless File.exist?(path)
|
|
46
|
+
|
|
47
|
+
data = YAML.safe_load(File.read(path)) || {}
|
|
48
|
+
entries = data["config_asks"]
|
|
49
|
+
entries.is_a?(Array) ? entries : []
|
|
50
|
+
rescue StandardError
|
|
51
|
+
[]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Entries whose key is unset in config.yml AND whose id is not dismissed AND
|
|
55
|
+
# whose agents (if any) include agent_key. Deliberately ignores "introduced"
|
|
56
|
+
# -- see the schema comment in config_asks.yml for why (retro-fire).
|
|
57
|
+
#
|
|
58
|
+
# agent_key: nil means "do not filter by agent" (every entry applies); pass
|
|
59
|
+
# the caller's actual agent ("claude", "codex", "hermes") to respect an
|
|
60
|
+
# entry's agents scoping.
|
|
61
|
+
def self.pending(plastic_home, agent_key = nil)
|
|
62
|
+
config = load_config(plastic_home)
|
|
63
|
+
dismissed = Array(config["config_asks_dismissed"])
|
|
64
|
+
|
|
65
|
+
load_entries(plastic_home).select do |entry|
|
|
66
|
+
next false if dismissed.include?(entry["id"])
|
|
67
|
+
next false unless applies_to_agent?(entry, agent_key)
|
|
68
|
+
|
|
69
|
+
value = dig(config, entry["key"].to_s)
|
|
70
|
+
value.nil? || value == ""
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The exact command that answers one option of one entry.
|
|
75
|
+
def self.write_config_command(plastic_home, key, value)
|
|
76
|
+
"ruby #{File.join(plastic_home, "scripts", "write-config")} #{key} #{value}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The exact command that dismisses one entry ("not now" / keep default).
|
|
80
|
+
def self.dismiss_command(plastic_home, id)
|
|
81
|
+
"ruby #{File.join(plastic_home, "scripts", "write-config")} config_asks_dismissed --push #{id}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# An entry with no agents field (or an empty one) applies to every agent.
|
|
85
|
+
# Otherwise it applies only when agent_key is nil (no filtering requested)
|
|
86
|
+
# or is present in the entry's agents list.
|
|
87
|
+
def self.applies_to_agent?(entry, agent_key)
|
|
88
|
+
scoped = Array(entry["agents"])
|
|
89
|
+
return true if scoped.empty?
|
|
90
|
+
return true if agent_key.nil?
|
|
91
|
+
|
|
92
|
+
scoped.include?(agent_key)
|
|
93
|
+
end
|
|
94
|
+
private_class_method :applies_to_agent?
|
|
95
|
+
|
|
96
|
+
def self.load_config(plastic_home)
|
|
97
|
+
path = File.join(plastic_home, "config.yml")
|
|
98
|
+
return {} unless File.exist?(path)
|
|
99
|
+
|
|
100
|
+
YAML.safe_load(File.read(path)) || {}
|
|
101
|
+
rescue StandardError
|
|
102
|
+
{}
|
|
103
|
+
end
|
|
104
|
+
private_class_method :load_config
|
|
105
|
+
|
|
106
|
+
def self.dig(hash, dotted_key)
|
|
107
|
+
dotted_key.split(".").reduce(hash) { |acc, k| acc.is_a?(Hash) ? acc[k] : nil }
|
|
108
|
+
end
|
|
109
|
+
private_class_method :dig
|
|
110
|
+
end
|
|
@@ -197,11 +197,17 @@ module GraphRebuild
|
|
|
197
197
|
|
|
198
198
|
# Classify a resolved (store, id) relative to the referer, using the live store
|
|
199
199
|
# index to detect dead targets:
|
|
200
|
-
# -
|
|
201
|
-
#
|
|
202
|
-
# - target id present
|
|
200
|
+
# - the store token resolves to no known store -> :unknown_store (NEVER
|
|
201
|
+
# dropped; this is what makes a future discovery miss non-destructive, intent 189 D2)
|
|
202
|
+
# - target id present in the referer's OWN known store -> :same_store (collapse)
|
|
203
|
+
# - target id present in a DIFFERENT known store -> :cross_store (keep slug:id)
|
|
204
|
+
# - target id present NOWHERE in a known store -> :dead (drop)
|
|
203
205
|
def classify(store_tok, bare, referer_store, store_index, original_ref)
|
|
204
206
|
canonical = canonical_store_key(store_tok, store_index)
|
|
207
|
+
unless known_store?(canonical, store_index)
|
|
208
|
+
return { status: :unknown_store, ref: original_ref.to_s, store: store_tok }
|
|
209
|
+
end
|
|
210
|
+
|
|
205
211
|
if ids_in(store_index, canonical).include?(bare)
|
|
206
212
|
if canonical == referer_store
|
|
207
213
|
{ status: :same_store, id: bare }
|
|
@@ -213,6 +219,15 @@ module GraphRebuild
|
|
|
213
219
|
end
|
|
214
220
|
end
|
|
215
221
|
|
|
222
|
+
# True iff `canonical` names a store this run actually knows about (it is "global", or a
|
|
223
|
+
# literal key in `store_index`). A ref whose store token canonicalizes to anything else
|
|
224
|
+
# has never been discovered by this run, and must be classified :unknown_store, never
|
|
225
|
+
# :dead: those are different facts (store unknown vs. id absent from a known store) and
|
|
226
|
+
# only the second one means the ref is genuinely gone.
|
|
227
|
+
def known_store?(canonical, store_index)
|
|
228
|
+
canonical == "global" || (store_index || {}).key?(canonical)
|
|
229
|
+
end
|
|
230
|
+
|
|
216
231
|
def ids_in(store_index, store_key)
|
|
217
232
|
Array((store_index || {})[store_key])
|
|
218
233
|
end
|
|
@@ -225,8 +240,11 @@ module GraphRebuild
|
|
|
225
240
|
# relocation_map — from build_relocation_map (spans all stores)
|
|
226
241
|
# store_index — { store_key => bare ids present } (spans all stores)
|
|
227
242
|
#
|
|
228
|
-
# Returns { nodes: <new map>, changes: [ {intent:, kind:, before:, after:} ]
|
|
229
|
-
#
|
|
243
|
+
# Returns { nodes: <new map>, changes: [ {intent:, kind:, before:, after:} ],
|
|
244
|
+
# preserved: [ {intent:, field:, ref:, store:} ] }.
|
|
245
|
+
# kinds (changes, real mutations only): :dedupe, :i3, :repoint, :collapse, :drop,
|
|
246
|
+
# :i1_backlink. `preserved` is DIFFERENT: an unknown-store ref left byte-for-byte
|
|
247
|
+
# unchanged, reported for visibility, never counted as a "change" (nothing mutated).
|
|
230
248
|
#
|
|
231
249
|
# Order is load-bearing (spec Phase 2):
|
|
232
250
|
# 1. dedupe each array order-preserving
|
|
@@ -247,6 +265,7 @@ module GraphRebuild
|
|
|
247
265
|
end
|
|
248
266
|
|
|
249
267
|
changes = []
|
|
268
|
+
preserved = []
|
|
250
269
|
|
|
251
270
|
out.each do |id, edges|
|
|
252
271
|
# 1. dedupe order-preserving
|
|
@@ -289,6 +308,11 @@ module GraphRebuild
|
|
|
289
308
|
changes << { intent: id, kind: :repoint, field: field, before: ref, after: res[:ref] }
|
|
290
309
|
end
|
|
291
310
|
rebuilt << res[:ref]
|
|
311
|
+
when :unknown_store
|
|
312
|
+
# NEVER drop: the store is unrecognized, not the id absent from a known store.
|
|
313
|
+
# Preserve byte-for-byte and report separately from `changes` (nothing mutated).
|
|
314
|
+
preserved << { intent: id, field: field, ref: ref, store: res[:store] }
|
|
315
|
+
rebuilt << ref
|
|
292
316
|
when :dead
|
|
293
317
|
changes << { intent: id, kind: :drop, field: field, before: ref, after: nil }
|
|
294
318
|
# dropped: not appended
|
|
@@ -323,6 +347,6 @@ module GraphRebuild
|
|
|
323
347
|
end
|
|
324
348
|
end
|
|
325
349
|
|
|
326
|
-
{ nodes: out, changes: changes }
|
|
350
|
+
{ nodes: out, changes: changes, preserved: preserved }
|
|
327
351
|
end
|
|
328
352
|
end
|
|
@@ -48,6 +48,7 @@ module HookRegistry
|
|
|
48
48
|
] },
|
|
49
49
|
{ "matcher" => "Write|Edit", "hooks" => [
|
|
50
50
|
{ "name" => "savepoint-pre", "status" => "Recording stage start..." },
|
|
51
|
+
{ "name" => "links-gate", "status" => "Checking Links gate..." },
|
|
51
52
|
] },
|
|
52
53
|
{ "matcher" => CREATE_MATCHER, "hooks" => [
|
|
53
54
|
{ "name" => "create-gate", "status" => "Checking create gate..." },
|
|
@@ -84,9 +85,29 @@ module HookRegistry
|
|
|
84
85
|
# [{"matcher","hooks":[{"type":"command","command","statusMessage"}]}]}},
|
|
85
86
|
# identical to Claude's shape, string command. Single source of truth (108 D7):
|
|
86
87
|
# any drift from `events` is a bug, pinned by test.
|
|
87
|
-
CODEX_PRE_HOOKS = %w[code-gate lock-gate savepoint-pre create-gate].freeze
|
|
88
|
+
CODEX_PRE_HOOKS = %w[code-gate lock-gate savepoint-pre links-gate create-gate].freeze
|
|
88
89
|
CODEX_POST_HOOKS = %w[gate-check].freeze
|
|
89
90
|
|
|
91
|
+
# Codex's shell-tool gate hole (intent 203): bash-gate (denies a shell write to
|
|
92
|
+
# project code before How) and retrieval-gate (advisory, never denies) both
|
|
93
|
+
# belong on the Bash matcher, and ONLY Bash: the official Codex hooks doc's
|
|
94
|
+
# PreToolUse event catalog enumerates exactly Bash, apply_patch, and MCP tool
|
|
95
|
+
# calls, and neither it nor the two prior Codex research passes (198's
|
|
96
|
+
# official-docs research, 181's deep research) documents a discrete Read,
|
|
97
|
+
# Grep, or Glob tool name (D3). So this does NOT copy Claude's four-name
|
|
98
|
+
# "Bash|Read|Grep|Glob" retrieval-gate matcher; registering a tool name Codex
|
|
99
|
+
# never reports would be dead weight that looks alive, the exact defect this
|
|
100
|
+
# intent exists to fix.
|
|
101
|
+
CODEX_BASH_HOOKS = %w[bash-gate retrieval-gate].freeze
|
|
102
|
+
|
|
103
|
+
# Live-state events registered WHOLE (intent 199), unlike CODEX_PRE_HOOKS/
|
|
104
|
+
# CODEX_POST_HOOKS above: Codex's SessionStart/UserPromptSubmit/PreCompact already
|
|
105
|
+
# match Claude's shape exactly, one matcher group each ("", no tool to collapse
|
|
106
|
+
# onto), so every hook `events` lists under these three events projects straight
|
|
107
|
+
# through with no allowlist to keep in sync. A hook added to any of them on the
|
|
108
|
+
# Claude side registers for Codex automatically.
|
|
109
|
+
CODEX_LIVE_STATE_EVENTS = %w[SessionStart UserPromptSubmit PreCompact].freeze
|
|
110
|
+
|
|
90
111
|
def codex_hooks_json(dispatcher_path:)
|
|
91
112
|
# name => statusMessage, straight from the single `events` source (A8): the
|
|
92
113
|
# guide Part 3 hooks.json format carries a per-hook statusMessage, so emit it.
|
|
@@ -100,11 +121,21 @@ module HookRegistry
|
|
|
100
121
|
# Preserve the order these hook names appear across the PreToolUse groups in `events`.
|
|
101
122
|
pre_order = events["PreToolUse"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
102
123
|
pre = (pre_order & CODEX_PRE_HOOKS).map { |n| cmd.call(n) }
|
|
124
|
+
bash = (pre_order & CODEX_BASH_HOOKS).map { |n| cmd.call(n) }
|
|
103
125
|
post = CODEX_POST_HOOKS.map { |n| cmd.call(n) }
|
|
104
|
-
|
|
105
|
-
|
|
126
|
+
|
|
127
|
+
result = {
|
|
128
|
+
"PreToolUse" => [
|
|
129
|
+
{ "matcher" => "apply_patch", "hooks" => pre },
|
|
130
|
+
{ "matcher" => "Bash", "hooks" => bash },
|
|
131
|
+
],
|
|
106
132
|
"PostToolUse" => [{ "matcher" => "apply_patch", "hooks" => post }],
|
|
107
133
|
}
|
|
134
|
+
CODEX_LIVE_STATE_EVENTS.each do |event|
|
|
135
|
+
names = events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
136
|
+
result[event] = [{ "matcher" => "", "hooks" => names.map { |n| cmd.call(n) } }]
|
|
137
|
+
end
|
|
138
|
+
result
|
|
108
139
|
end
|
|
109
140
|
|
|
110
141
|
# The settings.json shape merge_claude_hooks expects: single-group events map
|