@zalom/plastic 1.4.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.md +28 -11
- package/package.json +1 -1
- package/scripts/codex-hook +122 -8
- package/scripts/dashboard.rb +323 -71
- package/scripts/doctor.rb +230 -1
- package/scripts/hook-lock-gate +8 -3
- package/scripts/install.rb +51 -6
- package/scripts/lib/bridge.rb +76 -26
- package/scripts/lib/hook_registry.rb +32 -2
- package/scripts/lib/installer_core.rb +40 -6
- package/scripts/lib/lock.rb +186 -11
- package/scripts/plastic-lock +76 -9
- package/skills/auto/SKILL.md +29 -6
- package/skills/auto/references/agent-architecture.md +7 -0
- 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/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/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/doctor.rb
CHANGED
|
@@ -1268,12 +1268,35 @@ class Doctor
|
|
|
1268
1268
|
end
|
|
1269
1269
|
end
|
|
1270
1270
|
|
|
1271
|
-
|
|
1271
|
+
hooks_check = codex_hooks_registered_check(config)
|
|
1272
|
+
checks << hooks_check
|
|
1273
|
+
checks << codex_hooks_implemented_check(config)
|
|
1274
|
+
checks << codex_hook_trust_advisory_check if hooks_check[:status] == "pass"
|
|
1272
1275
|
codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
|
|
1273
1276
|
|
|
1274
1277
|
checks
|
|
1275
1278
|
end
|
|
1276
1279
|
|
|
1280
|
+
# Hooks being REGISTERED (hooks.json content matches HookRegistry) is not
|
|
1281
|
+
# the same as hooks being TRUSTED: Codex gates every non-managed hook
|
|
1282
|
+
# command behind a human /hooks review, keyed by the command's current
|
|
1283
|
+
# hash, so a release that changes a hook command re-arms the review
|
|
1284
|
+
# (intent 198, Decision D2). Whether Codex persists a queryable trust
|
|
1285
|
+
# record anywhere under ~/.codex is undocumented and unverified, so this
|
|
1286
|
+
# can never be a real pass or fail on trust state; it is an advisory that
|
|
1287
|
+
# always names the /hooks step, and it fires only once hooks are actually
|
|
1288
|
+
# registered (an unregistered hook is already reported by
|
|
1289
|
+
# codex_hooks_registered_check, so reminding about trust on top of that
|
|
1290
|
+
# would be noise, not signal).
|
|
1291
|
+
def codex_hook_trust_advisory_check
|
|
1292
|
+
check(
|
|
1293
|
+
category: "agent_registration", name: "codex_hooks_trust", status: "warn",
|
|
1294
|
+
message: "Open Codex, run /hooks, and trust the Plastic hook definitions. " \
|
|
1295
|
+
"Codex re-arms this review whenever a hook's command changes.",
|
|
1296
|
+
fixable: false
|
|
1297
|
+
)
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1277
1300
|
# Codex-specific agent presence + structural sanity: ~/.codex/agents/plastic-*.toml
|
|
1278
1301
|
# exist and each carries the mandatory fields. No TOML parser (doctor depends on none):
|
|
1279
1302
|
# "structural" means the mandatory keys appear as lines and the multi-line
|
|
@@ -1315,6 +1338,15 @@ class Doctor
|
|
|
1315
1338
|
has_name && has_desc && !di.nil? && !balanced.nil?
|
|
1316
1339
|
end
|
|
1317
1340
|
|
|
1341
|
+
# Plain string extraction of the single model-selection line a generated
|
|
1342
|
+
# Codex agent TOML carries (model_reasoning_effort for a tier alias, model
|
|
1343
|
+
# for a literal override id; codex_model_fields never emits both). No TOML
|
|
1344
|
+
# parser dependency, mirroring codex_agent_toml_well_formed? above.
|
|
1345
|
+
def codex_agent_toml_model_value(content)
|
|
1346
|
+
m = content.match(/^model_reasoning_effort\s*=\s*"([^"]*)"/) || content.match(/^model\s*=\s*"([^"]*)"/)
|
|
1347
|
+
m && m[1]
|
|
1348
|
+
end
|
|
1349
|
+
|
|
1318
1350
|
# codex_hooks_registered (intent 102, the owner-facing first-run validation
|
|
1319
1351
|
# path, Decision 14): ~/.codex/hooks.json must carry EXACTLY the commands
|
|
1320
1352
|
# HookRegistry.codex_hooks_json defines, mirroring the Claude
|
|
@@ -1356,6 +1388,124 @@ class Doctor
|
|
|
1356
1388
|
end
|
|
1357
1389
|
end
|
|
1358
1390
|
|
|
1391
|
+
# codex_hooks_implemented (intent 200): codex_hooks_registered_check above proves
|
|
1392
|
+
# hooks.json content matches what HookRegistry would emit; both sides of THAT
|
|
1393
|
+
# comparison come from the registry, so a pass only proves the registry agrees
|
|
1394
|
+
# with itself. It never looks at the actual dispatcher, so it cannot see a
|
|
1395
|
+
# registered gate with no real branch there (links-gate shipped exactly this way,
|
|
1396
|
+
# dead, in v1.4.0/intent 192, invisible to doctor and the suite until 198 found it
|
|
1397
|
+
# by hand), or a dispatcher branch nobody registers (bash-gate's shape, intent
|
|
1398
|
+
# 203, in the opposite direction). This check closes both directions at once.
|
|
1399
|
+
|
|
1400
|
+
# The Codex gate names HookRegistry actually registers: the six apply_patch-gated
|
|
1401
|
+
# names (CODEX_PRE_HOOKS + CODEX_POST_HOOKS), the two Bash-matcher shell gates
|
|
1402
|
+
# (CODEX_BASH_HOOKS), and the live-state hook names Codex inherits whole from
|
|
1403
|
+
# `events` (CODEX_LIVE_STATE_EVENTS). No parsing needed: these are HookRegistry's
|
|
1404
|
+
# own Ruby constants.
|
|
1405
|
+
def codex_registry_gate_names
|
|
1406
|
+
live_state = HookRegistry::CODEX_LIVE_STATE_EVENTS.flat_map do |event|
|
|
1407
|
+
HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
1408
|
+
end
|
|
1409
|
+
(HookRegistry::CODEX_PRE_HOOKS + HookRegistry::CODEX_POST_HOOKS +
|
|
1410
|
+
HookRegistry::CODEX_BASH_HOOKS + live_state).uniq
|
|
1411
|
+
end
|
|
1412
|
+
|
|
1413
|
+
# Source-text extraction of scripts/codex-hook's supported gate names (D3): the
|
|
1414
|
+
# dispatcher is an executable script with real top-level side effects (it reads
|
|
1415
|
+
# $stdin and may exit), so it can never be required or executed to introspect it,
|
|
1416
|
+
# only read as plain text, mirroring codex_agent_toml_well_formed? above. Pulls
|
|
1417
|
+
# gate names from the STATE_HOOKS and SHELL_HOOKS %w[] literals, plus the `when
|
|
1418
|
+
# "<name>"` labels of the top-level `case gate` statement (stopping at its
|
|
1419
|
+
# trailing `else`). Line-shape dependent, not AST-safe, disclosed as such in
|
|
1420
|
+
# docs; the healthy-install pass test against the REAL dispatcher is what proves
|
|
1421
|
+
# this shape still holds.
|
|
1422
|
+
#
|
|
1423
|
+
# SELF-CHECKING: returns nil, never [], when nothing recognizable is found. A
|
|
1424
|
+
# reshaped dispatcher (combined `when "a", "b"` arms, a multi-line array, a
|
|
1425
|
+
# Hash-dispatch rewrite) would otherwise silently read as zero gate names, and a
|
|
1426
|
+
# zero-gate dispatcher would make the diff below either flag every registered
|
|
1427
|
+
# hook as "missing" or, worse, quietly under-report a real gap. A check that
|
|
1428
|
+
# finds nothing and calls that healthy IS the exact disease this whole check
|
|
1429
|
+
# exists to catch, one level up, so nil forces the caller (below) to fail loudly
|
|
1430
|
+
# instead of reporting health it never actually verified.
|
|
1431
|
+
def codex_dispatcher_gate_names(source)
|
|
1432
|
+
names = []
|
|
1433
|
+
%w[STATE_HOOKS SHELL_HOOKS].each do |const|
|
|
1434
|
+
m = source.match(/^#{const}\s*=\s*%w\[([^\]]*)\]/)
|
|
1435
|
+
names.concat(m[1].split(/\s+/)) if m
|
|
1436
|
+
end
|
|
1437
|
+
|
|
1438
|
+
case_start = source.index(/^case gate\b/)
|
|
1439
|
+
if case_start
|
|
1440
|
+
case_body = source[case_start..-1]
|
|
1441
|
+
else_idx = case_body.index(/^else\b/)
|
|
1442
|
+
scanned = else_idx ? case_body[0...else_idx] : case_body
|
|
1443
|
+
names.concat(scanned.scan(/^when\s+"([^"]+)"/).flatten)
|
|
1444
|
+
end
|
|
1445
|
+
|
|
1446
|
+
names.uniq!
|
|
1447
|
+
names.empty? ? nil : names
|
|
1448
|
+
end
|
|
1449
|
+
|
|
1450
|
+
# The both-direction diff. Each mismatch becomes one detail line naming the
|
|
1451
|
+
# hook, the direction, the harness ("Codex"), and the concrete runtime effect,
|
|
1452
|
+
# never a generic "Codex hooks drift" message (D4).
|
|
1453
|
+
def codex_hooks_implemented_check(config)
|
|
1454
|
+
dispatcher_path = File.join(plastic_home, "scripts", "codex-hook")
|
|
1455
|
+
|
|
1456
|
+
unless File.exist?(dispatcher_path)
|
|
1457
|
+
return check(
|
|
1458
|
+
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
1459
|
+
message: "scripts/codex-hook not found at #{tilde(dispatcher_path)}; cannot verify " \
|
|
1460
|
+
"the Codex hook registry and dispatcher agree",
|
|
1461
|
+
fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
|
|
1462
|
+
)
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
dispatcher_names = codex_dispatcher_gate_names(File.read(dispatcher_path))
|
|
1466
|
+
|
|
1467
|
+
if dispatcher_names.nil?
|
|
1468
|
+
return check(
|
|
1469
|
+
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
1470
|
+
message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the " \
|
|
1471
|
+
"STATE_HOOKS/SHELL_HOOKS constants and the `case gate` statement no longer " \
|
|
1472
|
+
"match the shape this check expects, so the registry could not be checked " \
|
|
1473
|
+
"against the real dispatcher. This is exactly the silent-pass failure this " \
|
|
1474
|
+
"check exists to prevent; update codex_dispatcher_gate_names in doctor.rb " \
|
|
1475
|
+
"to the file's new shape.",
|
|
1476
|
+
fixable: false
|
|
1477
|
+
)
|
|
1478
|
+
end
|
|
1479
|
+
|
|
1480
|
+
registry_names = codex_registry_gate_names
|
|
1481
|
+
missing_dispatcher_branch = registry_names - dispatcher_names
|
|
1482
|
+
dead_branch = dispatcher_names - registry_names
|
|
1483
|
+
|
|
1484
|
+
if missing_dispatcher_branch.empty? && dead_branch.empty?
|
|
1485
|
+
return check(
|
|
1486
|
+
category: "agent_registration", name: "codex_hooks_implemented", status: "pass",
|
|
1487
|
+
message: "Every Codex-registered gate has a scripts/codex-hook branch, and every " \
|
|
1488
|
+
"dispatcher branch is registered"
|
|
1489
|
+
)
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1492
|
+
details = missing_dispatcher_branch.map do |name|
|
|
1493
|
+
"#{name} is registered in ~/.codex/hooks.json but scripts/codex-hook has no branch " \
|
|
1494
|
+
"for it, so it always falls through to the fail-open else and always allows the write"
|
|
1495
|
+
end
|
|
1496
|
+
details += dead_branch.map do |name|
|
|
1497
|
+
"#{name} has a branch in scripts/codex-hook but is not registered in HookRegistry for " \
|
|
1498
|
+
"Codex, so it is dead code Codex never reaches"
|
|
1499
|
+
end
|
|
1500
|
+
|
|
1501
|
+
check(
|
|
1502
|
+
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
1503
|
+
message: "Codex's hook registry and scripts/codex-hook disagree on #{details.size} gate(s)",
|
|
1504
|
+
details: details,
|
|
1505
|
+
fixable: false
|
|
1506
|
+
)
|
|
1507
|
+
end
|
|
1508
|
+
|
|
1359
1509
|
# config.toml advisory (intent 102, Decision 2, R2): READ ONLY. Warns on the two
|
|
1360
1510
|
# documented footguns (hooks disabled, sandbox read-only); never writes, and
|
|
1361
1511
|
# returns nil (no check emitted) when config.toml is absent or carries neither.
|
|
@@ -1547,6 +1697,8 @@ class Doctor
|
|
|
1547
1697
|
agent_config = agents[agent_key]
|
|
1548
1698
|
return [] unless agent_config
|
|
1549
1699
|
|
|
1700
|
+
return check_agent_model_drift_codex(agent_config) if agent_key == "codex"
|
|
1701
|
+
|
|
1550
1702
|
agents_dir = File.join(agent_config[:dir], "agents")
|
|
1551
1703
|
installed = Dir.glob(File.join(agents_dir, "plastic-*.md")).sort
|
|
1552
1704
|
|
|
@@ -1608,6 +1760,83 @@ class Doctor
|
|
|
1608
1760
|
end
|
|
1609
1761
|
end
|
|
1610
1762
|
|
|
1763
|
+
# Codex leg of agent_model_drift (intent 198, Decision D4): the shared .md
|
|
1764
|
+
# path above is structurally blind on Codex (codex agents are TOML under
|
|
1765
|
+
# ~/.codex/agents/, never ~/.agents/agents/*.md), so it always passed on an
|
|
1766
|
+
# empty glob without opening a single Codex file. This mirrors the same
|
|
1767
|
+
# four buckets (sanctioned override, matches default, drifted,
|
|
1768
|
+
# unclassified) against ~/.codex/agents/plastic-*.toml instead, reading
|
|
1769
|
+
# model / model_reasoning_effort with the same plain string matching
|
|
1770
|
+
# codex_agent_toml_well_formed? already uses (no TOML parser dependency).
|
|
1771
|
+
# The expected value resolves through AgentModels::TIER_DEFAULTS mapped
|
|
1772
|
+
# through AgentModels.effort_for, honoring the Codex-scoped
|
|
1773
|
+
# agents.models.codex.<name> override precedence install_codex's own
|
|
1774
|
+
# agent_model_overrides(harness: "codex") already applies.
|
|
1775
|
+
# AgentModels::CONSULTATION_AGENTS need no special-case bucket here:
|
|
1776
|
+
# generate_codex_agents already skips writing them for Codex entirely, so
|
|
1777
|
+
# the glob below never finds them and there is nothing to classify.
|
|
1778
|
+
def check_agent_model_drift_codex(agent_config)
|
|
1779
|
+
agents_dir = File.join(agent_config[:home_dir], "agents")
|
|
1780
|
+
installed = Dir.glob(File.join(agents_dir, "plastic-*.toml")).sort
|
|
1781
|
+
|
|
1782
|
+
if installed.empty?
|
|
1783
|
+
return [check(
|
|
1784
|
+
category: "core_files", name: "agent_model_drift", status: "pass",
|
|
1785
|
+
message: "No installed plastic-* agent TOML files to check for model drift"
|
|
1786
|
+
)]
|
|
1787
|
+
end
|
|
1788
|
+
|
|
1789
|
+
global_config = load_yaml_safe(File.join(plastic_home, "config.yml")) || {}
|
|
1790
|
+
overrides = AgentModels.override_map(project_config: {}, global_config: global_config, harness: "codex")
|
|
1791
|
+
|
|
1792
|
+
drifted = []
|
|
1793
|
+
sanctioned = []
|
|
1794
|
+
unclassified = []
|
|
1795
|
+
|
|
1796
|
+
installed.each do |path|
|
|
1797
|
+
basename = File.basename(path, ".toml")
|
|
1798
|
+
installed_value = codex_agent_toml_model_value(File.read(path))
|
|
1799
|
+
override = overrides[basename]
|
|
1800
|
+
|
|
1801
|
+
if override
|
|
1802
|
+
sanctioned << "#{basename}: toml=#{installed_value.inspect}, sanctioned override=#{override.inspect}"
|
|
1803
|
+
elsif AgentModels::TIER_DEFAULTS.key?(basename)
|
|
1804
|
+
expected_effort = AgentModels.effort_for(AgentModels::TIER_DEFAULTS[basename])
|
|
1805
|
+
if installed_value != expected_effort
|
|
1806
|
+
drifted << "#{basename}: toml=#{installed_value.inspect}, resolved default effort=#{expected_effort.inspect}"
|
|
1807
|
+
end
|
|
1808
|
+
else
|
|
1809
|
+
unclassified << "#{basename}: toml=#{installed_value.inspect}, no resolved default (basename is in " \
|
|
1810
|
+
"neither AgentModels::TIER_DEFAULTS nor AgentModels::CONSULTATION_AGENTS in " \
|
|
1811
|
+
"scripts/lib/agent_models.rb; add it there, or set agents.models.codex.#{basename} " \
|
|
1812
|
+
"to sanction a model explicitly)"
|
|
1813
|
+
end
|
|
1814
|
+
end
|
|
1815
|
+
|
|
1816
|
+
if drifted.empty? && unclassified.empty?
|
|
1817
|
+
message = if sanctioned.empty?
|
|
1818
|
+
"No agent-model drift (#{installed.size} installed Codex agent TOML(s) match the resolved default)"
|
|
1819
|
+
else
|
|
1820
|
+
"No unsanctioned Codex agent-model drift; #{sanctioned.size} sanctioned override(s) in effect"
|
|
1821
|
+
end
|
|
1822
|
+
[check(
|
|
1823
|
+
category: "core_files", name: "agent_model_drift", status: "pass",
|
|
1824
|
+
message: message,
|
|
1825
|
+
details: sanctioned
|
|
1826
|
+
)]
|
|
1827
|
+
else
|
|
1828
|
+
parts = []
|
|
1829
|
+
parts << "#{drifted.size} installed Codex agent TOML(s) have unsanctioned model drift vs the config-resolved default" if drifted.any?
|
|
1830
|
+
parts << "#{unclassified.size} installed Codex agent TOML(s) have no resolved default in scripts/lib/agent_models.rb" if unclassified.any?
|
|
1831
|
+
[check(
|
|
1832
|
+
category: "core_files", name: "agent_model_drift", status: "warn",
|
|
1833
|
+
message: parts.join("; "),
|
|
1834
|
+
details: drifted + unclassified + sanctioned,
|
|
1835
|
+
fixable: false
|
|
1836
|
+
)]
|
|
1837
|
+
end
|
|
1838
|
+
end
|
|
1839
|
+
|
|
1611
1840
|
# --- Check category: manifest sync (binary core integrity) ---
|
|
1612
1841
|
|
|
1613
1842
|
# Verify, for BOTH the global manifest and the agent-side manifest, that every
|
package/scripts/hook-lock-gate
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
#
|
|
5
|
-
# Usage: hook-lock-gate <file_path> [session_id]
|
|
5
|
+
# Usage: hook-lock-gate <file_path> [session_id] [harness]
|
|
6
6
|
# Fail-CLOSED PreToolUse gate (intent 96). Blocks a mutating write to an active
|
|
7
7
|
# intent's lifecycle dir when THIS session holds no live lock. Project code is NOT
|
|
8
8
|
# gated here (D2). Emits the PreToolUse JSON deny contract at exit 0; reserves
|
|
@@ -16,9 +16,14 @@ begin
|
|
|
16
16
|
file_path = ARGV[0]
|
|
17
17
|
exit 0 unless file_path && !file_path.empty?
|
|
18
18
|
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
19
|
+
# Usage: hook-lock-gate <file_path> [session_id] [harness]. Claude's own bash
|
|
20
|
+
# shim never passes a third arg, so harness falls through to Lock's default
|
|
21
|
+
# (claude, slash form); scripts/codex-hook is the only caller that passes
|
|
22
|
+
# "codex" here (intent 201 D2).
|
|
23
|
+
harness = (ARGV[2] unless ARGV[2].to_s.empty?) || "claude"
|
|
19
24
|
|
|
20
25
|
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no bridge cache
|
|
21
|
-
reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session)
|
|
26
|
+
reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session, harness: harness)
|
|
22
27
|
unless reason
|
|
23
28
|
# Allow path: refresh the lease for the session that holds this target's
|
|
24
29
|
# lock (owner or delegate). Best-effort, never blocks.
|
|
@@ -37,7 +42,7 @@ begin
|
|
|
37
42
|
dir = Bridge.intent_dir_for(file_path)
|
|
38
43
|
artifact = File.basename(file_path) if dir
|
|
39
44
|
if dir && artifact
|
|
40
|
-
claim_reason = Claim.claim_gate_reason(dir, artifact, session: session)
|
|
45
|
+
claim_reason = Claim.claim_gate_reason(dir, artifact, session: session, harness: harness)
|
|
41
46
|
if claim_reason
|
|
42
47
|
print JSON.generate(
|
|
43
48
|
"hookSpecificOutput" => {
|
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
|
|
|
@@ -872,7 +888,8 @@ module Bridge
|
|
|
872
888
|
# and arm_guided (auto: false) are thin delegators so the lock-stamp + provision
|
|
873
889
|
# behaviour stays identical across both modes. Works even when no bridge exists
|
|
874
890
|
# yet (mid-session intent creation).
|
|
875
|
-
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)
|
|
876
893
|
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
877
894
|
if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
|
|
878
895
|
$stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
|
|
@@ -883,24 +900,29 @@ module Bridge
|
|
|
883
900
|
# Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
|
|
884
901
|
# intent dir. The bridge lock block is a cache of the file.
|
|
885
902
|
intent_dir_abs = File.expand_path(intent_dir)
|
|
886
|
-
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")
|
|
887
907
|
case status
|
|
888
908
|
when :acquired, :owned
|
|
889
909
|
data["lock"] = lock_cache(lock_data)
|
|
890
910
|
when :held
|
|
891
911
|
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"
|
|
912
|
+
"session #{lock_data && lock_data['owner_session']}; run " \
|
|
913
|
+
"#{skill_ref('plastic-doctor', harness: harness)} check the lock status"
|
|
894
914
|
when :stale
|
|
895
915
|
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
|
|
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"
|
|
898
919
|
when :excluded
|
|
899
920
|
raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
|
|
900
|
-
"intent #{intent_id}; run
|
|
921
|
+
"intent #{intent_id}; run #{skill_ref('plastic-doctor', harness: harness)} check " \
|
|
922
|
+
"the lock status"
|
|
901
923
|
when :corrupt
|
|
902
924
|
raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
|
|
903
|
-
"run
|
|
925
|
+
"run #{skill_ref('plastic-doctor', harness: harness)} fix the lock"
|
|
904
926
|
end
|
|
905
927
|
|
|
906
928
|
# Provision the per-intent worktrees (mandatory code worktree for project
|
|
@@ -920,15 +942,19 @@ module Bridge
|
|
|
920
942
|
|
|
921
943
|
# Arm auto mode for a session+intent. Works even when no bridge exists yet
|
|
922
944
|
# (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
|
-
|
|
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)
|
|
925
949
|
end
|
|
926
950
|
|
|
927
951
|
# Acquire the delivery lock WITHOUT auto mode (intent 96 / Start guided branch).
|
|
928
952
|
# Mirrors arm_auto's lock-stamp + worktree provision but leaves build.auto = false.
|
|
929
953
|
# 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
|
-
|
|
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)
|
|
932
958
|
end
|
|
933
959
|
|
|
934
960
|
# Degrade path for disarm_auto when no intent_id is given (intent 131): the
|
|
@@ -992,10 +1018,20 @@ module Bridge
|
|
|
992
1018
|
# reclaim verb (Lock.takeover). Two entry points call this: the
|
|
993
1019
|
# plastic-lock CLI and /plastic-intent-starting (self-healing boarding).
|
|
994
1020
|
def self.repair_lock(session, intent_id:, intent_dir:, store:, name:,
|
|
995
|
-
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)
|
|
996
1024
|
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
997
1025
|
dir = File.expand_path(intent_dir)
|
|
998
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 }
|
|
999
1035
|
|
|
1000
1036
|
if Lock.corrupt?(dir)
|
|
1001
1037
|
File.delete(Lock.path(dir))
|
|
@@ -1010,21 +1046,31 @@ module Bridge
|
|
|
1010
1046
|
end
|
|
1011
1047
|
return { "status" => "stale", "owner" => lock["owner_session"],
|
|
1012
1048
|
"actions" => actions, "session" => key,
|
|
1013
|
-
"hint" => "run
|
|
1049
|
+
"hint" => "run #{skill_ref('plastic-doctor', harness: hint_harness || harness)} reclaim the " \
|
|
1050
|
+
"lock to take over with an audit" }
|
|
1014
1051
|
end
|
|
1015
1052
|
|
|
1016
1053
|
if lock
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
|
1019
1067
|
role = lock_data["owner_session"].to_s == key ? "owner" : "delegate"
|
|
1020
1068
|
actions << "lock kept (#{role})"
|
|
1021
1069
|
else
|
|
1022
|
-
status, lock_data = Lock.acquire(dir, session: key, now: now)
|
|
1070
|
+
status, lock_data = Lock.acquire(dir, session: key, now: now, **identity)
|
|
1023
1071
|
actions << "lock #{status}"
|
|
1024
1072
|
end
|
|
1025
1073
|
|
|
1026
|
-
previous = read(key, intent_id: intent_id, tmp: tmp)
|
|
1027
|
-
auto = !!(previous && previous.dig("build", "auto"))
|
|
1028
1074
|
data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
|
|
1029
1075
|
name: name, tmp: tmp)
|
|
1030
1076
|
data["build"]["auto"] = auto
|
|
@@ -1138,7 +1184,8 @@ module Bridge
|
|
|
1138
1184
|
# target's lock names as owner or delegate (even when stale: a stale lock is
|
|
1139
1185
|
# still its owner's until an explicit takeover).
|
|
1140
1186
|
def self.lock_gate_decision(bridge_data, file_path, session: nil,
|
|
1141
|
-
ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home
|
|
1187
|
+
ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home,
|
|
1188
|
+
harness: :claude)
|
|
1142
1189
|
return nil if blank?(file_path)
|
|
1143
1190
|
|
|
1144
1191
|
target_dir = intent_dir_for(file_path)
|
|
@@ -1166,20 +1213,23 @@ module Bridge
|
|
|
1166
1213
|
"#{lock['owner_session']}. Back off; if you are the owner's " \
|
|
1167
1214
|
"subagent, the owner must run: plastic-lock delegate " \
|
|
1168
1215
|
"--intent-dir #{target_dir} --session <your-session-id>. " \
|
|
1169
|
-
"Inspect with
|
|
1216
|
+
"Inspect with #{skill_ref('plastic-doctor', harness: harness)} check the " \
|
|
1217
|
+
"lock status"
|
|
1170
1218
|
end
|
|
1171
1219
|
return solo_allow(id, "stale delivery lock") if solo
|
|
1172
1220
|
return "intent #{id} has a stale delivery lock (owner " \
|
|
1173
|
-
"#{lock['owner_session']}); run
|
|
1174
|
-
"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"
|
|
1175
1224
|
end
|
|
1176
1225
|
if Lock.corrupt?(target_dir)
|
|
1177
1226
|
return solo_allow(id, "unreadable delivery.lock") if solo
|
|
1178
|
-
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"
|
|
1179
1229
|
end
|
|
1180
1230
|
return solo_allow(id, "no delivery lock") if solo
|
|
1181
|
-
"no delivery lock held for intent #{id}; run
|
|
1182
|
-
"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"
|
|
1183
1233
|
end
|
|
1184
1234
|
|
|
1185
1235
|
# 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,21 @@ 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
|
|
109
139
|
end
|
|
110
140
|
|
|
111
141
|
# The settings.json shape merge_claude_hooks expects: single-group events map
|