@zalom/plastic 1.4.0 → 1.5.0
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 +2 -0
- package/PLASTIC.md +160 -42
- package/agents/plastic-intent-curator.md +10 -2
- package/package.json +1 -1
- package/scripts/codex-hook +122 -8
- package/scripts/dashboard.rb +323 -71
- package/scripts/doctor.rb +271 -15
- package/scripts/end-intent +32 -7
- package/scripts/hook-lock-gate +8 -3
- package/scripts/install.rb +51 -6
- package/scripts/lib/bridge.rb +79 -30
- package/scripts/lib/hook_registry.rb +44 -2
- package/scripts/lib/installer_core.rb +45 -6
- package/scripts/lib/lock.rb +186 -11
- package/scripts/lib/maintenance_git.rb +94 -0
- package/scripts/lib/revisions_writer.rb +69 -0
- package/scripts/lib/worktree.rb +14 -32
- package/scripts/lib/worktree_sweep.rb +129 -0
- package/scripts/maintenance-run +236 -0
- package/scripts/plastic-lock +76 -9
- package/scripts/project-links +127 -24
- package/scripts/rebuild-graph +37 -3
- package/scripts/restore-intent-v1 +37 -3
- package/scripts/sweep-store-worktrees +53 -0
- package/skills/auto/SKILL.md +29 -6
- package/skills/auto/references/agent-architecture.md +7 -0
- package/skills/auto/references/end-tail.md +8 -6
- 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/doctor/SKILL.md +6 -0
- 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/releasing/SKILL.md +3 -3
- package/skills/store-curating/SKILL.md +9 -0
- package/skills/store-curating/evals/evals.json +16 -0
- 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 +2 -1
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
|
|
|
@@ -782,13 +798,12 @@ module Bridge
|
|
|
782
798
|
"warning_at" => 80,
|
|
783
799
|
"critical_at" => 90
|
|
784
800
|
},
|
|
785
|
-
# Worktree isolation block (intent 73c
|
|
786
|
-
#
|
|
801
|
+
# Worktree isolation block (intent 73c; store-worktree half retired by
|
|
802
|
+
# intent 178). Born unprovisioned; arm_auto calls Worktree.provision to
|
|
803
|
+
# fill it. "code" is an abs path or null.
|
|
787
804
|
"worktree" => {
|
|
788
805
|
"code" => nil,
|
|
789
806
|
"code_branch" => nil,
|
|
790
|
-
"store" => nil,
|
|
791
|
-
"store_branch" => nil,
|
|
792
807
|
"provisioned" => false
|
|
793
808
|
},
|
|
794
809
|
# Delivery-lock CACHE block (intent 108, D2). The durable truth is the
|
|
@@ -872,7 +887,8 @@ module Bridge
|
|
|
872
887
|
# and arm_guided (auto: false) are thin delegators so the lock-stamp + provision
|
|
873
888
|
# behaviour stays identical across both modes. Works even when no bridge exists
|
|
874
889
|
# yet (mid-session intent creation).
|
|
875
|
-
def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:
|
|
890
|
+
def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:, harness: nil,
|
|
891
|
+
agent: nil, model: nil, thread: nil)
|
|
876
892
|
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
877
893
|
if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
|
|
878
894
|
$stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
|
|
@@ -883,24 +899,29 @@ module Bridge
|
|
|
883
899
|
# Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
|
|
884
900
|
# intent dir. The bridge lock block is a cache of the file.
|
|
885
901
|
intent_dir_abs = File.expand_path(intent_dir)
|
|
886
|
-
status, lock_data = Lock.acquire(intent_dir_abs, session: key
|
|
902
|
+
status, lock_data = Lock.acquire(intent_dir_abs, session: key,
|
|
903
|
+
harness: harness, agent: agent,
|
|
904
|
+
model: model, thread: thread,
|
|
905
|
+
run_mode: auto ? "auto" : "guided")
|
|
887
906
|
case status
|
|
888
907
|
when :acquired, :owned
|
|
889
908
|
data["lock"] = lock_cache(lock_data)
|
|
890
909
|
when :held
|
|
891
910
|
raise LockHeldError, "delivery lock for intent #{intent_id} is held by " \
|
|
892
|
-
"session #{lock_data && lock_data['owner_session']}; run
|
|
893
|
-
"check the lock status"
|
|
911
|
+
"session #{lock_data && lock_data['owner_session']}; run " \
|
|
912
|
+
"#{skill_ref('plastic-doctor', harness: harness)} check the lock status"
|
|
894
913
|
when :stale
|
|
895
914
|
raise LockHeldError, "delivery lock for intent #{intent_id} is stale " \
|
|
896
|
-
"(owner #{lock_data && lock_data['owner_session']}); run
|
|
897
|
-
"reclaim the lock to take it
|
|
915
|
+
"(owner #{lock_data && lock_data['owner_session']}); run " \
|
|
916
|
+
"#{skill_ref('plastic-doctor', harness: harness)} reclaim the lock to take it " \
|
|
917
|
+
"over with an audit"
|
|
898
918
|
when :excluded
|
|
899
919
|
raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
|
|
900
|
-
"intent #{intent_id}; run
|
|
920
|
+
"intent #{intent_id}; run #{skill_ref('plastic-doctor', harness: harness)} check " \
|
|
921
|
+
"the lock status"
|
|
901
922
|
when :corrupt
|
|
902
923
|
raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
|
|
903
|
-
"run
|
|
924
|
+
"run #{skill_ref('plastic-doctor', harness: harness)} fix the lock"
|
|
904
925
|
end
|
|
905
926
|
|
|
906
927
|
# Provision the per-intent worktrees (mandatory code worktree for project
|
|
@@ -920,15 +941,19 @@ module Bridge
|
|
|
920
941
|
|
|
921
942
|
# Arm auto mode for a session+intent. Works even when no bridge exists yet
|
|
922
943
|
# (mid-session intent creation). Re-derives intent state, then sets build.auto.
|
|
923
|
-
def self.arm_auto(session, intent_id:, intent_dir:, store:, name:
|
|
924
|
-
|
|
944
|
+
def self.arm_auto(session, intent_id:, intent_dir:, store:, name:, harness: nil,
|
|
945
|
+
agent: nil, model: nil, thread: nil)
|
|
946
|
+
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name,
|
|
947
|
+
auto: true, harness: harness, agent: agent, model: model, thread: thread)
|
|
925
948
|
end
|
|
926
949
|
|
|
927
950
|
# Acquire the delivery lock WITHOUT auto mode (intent 96 / Start guided branch).
|
|
928
951
|
# Mirrors arm_auto's lock-stamp + worktree provision but leaves build.auto = false.
|
|
929
952
|
# Same signature as arm_auto; disarm_auto (mode-agnostic) releases a guided lock.
|
|
930
|
-
def self.arm_guided(session, intent_id:, intent_dir:, store:, name:
|
|
931
|
-
|
|
953
|
+
def self.arm_guided(session, intent_id:, intent_dir:, store:, name:, harness: nil,
|
|
954
|
+
agent: nil, model: nil, thread: nil)
|
|
955
|
+
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name,
|
|
956
|
+
auto: false, harness: harness, agent: agent, model: model, thread: thread)
|
|
932
957
|
end
|
|
933
958
|
|
|
934
959
|
# Degrade path for disarm_auto when no intent_id is given (intent 131): the
|
|
@@ -992,10 +1017,20 @@ module Bridge
|
|
|
992
1017
|
# reclaim verb (Lock.takeover). Two entry points call this: the
|
|
993
1018
|
# plastic-lock CLI and /plastic-intent-starting (self-healing boarding).
|
|
994
1019
|
def self.repair_lock(session, intent_id:, intent_dir:, store:, name:,
|
|
995
|
-
now: Time.now, tmp: tmp_dir
|
|
1020
|
+
now: Time.now, tmp: tmp_dir, harness: nil,
|
|
1021
|
+
agent: nil, model: nil, thread: nil, run_mode: nil,
|
|
1022
|
+
hint_harness: nil)
|
|
996
1023
|
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
997
1024
|
dir = File.expand_path(intent_dir)
|
|
998
1025
|
actions = []
|
|
1026
|
+
previous = read(key, intent_id: intent_id, tmp: tmp)
|
|
1027
|
+
auto = !!(previous && previous.dig("build", "auto"))
|
|
1028
|
+
derived_mode = if previous && previous.dig("build").is_a?(Hash) &&
|
|
1029
|
+
previous["build"].key?("auto")
|
|
1030
|
+
auto ? "auto" : "guided"
|
|
1031
|
+
end
|
|
1032
|
+
identity = { harness: harness, agent: agent, model: model, thread: thread,
|
|
1033
|
+
run_mode: blank?(run_mode) ? derived_mode : run_mode.to_s }
|
|
999
1034
|
|
|
1000
1035
|
if Lock.corrupt?(dir)
|
|
1001
1036
|
File.delete(Lock.path(dir))
|
|
@@ -1010,21 +1045,31 @@ module Bridge
|
|
|
1010
1045
|
end
|
|
1011
1046
|
return { "status" => "stale", "owner" => lock["owner_session"],
|
|
1012
1047
|
"actions" => actions, "session" => key,
|
|
1013
|
-
"hint" => "run
|
|
1048
|
+
"hint" => "run #{skill_ref('plastic-doctor', harness: hint_harness || harness)} reclaim the " \
|
|
1049
|
+
"lock to take over with an audit" }
|
|
1014
1050
|
end
|
|
1015
1051
|
|
|
1016
1052
|
if lock
|
|
1017
|
-
|
|
1018
|
-
|
|
1053
|
+
if lock["owner_session"].to_s == key.to_s
|
|
1054
|
+
lock_data = lock.dup
|
|
1055
|
+
{ "owner_harness" => harness, "owner_agent" => agent,
|
|
1056
|
+
"owner_model" => model, "owner_thread" => thread,
|
|
1057
|
+
"run_mode" => identity[:run_mode] }.each do |field, value|
|
|
1058
|
+
lock_data[field] = value.to_s unless blank?(value)
|
|
1059
|
+
end
|
|
1060
|
+
Lock.write(dir, lock_data)
|
|
1061
|
+
Lock.heartbeat(dir, session: key, now: now)
|
|
1062
|
+
else
|
|
1063
|
+
Lock.heartbeat(dir, session: key, now: now)
|
|
1064
|
+
lock_data = Lock.read(dir)
|
|
1065
|
+
end
|
|
1019
1066
|
role = lock_data["owner_session"].to_s == key ? "owner" : "delegate"
|
|
1020
1067
|
actions << "lock kept (#{role})"
|
|
1021
1068
|
else
|
|
1022
|
-
status, lock_data = Lock.acquire(dir, session: key, now: now)
|
|
1069
|
+
status, lock_data = Lock.acquire(dir, session: key, now: now, **identity)
|
|
1023
1070
|
actions << "lock #{status}"
|
|
1024
1071
|
end
|
|
1025
1072
|
|
|
1026
|
-
previous = read(key, intent_id: intent_id, tmp: tmp)
|
|
1027
|
-
auto = !!(previous && previous.dig("build", "auto"))
|
|
1028
1073
|
data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
|
|
1029
1074
|
name: name, tmp: tmp)
|
|
1030
1075
|
data["build"]["auto"] = auto
|
|
@@ -1138,7 +1183,8 @@ module Bridge
|
|
|
1138
1183
|
# target's lock names as owner or delegate (even when stale: a stale lock is
|
|
1139
1184
|
# still its owner's until an explicit takeover).
|
|
1140
1185
|
def self.lock_gate_decision(bridge_data, file_path, session: nil,
|
|
1141
|
-
ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home
|
|
1186
|
+
ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home,
|
|
1187
|
+
harness: :claude)
|
|
1142
1188
|
return nil if blank?(file_path)
|
|
1143
1189
|
|
|
1144
1190
|
target_dir = intent_dir_for(file_path)
|
|
@@ -1166,20 +1212,23 @@ module Bridge
|
|
|
1166
1212
|
"#{lock['owner_session']}. Back off; if you are the owner's " \
|
|
1167
1213
|
"subagent, the owner must run: plastic-lock delegate " \
|
|
1168
1214
|
"--intent-dir #{target_dir} --session <your-session-id>. " \
|
|
1169
|
-
"Inspect with
|
|
1215
|
+
"Inspect with #{skill_ref('plastic-doctor', harness: harness)} check the " \
|
|
1216
|
+
"lock status"
|
|
1170
1217
|
end
|
|
1171
1218
|
return solo_allow(id, "stale delivery lock") if solo
|
|
1172
1219
|
return "intent #{id} has a stale delivery lock (owner " \
|
|
1173
|
-
"#{lock['owner_session']}); run
|
|
1174
|
-
"take it over, or
|
|
1220
|
+
"#{lock['owner_session']}); run #{skill_ref('plastic-doctor', harness: harness)} " \
|
|
1221
|
+
"reclaim the lock to take it over, or " \
|
|
1222
|
+
"#{skill_ref('plastic-doctor', harness: harness)} fix the lock"
|
|
1175
1223
|
end
|
|
1176
1224
|
if Lock.corrupt?(target_dir)
|
|
1177
1225
|
return solo_allow(id, "unreadable delivery.lock") if solo
|
|
1178
|
-
return "delivery.lock for intent #{id} is unreadable; run
|
|
1226
|
+
return "delivery.lock for intent #{id} is unreadable; run " \
|
|
1227
|
+
"#{skill_ref('plastic-doctor', harness: harness)} fix the lock"
|
|
1179
1228
|
end
|
|
1180
1229
|
return solo_allow(id, "no delivery lock") if solo
|
|
1181
|
-
"no delivery lock held for intent #{id}; run
|
|
1182
|
-
"to lock and begin"
|
|
1230
|
+
"no delivery lock held for intent #{id}; run " \
|
|
1231
|
+
"#{skill_ref('plastic-intent-starting', harness: harness)} to lock and begin"
|
|
1183
1232
|
end
|
|
1184
1233
|
|
|
1185
1234
|
# A session holds an intent's lock iff the durable delivery.lock in the
|
|
@@ -88,6 +88,26 @@ module HookRegistry
|
|
|
88
88
|
CODEX_PRE_HOOKS = %w[code-gate lock-gate savepoint-pre links-gate create-gate].freeze
|
|
89
89
|
CODEX_POST_HOOKS = %w[gate-check].freeze
|
|
90
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
|
+
|
|
91
111
|
def codex_hooks_json(dispatcher_path:)
|
|
92
112
|
# name => statusMessage, straight from the single `events` source (A8): the
|
|
93
113
|
# guide Part 3 hooks.json format carries a per-hook statusMessage, so emit it.
|
|
@@ -101,11 +121,33 @@ module HookRegistry
|
|
|
101
121
|
# Preserve the order these hook names appear across the PreToolUse groups in `events`.
|
|
102
122
|
pre_order = events["PreToolUse"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
103
123
|
pre = (pre_order & CODEX_PRE_HOOKS).map { |n| cmd.call(n) }
|
|
124
|
+
bash = (pre_order & CODEX_BASH_HOOKS).map { |n| cmd.call(n) }
|
|
104
125
|
post = CODEX_POST_HOOKS.map { |n| cmd.call(n) }
|
|
105
|
-
|
|
106
|
-
|
|
126
|
+
|
|
127
|
+
result = {
|
|
128
|
+
"PreToolUse" => [
|
|
129
|
+
{ "matcher" => "apply_patch", "hooks" => pre },
|
|
130
|
+
{ "matcher" => "Bash", "hooks" => bash },
|
|
131
|
+
],
|
|
107
132
|
"PostToolUse" => [{ "matcher" => "apply_patch", "hooks" => post }],
|
|
108
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
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Flattened, deduplicated Claude launcher names for every hook `events`
|
|
142
|
+
# registers (intent 204): each hook name maps to a hooks/<name> launcher
|
|
143
|
+
# installed as ~/.claude/hooks/plastic-<name>. The single derivation doctor's
|
|
144
|
+
# hooks_exist/hooks_executable/hooks_no_orphans checks read from, so a
|
|
145
|
+
# hand-kept list of launchers can never drift out of step with `events`
|
|
146
|
+
# again (the gap that let 8 of 15 launchers, all the enforcement gates, go
|
|
147
|
+
# unchecked).
|
|
148
|
+
def claude_launcher_names
|
|
149
|
+
events.values.flatten.flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
150
|
+
.uniq.sort.map { |name| "plastic-#{name}" }
|
|
109
151
|
end
|
|
110
152
|
|
|
111
153
|
# The settings.json shape merge_claude_hooks expects: single-group events map
|
|
@@ -17,9 +17,10 @@ class InstallerCore
|
|
|
17
17
|
DEFAULT_PLASTIC_HOME = File.join(Dir.home, ".plastic")
|
|
18
18
|
|
|
19
19
|
DEFAULT_AGENTS = [
|
|
20
|
-
{ key: "claude", name: "Claude Code", dir: File.join(Dir.home, ".claude"), flag: "--claude"
|
|
20
|
+
{ key: "claude", name: "Claude Code", dir: File.join(Dir.home, ".claude"), flag: "--claude",
|
|
21
|
+
skill_prefix: "/" },
|
|
21
22
|
{ key: "codex", name: "Codex CLI", dir: File.join(Dir.home, ".agents"),
|
|
22
|
-
home_dir: File.join(Dir.home, ".codex"), flag: "--codex" },
|
|
23
|
+
home_dir: File.join(Dir.home, ".codex"), flag: "--codex", skill_prefix: "$" },
|
|
23
24
|
{ key: "hermes", name: "Hermes", dir: File.join(Dir.home, ".hermes"), flag: "--hermes" },
|
|
24
25
|
].freeze
|
|
25
26
|
|
|
@@ -43,7 +44,8 @@ class InstallerCore
|
|
|
43
44
|
- The full conventions live in ~/.plastic/PLASTIC.md. Read it and follow it exactly.
|
|
44
45
|
It is generated and overwritten on Plastic updates, so never edit it.
|
|
45
46
|
- Operational procedures are installed as skills under ~/.agents/skills/ (each
|
|
46
|
-
plastic-<name>/SKILL.md).
|
|
47
|
+
plastic-<name>/SKILL.md). Invoke one explicitly as $plastic-<name> (for example
|
|
48
|
+
$plastic-doctor), or let Codex pick one implicitly by matching its description.
|
|
47
49
|
- Intents, specs, plans, checklists, and outcomes live under ~/.plastic/, never in
|
|
48
50
|
the project tree.
|
|
49
51
|
|
|
@@ -175,7 +177,7 @@ class InstallerCore
|
|
|
175
177
|
return ["claude"] unless input.tty?
|
|
176
178
|
|
|
177
179
|
puts "Which agents should Plastic register for?\n\n"
|
|
178
|
-
agents.each_with_index { |a, i| puts " #{i + 1}. #{a[:name]} (#{a[:dir]})" }
|
|
180
|
+
agents.each_with_index { |a, i| puts " #{i + 1}. #{a[:name]} (#{a[:home_dir] || a[:dir]})" }
|
|
179
181
|
puts " #{agents.size + 1}. All"
|
|
180
182
|
puts
|
|
181
183
|
|
|
@@ -328,6 +330,11 @@ class InstallerCore
|
|
|
328
330
|
"scripts/rebuild-graph" => "scripts/rebuild-graph",
|
|
329
331
|
"scripts/lib/restore_intent_v1.rb" => "scripts/lib/restore_intent_v1.rb",
|
|
330
332
|
"scripts/restore-intent-v1" => "scripts/restore-intent-v1",
|
|
333
|
+
"scripts/lib/revisions_writer.rb" => "scripts/lib/revisions_writer.rb",
|
|
334
|
+
"scripts/maintenance-run" => "scripts/maintenance-run",
|
|
335
|
+
"scripts/lib/maintenance_git.rb" => "scripts/lib/maintenance_git.rb",
|
|
336
|
+
"scripts/lib/worktree_sweep.rb" => "scripts/lib/worktree_sweep.rb",
|
|
337
|
+
"scripts/sweep-store-worktrees" => "scripts/sweep-store-worktrees",
|
|
331
338
|
"scripts/validate-intent" => "scripts/validate-intent",
|
|
332
339
|
"scripts/new-intent" => "scripts/new-intent",
|
|
333
340
|
"scripts/end-intent" => "scripts/end-intent",
|
|
@@ -420,12 +427,44 @@ class InstallerCore
|
|
|
420
427
|
(data["files"] || {}).keys
|
|
421
428
|
end
|
|
422
429
|
|
|
430
|
+
# Per-agent registration probe (intent 198, D7 follow-up). `installed?` in
|
|
431
|
+
# install.rb only answers "is Plastic core installed at all", which cannot
|
|
432
|
+
# tell two harnesses apart: once core is present, install.rb's old gate
|
|
433
|
+
# refused to add ANY new harness, even one that had never been touched. This
|
|
434
|
+
# asks the narrower, correct question, "has Plastic already registered
|
|
435
|
+
# files for THIS agent", using the signal already tracked for prune-on-update:
|
|
436
|
+
# the per-agent manifest (manifest_path_for). A missing manifest file, or a
|
|
437
|
+
# manifest whose "files" list is empty (write_manifest still writes one when
|
|
438
|
+
# nothing was installed), both mean nothing is registered for this agent yet.
|
|
439
|
+
# An unknown key is never "installed" (fail toward proceeding, since a caller
|
|
440
|
+
# that already validated the key gets its own "Unknown agent" result from
|
|
441
|
+
# install_for_agent).
|
|
442
|
+
def agent_installed?(key)
|
|
443
|
+
config = agent_config(key)
|
|
444
|
+
return false unless config
|
|
445
|
+
!manifest_files(manifest_path_for(key, config)).empty?
|
|
446
|
+
end
|
|
447
|
+
|
|
423
448
|
def install_for_agent(key, force, argv: [], input: $stdin, reinstall: false)
|
|
424
449
|
config = agent_config(key)
|
|
425
450
|
return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
|
|
426
451
|
|
|
427
|
-
|
|
428
|
-
|
|
452
|
+
# Presence probe (intent 198, Decision D1): an agent that declares its own
|
|
453
|
+
# home directory (Codex, home_dir: ~/.codex) is checked THERE, because
|
|
454
|
+
# config[:dir] (~/.agents) is the shared cross-tool skills root, not
|
|
455
|
+
# anything Codex itself creates. A fresh Codex install has no ~/.agents
|
|
456
|
+
# yet, so testing config[:dir] aborted a genuinely-present Codex. Claude
|
|
457
|
+
# and Hermes declare no home_dir, so presence_dir resolves to config[:dir]
|
|
458
|
+
# exactly as before and their behavior is unchanged. The failure message
|
|
459
|
+
# reuses the same resolved directory, so it always names the directory
|
|
460
|
+
# actually tested. install_codex still needs config[:dir] to exist by the
|
|
461
|
+
# time it writes skills; install_skills_flat and generate_codex_agents
|
|
462
|
+
# already FileUtils.mkdir_p their own nested paths under config[:dir] and
|
|
463
|
+
# config[:home_dir], so a fresh install creates it as a side effect (no
|
|
464
|
+
# separate top-level mkdir_p is required here).
|
|
465
|
+
presence_dir = config[:home_dir] || config[:dir]
|
|
466
|
+
unless File.directory?(presence_dir)
|
|
467
|
+
return { agent: config[:name], success: false, reason: "#{presence_dir} not found, #{config[:name]} not installed?" }
|
|
429
468
|
end
|
|
430
469
|
|
|
431
470
|
# Capture the prior manifest so we can prune files that no longer ship
|
package/scripts/lib/lock.rb
CHANGED
|
@@ -24,7 +24,30 @@ require "time"
|
|
|
24
24
|
module Lock
|
|
25
25
|
module_function
|
|
26
26
|
|
|
27
|
+
# Skill-invocation prefix per harness (intent 201, D2/D3). Claude Code invokes a
|
|
28
|
+
# skill with a slash (/plastic-doctor); Codex CLI invokes explicitly with a
|
|
29
|
+
# dollar ($plastic-doctor) and may also select one implicitly by matching the
|
|
30
|
+
# skill's description. This table is the actual source of truth for
|
|
31
|
+
# Bridge.skill_ref (bridge.rb requires lock.rb, never the reverse, so the
|
|
32
|
+
# table lives here rather than pulling Bridge into this dependency-free file
|
|
33
|
+
# just to render two characters). InstallerCore::DEFAULT_AGENTS carries the
|
|
34
|
+
# same values per adapter as documented config (see ACTION_2); this constant
|
|
35
|
+
# is not read from it at runtime, by the same reasoning bridge.rb/hook-*
|
|
36
|
+
# already stay clear of installer_core.rb (spec Alternatives Considered).
|
|
37
|
+
SKILL_PREFIXES = { "claude" => "/", "codex" => "$" }.freeze
|
|
38
|
+
|
|
39
|
+
# Renders a skill reference for the given harness. Unset or unrecognized
|
|
40
|
+
# harness falls back to Claude's slash form, so an existing call site that
|
|
41
|
+
# never passes harness: keeps behaving exactly as it does today (D2). name
|
|
42
|
+
# is the bare skill name ("plastic-doctor"), never pre-prefixed.
|
|
43
|
+
def self.skill_ref(name, harness: :claude)
|
|
44
|
+
prefix = SKILL_PREFIXES.fetch(harness.to_s, SKILL_PREFIXES["claude"])
|
|
45
|
+
"#{prefix}#{name}"
|
|
46
|
+
end
|
|
47
|
+
|
|
27
48
|
TYPES = %w[delivery maintenance].freeze
|
|
49
|
+
DELEGATE_ACTIVITY_LIMIT = 20
|
|
50
|
+
DELEGATE_STATUSES = %w[active finished failed].freeze
|
|
28
51
|
|
|
29
52
|
# Lease TTL. Heartbeats fire from the write-path hooks (PostToolUse
|
|
30
53
|
# gate-check and the lock-gate allow path), so a delivering session
|
|
@@ -84,7 +107,8 @@ module Lock
|
|
|
84
107
|
# [:excluded, other] the OTHER lock type is fresh (D3)
|
|
85
108
|
# [:corrupt, nil] unparseable lock file: run repair
|
|
86
109
|
def acquire(intent_dir, session:, type: "delivery", host: Socket.gethostname,
|
|
87
|
-
ttl: TTL_SECONDS, now: Time.now
|
|
110
|
+
ttl: TTL_SECONDS, now: Time.now, harness: nil, agent: nil,
|
|
111
|
+
model: nil, thread: nil, run_mode: nil)
|
|
88
112
|
raise ArgumentError, "unknown lock type #{type.inspect}" unless TYPES.include?(type)
|
|
89
113
|
raise ArgumentError, "lock session must be present" if blank?(session)
|
|
90
114
|
|
|
@@ -99,7 +123,13 @@ module Lock
|
|
|
99
123
|
if existing
|
|
100
124
|
if existing["owner_session"].to_s == session.to_s
|
|
101
125
|
data = payload(session: session, type: type, host: host, now: now,
|
|
102
|
-
delegates: Array(existing["delegates"])
|
|
126
|
+
delegates: Array(existing["delegates"]),
|
|
127
|
+
delegate_activity: Array(existing["delegate_activity"]),
|
|
128
|
+
harness: merged_value(harness, existing["owner_harness"]),
|
|
129
|
+
agent: merged_value(agent, existing["owner_agent"]),
|
|
130
|
+
model: merged_value(model, existing["owner_model"]),
|
|
131
|
+
thread: merged_value(thread, existing["owner_thread"]),
|
|
132
|
+
run_mode: merged_value(run_mode, existing["run_mode"]))
|
|
103
133
|
write(intent_dir, data, type: type)
|
|
104
134
|
return [:owned, data]
|
|
105
135
|
end
|
|
@@ -107,7 +137,9 @@ module Lock
|
|
|
107
137
|
return [:stale, existing]
|
|
108
138
|
end
|
|
109
139
|
|
|
110
|
-
data = payload(session: session, type: type, host: host, now: now
|
|
140
|
+
data = payload(session: session, type: type, host: host, now: now,
|
|
141
|
+
harness: harness, agent: agent, model: model, thread: thread,
|
|
142
|
+
run_mode: run_mode)
|
|
111
143
|
File.open(path(intent_dir, type: type),
|
|
112
144
|
File::WRONLY | File::CREAT | File::EXCL) do |io|
|
|
113
145
|
io.write(JSON.pretty_generate(data))
|
|
@@ -117,16 +149,49 @@ module Lock
|
|
|
117
149
|
[:held, read(intent_dir, type: type)] # lost the O_EXCL race
|
|
118
150
|
end
|
|
119
151
|
|
|
120
|
-
def payload(session:, type:, host:, now:, delegates: []
|
|
152
|
+
def payload(session:, type:, host:, now:, delegates: [], delegate_activity: [],
|
|
153
|
+
harness: nil, agent: nil, model: nil, thread: nil, run_mode: nil)
|
|
121
154
|
{
|
|
122
155
|
"type" => type,
|
|
123
156
|
"owner_session" => session.to_s,
|
|
124
157
|
"host" => host,
|
|
125
158
|
"acquired_at" => now.utc.iso8601,
|
|
126
159
|
"delegates" => delegates,
|
|
160
|
+
"owner_harness" => normalized_value(harness),
|
|
161
|
+
"owner_agent" => normalized_value(agent),
|
|
162
|
+
"owner_model" => normalized_value(model),
|
|
163
|
+
"owner_thread" => normalized_value(thread),
|
|
164
|
+
"run_mode" => normalized_value(run_mode),
|
|
165
|
+
"delegate_activity" => bounded_delegate_activity(delegate_activity, delegates: delegates),
|
|
127
166
|
}
|
|
128
167
|
end
|
|
129
168
|
|
|
169
|
+
def normalized_value(value)
|
|
170
|
+
blank?(value) ? nil : value.to_s
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def merged_value(explicit, existing)
|
|
174
|
+
blank?(explicit) ? normalized_value(existing) : explicit.to_s
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Keep every active record that still names an authorized delegate, while
|
|
178
|
+
# bounding completed history. Active work is current truth and must never be
|
|
179
|
+
# evicted merely because newer delegates finished.
|
|
180
|
+
def bounded_delegate_activity(records, delegates:)
|
|
181
|
+
records = Array(records)
|
|
182
|
+
authorized = Array(delegates).map(&:to_s)
|
|
183
|
+
terminal_indexes = records.each_index.reject do |index|
|
|
184
|
+
record = records[index]
|
|
185
|
+
record.is_a?(Hash) && record["status"].to_s == "active" &&
|
|
186
|
+
authorized.include?(record["session"].to_s)
|
|
187
|
+
end.last(DELEGATE_ACTIVITY_LIMIT)
|
|
188
|
+
records.each_with_index.filter_map do |record, index|
|
|
189
|
+
active = record.is_a?(Hash) && record["status"].to_s == "active" &&
|
|
190
|
+
authorized.include?(record["session"].to_s)
|
|
191
|
+
record if active || terminal_indexes.include?(index)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
130
195
|
# Owner/delegate heartbeat: touch the mtime, never rewrite content.
|
|
131
196
|
def heartbeat(intent_dir, session:, type: "delivery", now: Time.now)
|
|
132
197
|
return false unless holds?(intent_dir, session: session, type: type)
|
|
@@ -136,11 +201,49 @@ module Lock
|
|
|
136
201
|
|
|
137
202
|
# Owner registers a delegate (D4): a session allowed to write under this
|
|
138
203
|
# lock. Only the OWNER may delegate; delegates cannot re-delegate.
|
|
139
|
-
def add_delegate(intent_dir, delegate:, session:, type: "delivery"
|
|
204
|
+
def add_delegate(intent_dir, delegate:, session:, type: "delivery", now: Time.now,
|
|
205
|
+
harness: nil, agent: nil, model: nil, thread: nil)
|
|
140
206
|
data = read(intent_dir, type: type)
|
|
141
207
|
return false if blank?(delegate)
|
|
142
208
|
return false unless data && data["owner_session"].to_s == session.to_s
|
|
143
209
|
data["delegates"] = (Array(data["delegates"]) + [delegate.to_s]).uniq
|
|
210
|
+
activity = Array(data["delegate_activity"])
|
|
211
|
+
previous = activity.find { |record| record.is_a?(Hash) && record["session"].to_s == delegate.to_s }
|
|
212
|
+
activity.reject! { |record| record.is_a?(Hash) && record["session"].to_s == delegate.to_s }
|
|
213
|
+
record = {
|
|
214
|
+
"session" => delegate.to_s,
|
|
215
|
+
"status" => "active",
|
|
216
|
+
"registered_at" => now.utc.iso8601,
|
|
217
|
+
"last_activity_at" => now.utc.iso8601,
|
|
218
|
+
"harness" => merged_value(harness, previous && previous["harness"]),
|
|
219
|
+
"agent" => merged_value(agent, previous && previous["agent"]),
|
|
220
|
+
"model" => merged_value(model, previous && previous["model"]),
|
|
221
|
+
"thread" => merged_value(thread, previous && previous["thread"]),
|
|
222
|
+
}
|
|
223
|
+
data["delegate_activity"] = bounded_delegate_activity(activity + [record],
|
|
224
|
+
delegates: data["delegates"])
|
|
225
|
+
write(intent_dir, data, type: type)
|
|
226
|
+
true
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Activity metadata is observational only. Finishing or failing a delegate
|
|
230
|
+
# never removes its string session id from the authorization list.
|
|
231
|
+
def update_delegate_status(intent_dir, delegate:, status:, session:, type: "delivery",
|
|
232
|
+
now: Time.now)
|
|
233
|
+
return false unless (DELEGATE_STATUSES - ["active"]).include?(status.to_s)
|
|
234
|
+
data = read(intent_dir, type: type)
|
|
235
|
+
return false unless data && data["owner_session"].to_s == session.to_s
|
|
236
|
+
activity = Array(data["delegate_activity"])
|
|
237
|
+
index = activity.index do |record|
|
|
238
|
+
record.is_a?(Hash) && record["session"].to_s == delegate.to_s
|
|
239
|
+
end
|
|
240
|
+
return false unless index
|
|
241
|
+
activity[index] = activity[index].merge(
|
|
242
|
+
"status" => status.to_s,
|
|
243
|
+
"last_activity_at" => now.utc.iso8601
|
|
244
|
+
)
|
|
245
|
+
data["delegate_activity"] = bounded_delegate_activity(activity,
|
|
246
|
+
delegates: data["delegates"])
|
|
144
247
|
write(intent_dir, data, type: type)
|
|
145
248
|
true
|
|
146
249
|
end
|
|
@@ -164,7 +267,8 @@ module Lock
|
|
|
164
267
|
# lock; there is no silent reclaim path anywhere else.
|
|
165
268
|
# Returns [:taken, data], [:fresh, existing], or acquire's error statuses.
|
|
166
269
|
def takeover(intent_dir, session:, type: "delivery", host: Socket.gethostname,
|
|
167
|
-
ttl: TTL_SECONDS, now: Time.now
|
|
270
|
+
ttl: TTL_SECONDS, now: Time.now, harness: nil, agent: nil,
|
|
271
|
+
model: nil, thread: nil, run_mode: nil)
|
|
168
272
|
existing = read(intent_dir, type: type)
|
|
169
273
|
if existing && !authorized?(existing, session) &&
|
|
170
274
|
fresh?(intent_dir, type: type, ttl: ttl, now: now)
|
|
@@ -175,7 +279,8 @@ module Lock
|
|
|
175
279
|
p = path(intent_dir, type: type)
|
|
176
280
|
File.delete(p) if File.exist?(p)
|
|
177
281
|
status, data = acquire(intent_dir, session: session, type: type, host: host,
|
|
178
|
-
ttl: ttl, now: now
|
|
282
|
+
ttl: ttl, now: now, harness: harness, agent: agent,
|
|
283
|
+
model: model, thread: thread, run_mode: run_mode)
|
|
179
284
|
return [status, data] unless status == :acquired
|
|
180
285
|
|
|
181
286
|
audit = "#{now.utc.iso8601} Lock takeover: #{session} reclaimed #{type} " \
|
|
@@ -190,6 +295,75 @@ module Lock
|
|
|
190
295
|
def write(intent_dir, data, type: "delivery")
|
|
191
296
|
File.write(path(intent_dir, type: type), JSON.pretty_generate(data))
|
|
192
297
|
end
|
|
298
|
+
|
|
299
|
+
# Read-only normalized inspection. The lock file and its mtime remain the
|
|
300
|
+
# sole sources of owner and heartbeat truth; no environment or transcript
|
|
301
|
+
# inference belongs here.
|
|
302
|
+
def who(intent_dir, ttl: TTL_SECONDS, now: Time.now)
|
|
303
|
+
p = path(intent_dir)
|
|
304
|
+
unless File.exist?(p)
|
|
305
|
+
return { "state" => "none",
|
|
306
|
+
"claims" => Claim.claims_status(intent_dir, ttl: ttl, now: now) }
|
|
307
|
+
end
|
|
308
|
+
data = read(intent_dir)
|
|
309
|
+
unless data
|
|
310
|
+
return { "state" => "corrupt",
|
|
311
|
+
"claims" => Claim.claims_status(intent_dir, ttl: ttl, now: now) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
activity = Array(data["delegate_activity"])
|
|
315
|
+
activity_by_session = activity.each_with_object({}) do |record, memo|
|
|
316
|
+
memo[record["session"].to_s] = record if record.is_a?(Hash)
|
|
317
|
+
end
|
|
318
|
+
authorized_sessions = Array(data["delegates"]).map(&:to_s)
|
|
319
|
+
activity_sessions = activity.filter_map do |record|
|
|
320
|
+
record["session"].to_s if record.is_a?(Hash) &&
|
|
321
|
+
authorized_sessions.include?(record["session"].to_s)
|
|
322
|
+
end.uniq
|
|
323
|
+
activity_order = activity.each_with_index.each_with_object({}) do |(record, index), memo|
|
|
324
|
+
memo[record["session"].to_s] = index if record.is_a?(Hash)
|
|
325
|
+
end
|
|
326
|
+
activity_sessions.sort_by! do |session|
|
|
327
|
+
record = activity_by_session[session] || {}
|
|
328
|
+
# Active delegates are current ahead of terminal delegates. Within that
|
|
329
|
+
# group, latest activity wins; original record order is the stable
|
|
330
|
+
# fallback for legacy records without timestamps.
|
|
331
|
+
[record["status"].to_s == "active" ? 1 : 0,
|
|
332
|
+
record["last_activity_at"].to_s, activity_order.fetch(session, -1)]
|
|
333
|
+
end
|
|
334
|
+
# Legacy string-only delegates retain their authorization order. Rich
|
|
335
|
+
# records follow in deterministic current/latest order, so consumers may
|
|
336
|
+
# reliably take the last projection entry: the most recent active delegate
|
|
337
|
+
# when one exists, otherwise the most recent terminal activity.
|
|
338
|
+
ordered_sessions = (authorized_sessions - activity_sessions) + activity_sessions
|
|
339
|
+
delegates = ordered_sessions.map do |session|
|
|
340
|
+
record = activity_by_session[session] || {}
|
|
341
|
+
{
|
|
342
|
+
"session" => session,
|
|
343
|
+
"harness" => normalized_value(record["harness"]) || "unknown",
|
|
344
|
+
"agent" => normalized_value(record["agent"]) || "unknown",
|
|
345
|
+
"model" => normalized_value(record["model"]) || "unknown",
|
|
346
|
+
"thread" => normalized_value(record["thread"]) || "unknown",
|
|
347
|
+
"status" => normalized_value(record["status"]) || "unknown",
|
|
348
|
+
"registered_at" => record["registered_at"],
|
|
349
|
+
"last_activity_at" => record["last_activity_at"],
|
|
350
|
+
}
|
|
351
|
+
end
|
|
352
|
+
{
|
|
353
|
+
"state" => fresh?(intent_dir, ttl: ttl, now: now) ? "fresh" : "stale",
|
|
354
|
+
"owner" => {
|
|
355
|
+
"harness" => normalized_value(data["owner_harness"]) || "unknown",
|
|
356
|
+
"agent" => normalized_value(data["owner_agent"]) || "unknown",
|
|
357
|
+
"model" => normalized_value(data["owner_model"]) || "unknown",
|
|
358
|
+
"thread" => normalized_value(data["owner_thread"]) || "unknown",
|
|
359
|
+
"run_mode" => normalized_value(data["run_mode"]) || "unknown",
|
|
360
|
+
},
|
|
361
|
+
"owner_session" => data["owner_session"],
|
|
362
|
+
"heartbeat_at" => File.mtime(p).utc.iso8601,
|
|
363
|
+
"delegates" => delegates,
|
|
364
|
+
"claims" => Claim.claims_status(intent_dir, ttl: ttl, now: now),
|
|
365
|
+
}
|
|
366
|
+
end
|
|
193
367
|
end
|
|
194
368
|
|
|
195
369
|
# Claim: the per-artifact claim-token layer (intent 111, D1/D7). Sits BENEATH
|
|
@@ -359,7 +533,8 @@ module Claim
|
|
|
359
533
|
# ENGAGES only when a claim file exists (dormant otherwise, so single-owner flows
|
|
360
534
|
# and the existing suite stay green, AC7). Fails open on stale/corrupt via
|
|
361
535
|
# fail_open?, the named contract.
|
|
362
|
-
def claim_gate_reason(intent_dir, artifact, session:, ttl: Lock::TTL_SECONDS, now: Time.now
|
|
536
|
+
def claim_gate_reason(intent_dir, artifact, session:, ttl: Lock::TTL_SECONDS, now: Time.now,
|
|
537
|
+
harness: :claude)
|
|
363
538
|
return nil if Lock.blank?(artifact)
|
|
364
539
|
return nil unless File.exist?(path(intent_dir, artifact)) # dormant: no claim
|
|
365
540
|
return nil if holds_claim?(intent_dir, artifact, session: session) # you hold it
|
|
@@ -368,8 +543,8 @@ module Claim
|
|
|
368
543
|
holder = data && data["owner_session"]
|
|
369
544
|
since = data && data["acquired_at"]
|
|
370
545
|
"artifact #{artifact} is claimed by #{holder} since #{since}; another writer holds " \
|
|
371
|
-
"it. Back off or run
|
|
372
|
-
"delegate, the owner must register you:
|
|
373
|
-
"#{intent_dir} --session <your-session-id>"
|
|
546
|
+
"it. Back off or run #{Lock.skill_ref('plastic-doctor', harness: harness)} check the " \
|
|
547
|
+
"lock status. If you are a distinct delegate, the owner must register you: " \
|
|
548
|
+
"plastic-lock delegate --intent-dir #{intent_dir} --session <your-session-id>"
|
|
374
549
|
end
|
|
375
550
|
end
|