@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/doctor.rb
CHANGED
|
@@ -52,18 +52,14 @@ class Doctor
|
|
|
52
52
|
# never drift.
|
|
53
53
|
LEGACY_BOOKEND_AMNESTY = LegacyBookendAmnesty::LIST
|
|
54
54
|
|
|
55
|
-
CLAUDE_HOOK_SCRIPTS = %w[
|
|
56
|
-
plastic-session-start
|
|
57
|
-
plastic-check-update
|
|
58
|
-
plastic-savepoint
|
|
59
|
-
plastic-gate-check
|
|
60
|
-
plastic-continue
|
|
61
|
-
plastic-future-intent-check
|
|
62
|
-
plastic-qmd-search
|
|
63
|
-
].freeze
|
|
64
|
-
|
|
65
55
|
CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
|
|
66
56
|
|
|
57
|
+
# Launchers the installer places in the agent's hooks dir that are NOT hooks
|
|
58
|
+
# (intent 204): plastic-statusline is the settings["statusLine"] command, wired
|
|
59
|
+
# outside HookRegistry.events entirely, so it must be excluded from the
|
|
60
|
+
# orphan-launcher scan below or a correct install would report a false orphan.
|
|
61
|
+
CLAUDE_NON_HOOK_LAUNCHERS = %w[plastic-statusline].freeze
|
|
62
|
+
|
|
67
63
|
REQUIRED_SCRIPTS = %w[
|
|
68
64
|
folgezettel-id
|
|
69
65
|
read-config
|
|
@@ -815,7 +811,11 @@ class Doctor
|
|
|
815
811
|
"it PRESERVES any line unbacked by frontmatter that still resolves to a real intent " \
|
|
816
812
|
"(reported as an orphan candidate, never silently deleted); add the missing " \
|
|
817
813
|
"sources/chain edge if the relationship is real, or pass --drop-unbacked-links to " \
|
|
818
|
-
"delete an orphan candidate deliberately."
|
|
814
|
+
"delete an orphan candidate deliberately. Prefer `ruby scripts/maintenance-run --tool " \
|
|
815
|
+
"project-links --intent <id> --apply` over running project-links directly: it detects " \
|
|
816
|
+
"(never acquires) the target's delivery lock, requires a clean store working tree, and " \
|
|
817
|
+
"commits the scoped change plus its revisions.md receipt as one merged operation " \
|
|
818
|
+
"(PLASTIC.md > WORK vs MAINTENANCE)."
|
|
819
819
|
)
|
|
820
820
|
end
|
|
821
821
|
|
|
@@ -1003,13 +1003,17 @@ class Doctor
|
|
|
1003
1003
|
checks = []
|
|
1004
1004
|
hooks_dir = File.join(agent_dir, "hooks")
|
|
1005
1005
|
|
|
1006
|
+
# Derived from HookRegistry.events (intent 204), not a hand-kept list, so
|
|
1007
|
+
# every registered hook (all 15, including the enforcement gates) is checked.
|
|
1008
|
+
expected_launchers = HookRegistry.claude_launcher_names
|
|
1009
|
+
|
|
1006
1010
|
# hooks_exist
|
|
1007
|
-
missing_hooks =
|
|
1011
|
+
missing_hooks = expected_launchers.reject { |h| File.exist?(File.join(hooks_dir, h)) }
|
|
1008
1012
|
|
|
1009
1013
|
if missing_hooks.empty?
|
|
1010
1014
|
checks << check(
|
|
1011
1015
|
category: "agent_registration", name: "hooks_exist", status: "pass",
|
|
1012
|
-
message: "All #{
|
|
1016
|
+
message: "All #{expected_launchers.size} expected hook scripts exist"
|
|
1013
1017
|
)
|
|
1014
1018
|
else
|
|
1015
1019
|
checks << check(
|
|
@@ -1021,7 +1025,7 @@ class Doctor
|
|
|
1021
1025
|
end
|
|
1022
1026
|
|
|
1023
1027
|
# hooks_executable
|
|
1024
|
-
existing_hooks =
|
|
1028
|
+
existing_hooks = expected_launchers
|
|
1025
1029
|
.map { |h| File.join(hooks_dir, h) }
|
|
1026
1030
|
.select { |p| File.exist?(p) }
|
|
1027
1031
|
|
|
@@ -1041,6 +1045,29 @@ class Doctor
|
|
|
1041
1045
|
)
|
|
1042
1046
|
end
|
|
1043
1047
|
|
|
1048
|
+
# hooks_no_orphans: the mirror of hooks_exist. A plastic-* launcher on disk
|
|
1049
|
+
# that HookRegistry does not know about is dead code the next reader would
|
|
1050
|
+
# trust as live (the same drift class as a missing launcher, just facing
|
|
1051
|
+
# the other way). plastic-statusline is a legitimate non-hook installer
|
|
1052
|
+
# artifact (see CLAUDE_NON_HOOK_LAUNCHERS) and is excluded here.
|
|
1053
|
+
present_launchers = Dir.glob(File.join(hooks_dir, "plastic-*")).map { |p| File.basename(p) }
|
|
1054
|
+
orphans = (present_launchers - expected_launchers - CLAUDE_NON_HOOK_LAUNCHERS).sort
|
|
1055
|
+
|
|
1056
|
+
if orphans.empty?
|
|
1057
|
+
checks << check(
|
|
1058
|
+
category: "agent_registration", name: "hooks_no_orphans", status: "pass",
|
|
1059
|
+
message: "No orphaned hook launchers in #{tilde(hooks_dir)}"
|
|
1060
|
+
)
|
|
1061
|
+
else
|
|
1062
|
+
checks << check(
|
|
1063
|
+
category: "agent_registration", name: "hooks_no_orphans", status: "warn",
|
|
1064
|
+
message: "#{orphans.size} hook launcher(s) on disk are not registered in HookRegistry",
|
|
1065
|
+
details: orphans.map { |h| "#{tilde(hooks_dir)}/#{h}" },
|
|
1066
|
+
fixable: true,
|
|
1067
|
+
fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes stale launchers)"
|
|
1068
|
+
)
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1044
1071
|
# hooks_registered — settings.json has Plastic hooks for required events
|
|
1045
1072
|
settings_path = File.join(agent_dir, "settings.json")
|
|
1046
1073
|
settings = read_json_safe(settings_path)
|
|
@@ -1268,12 +1295,35 @@ class Doctor
|
|
|
1268
1295
|
end
|
|
1269
1296
|
end
|
|
1270
1297
|
|
|
1271
|
-
|
|
1298
|
+
hooks_check = codex_hooks_registered_check(config)
|
|
1299
|
+
checks << hooks_check
|
|
1300
|
+
checks << codex_hooks_implemented_check(config)
|
|
1301
|
+
checks << codex_hook_trust_advisory_check if hooks_check[:status] == "pass"
|
|
1272
1302
|
codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
|
|
1273
1303
|
|
|
1274
1304
|
checks
|
|
1275
1305
|
end
|
|
1276
1306
|
|
|
1307
|
+
# Hooks being REGISTERED (hooks.json content matches HookRegistry) is not
|
|
1308
|
+
# the same as hooks being TRUSTED: Codex gates every non-managed hook
|
|
1309
|
+
# command behind a human /hooks review, keyed by the command's current
|
|
1310
|
+
# hash, so a release that changes a hook command re-arms the review
|
|
1311
|
+
# (intent 198, Decision D2). Whether Codex persists a queryable trust
|
|
1312
|
+
# record anywhere under ~/.codex is undocumented and unverified, so this
|
|
1313
|
+
# can never be a real pass or fail on trust state; it is an advisory that
|
|
1314
|
+
# always names the /hooks step, and it fires only once hooks are actually
|
|
1315
|
+
# registered (an unregistered hook is already reported by
|
|
1316
|
+
# codex_hooks_registered_check, so reminding about trust on top of that
|
|
1317
|
+
# would be noise, not signal).
|
|
1318
|
+
def codex_hook_trust_advisory_check
|
|
1319
|
+
check(
|
|
1320
|
+
category: "agent_registration", name: "codex_hooks_trust", status: "warn",
|
|
1321
|
+
message: "Open Codex, run /hooks, and trust the Plastic hook definitions. " \
|
|
1322
|
+
"Codex re-arms this review whenever a hook's command changes.",
|
|
1323
|
+
fixable: false
|
|
1324
|
+
)
|
|
1325
|
+
end
|
|
1326
|
+
|
|
1277
1327
|
# Codex-specific agent presence + structural sanity: ~/.codex/agents/plastic-*.toml
|
|
1278
1328
|
# exist and each carries the mandatory fields. No TOML parser (doctor depends on none):
|
|
1279
1329
|
# "structural" means the mandatory keys appear as lines and the multi-line
|
|
@@ -1315,6 +1365,15 @@ class Doctor
|
|
|
1315
1365
|
has_name && has_desc && !di.nil? && !balanced.nil?
|
|
1316
1366
|
end
|
|
1317
1367
|
|
|
1368
|
+
# Plain string extraction of the single model-selection line a generated
|
|
1369
|
+
# Codex agent TOML carries (model_reasoning_effort for a tier alias, model
|
|
1370
|
+
# for a literal override id; codex_model_fields never emits both). No TOML
|
|
1371
|
+
# parser dependency, mirroring codex_agent_toml_well_formed? above.
|
|
1372
|
+
def codex_agent_toml_model_value(content)
|
|
1373
|
+
m = content.match(/^model_reasoning_effort\s*=\s*"([^"]*)"/) || content.match(/^model\s*=\s*"([^"]*)"/)
|
|
1374
|
+
m && m[1]
|
|
1375
|
+
end
|
|
1376
|
+
|
|
1318
1377
|
# codex_hooks_registered (intent 102, the owner-facing first-run validation
|
|
1319
1378
|
# path, Decision 14): ~/.codex/hooks.json must carry EXACTLY the commands
|
|
1320
1379
|
# HookRegistry.codex_hooks_json defines, mirroring the Claude
|
|
@@ -1356,6 +1415,124 @@ class Doctor
|
|
|
1356
1415
|
end
|
|
1357
1416
|
end
|
|
1358
1417
|
|
|
1418
|
+
# codex_hooks_implemented (intent 200): codex_hooks_registered_check above proves
|
|
1419
|
+
# hooks.json content matches what HookRegistry would emit; both sides of THAT
|
|
1420
|
+
# comparison come from the registry, so a pass only proves the registry agrees
|
|
1421
|
+
# with itself. It never looks at the actual dispatcher, so it cannot see a
|
|
1422
|
+
# registered gate with no real branch there (links-gate shipped exactly this way,
|
|
1423
|
+
# dead, in v1.4.0/intent 192, invisible to doctor and the suite until 198 found it
|
|
1424
|
+
# by hand), or a dispatcher branch nobody registers (bash-gate's shape, intent
|
|
1425
|
+
# 203, in the opposite direction). This check closes both directions at once.
|
|
1426
|
+
|
|
1427
|
+
# The Codex gate names HookRegistry actually registers: the six apply_patch-gated
|
|
1428
|
+
# names (CODEX_PRE_HOOKS + CODEX_POST_HOOKS), the two Bash-matcher shell gates
|
|
1429
|
+
# (CODEX_BASH_HOOKS), and the live-state hook names Codex inherits whole from
|
|
1430
|
+
# `events` (CODEX_LIVE_STATE_EVENTS). No parsing needed: these are HookRegistry's
|
|
1431
|
+
# own Ruby constants.
|
|
1432
|
+
def codex_registry_gate_names
|
|
1433
|
+
live_state = HookRegistry::CODEX_LIVE_STATE_EVENTS.flat_map do |event|
|
|
1434
|
+
HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
1435
|
+
end
|
|
1436
|
+
(HookRegistry::CODEX_PRE_HOOKS + HookRegistry::CODEX_POST_HOOKS +
|
|
1437
|
+
HookRegistry::CODEX_BASH_HOOKS + live_state).uniq
|
|
1438
|
+
end
|
|
1439
|
+
|
|
1440
|
+
# Source-text extraction of scripts/codex-hook's supported gate names (D3): the
|
|
1441
|
+
# dispatcher is an executable script with real top-level side effects (it reads
|
|
1442
|
+
# $stdin and may exit), so it can never be required or executed to introspect it,
|
|
1443
|
+
# only read as plain text, mirroring codex_agent_toml_well_formed? above. Pulls
|
|
1444
|
+
# gate names from the STATE_HOOKS and SHELL_HOOKS %w[] literals, plus the `when
|
|
1445
|
+
# "<name>"` labels of the top-level `case gate` statement (stopping at its
|
|
1446
|
+
# trailing `else`). Line-shape dependent, not AST-safe, disclosed as such in
|
|
1447
|
+
# docs; the healthy-install pass test against the REAL dispatcher is what proves
|
|
1448
|
+
# this shape still holds.
|
|
1449
|
+
#
|
|
1450
|
+
# SELF-CHECKING: returns nil, never [], when nothing recognizable is found. A
|
|
1451
|
+
# reshaped dispatcher (combined `when "a", "b"` arms, a multi-line array, a
|
|
1452
|
+
# Hash-dispatch rewrite) would otherwise silently read as zero gate names, and a
|
|
1453
|
+
# zero-gate dispatcher would make the diff below either flag every registered
|
|
1454
|
+
# hook as "missing" or, worse, quietly under-report a real gap. A check that
|
|
1455
|
+
# finds nothing and calls that healthy IS the exact disease this whole check
|
|
1456
|
+
# exists to catch, one level up, so nil forces the caller (below) to fail loudly
|
|
1457
|
+
# instead of reporting health it never actually verified.
|
|
1458
|
+
def codex_dispatcher_gate_names(source)
|
|
1459
|
+
names = []
|
|
1460
|
+
%w[STATE_HOOKS SHELL_HOOKS].each do |const|
|
|
1461
|
+
m = source.match(/^#{const}\s*=\s*%w\[([^\]]*)\]/)
|
|
1462
|
+
names.concat(m[1].split(/\s+/)) if m
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
case_start = source.index(/^case gate\b/)
|
|
1466
|
+
if case_start
|
|
1467
|
+
case_body = source[case_start..-1]
|
|
1468
|
+
else_idx = case_body.index(/^else\b/)
|
|
1469
|
+
scanned = else_idx ? case_body[0...else_idx] : case_body
|
|
1470
|
+
names.concat(scanned.scan(/^when\s+"([^"]+)"/).flatten)
|
|
1471
|
+
end
|
|
1472
|
+
|
|
1473
|
+
names.uniq!
|
|
1474
|
+
names.empty? ? nil : names
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
# The both-direction diff. Each mismatch becomes one detail line naming the
|
|
1478
|
+
# hook, the direction, the harness ("Codex"), and the concrete runtime effect,
|
|
1479
|
+
# never a generic "Codex hooks drift" message (D4).
|
|
1480
|
+
def codex_hooks_implemented_check(config)
|
|
1481
|
+
dispatcher_path = File.join(plastic_home, "scripts", "codex-hook")
|
|
1482
|
+
|
|
1483
|
+
unless File.exist?(dispatcher_path)
|
|
1484
|
+
return check(
|
|
1485
|
+
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
1486
|
+
message: "scripts/codex-hook not found at #{tilde(dispatcher_path)}; cannot verify " \
|
|
1487
|
+
"the Codex hook registry and dispatcher agree",
|
|
1488
|
+
fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
|
|
1489
|
+
)
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1492
|
+
dispatcher_names = codex_dispatcher_gate_names(File.read(dispatcher_path))
|
|
1493
|
+
|
|
1494
|
+
if dispatcher_names.nil?
|
|
1495
|
+
return check(
|
|
1496
|
+
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
1497
|
+
message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the " \
|
|
1498
|
+
"STATE_HOOKS/SHELL_HOOKS constants and the `case gate` statement no longer " \
|
|
1499
|
+
"match the shape this check expects, so the registry could not be checked " \
|
|
1500
|
+
"against the real dispatcher. This is exactly the silent-pass failure this " \
|
|
1501
|
+
"check exists to prevent; update codex_dispatcher_gate_names in doctor.rb " \
|
|
1502
|
+
"to the file's new shape.",
|
|
1503
|
+
fixable: false
|
|
1504
|
+
)
|
|
1505
|
+
end
|
|
1506
|
+
|
|
1507
|
+
registry_names = codex_registry_gate_names
|
|
1508
|
+
missing_dispatcher_branch = registry_names - dispatcher_names
|
|
1509
|
+
dead_branch = dispatcher_names - registry_names
|
|
1510
|
+
|
|
1511
|
+
if missing_dispatcher_branch.empty? && dead_branch.empty?
|
|
1512
|
+
return check(
|
|
1513
|
+
category: "agent_registration", name: "codex_hooks_implemented", status: "pass",
|
|
1514
|
+
message: "Every Codex-registered gate has a scripts/codex-hook branch, and every " \
|
|
1515
|
+
"dispatcher branch is registered"
|
|
1516
|
+
)
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1519
|
+
details = missing_dispatcher_branch.map do |name|
|
|
1520
|
+
"#{name} is registered in ~/.codex/hooks.json but scripts/codex-hook has no branch " \
|
|
1521
|
+
"for it, so it always falls through to the fail-open else and always allows the write"
|
|
1522
|
+
end
|
|
1523
|
+
details += dead_branch.map do |name|
|
|
1524
|
+
"#{name} has a branch in scripts/codex-hook but is not registered in HookRegistry for " \
|
|
1525
|
+
"Codex, so it is dead code Codex never reaches"
|
|
1526
|
+
end
|
|
1527
|
+
|
|
1528
|
+
check(
|
|
1529
|
+
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
1530
|
+
message: "Codex's hook registry and scripts/codex-hook disagree on #{details.size} gate(s)",
|
|
1531
|
+
details: details,
|
|
1532
|
+
fixable: false
|
|
1533
|
+
)
|
|
1534
|
+
end
|
|
1535
|
+
|
|
1359
1536
|
# config.toml advisory (intent 102, Decision 2, R2): READ ONLY. Warns on the two
|
|
1360
1537
|
# documented footguns (hooks disabled, sandbox read-only); never writes, and
|
|
1361
1538
|
# returns nil (no check emitted) when config.toml is absent or carries neither.
|
|
@@ -1547,6 +1724,8 @@ class Doctor
|
|
|
1547
1724
|
agent_config = agents[agent_key]
|
|
1548
1725
|
return [] unless agent_config
|
|
1549
1726
|
|
|
1727
|
+
return check_agent_model_drift_codex(agent_config) if agent_key == "codex"
|
|
1728
|
+
|
|
1550
1729
|
agents_dir = File.join(agent_config[:dir], "agents")
|
|
1551
1730
|
installed = Dir.glob(File.join(agents_dir, "plastic-*.md")).sort
|
|
1552
1731
|
|
|
@@ -1608,6 +1787,83 @@ class Doctor
|
|
|
1608
1787
|
end
|
|
1609
1788
|
end
|
|
1610
1789
|
|
|
1790
|
+
# Codex leg of agent_model_drift (intent 198, Decision D4): the shared .md
|
|
1791
|
+
# path above is structurally blind on Codex (codex agents are TOML under
|
|
1792
|
+
# ~/.codex/agents/, never ~/.agents/agents/*.md), so it always passed on an
|
|
1793
|
+
# empty glob without opening a single Codex file. This mirrors the same
|
|
1794
|
+
# four buckets (sanctioned override, matches default, drifted,
|
|
1795
|
+
# unclassified) against ~/.codex/agents/plastic-*.toml instead, reading
|
|
1796
|
+
# model / model_reasoning_effort with the same plain string matching
|
|
1797
|
+
# codex_agent_toml_well_formed? already uses (no TOML parser dependency).
|
|
1798
|
+
# The expected value resolves through AgentModels::TIER_DEFAULTS mapped
|
|
1799
|
+
# through AgentModels.effort_for, honoring the Codex-scoped
|
|
1800
|
+
# agents.models.codex.<name> override precedence install_codex's own
|
|
1801
|
+
# agent_model_overrides(harness: "codex") already applies.
|
|
1802
|
+
# AgentModels::CONSULTATION_AGENTS need no special-case bucket here:
|
|
1803
|
+
# generate_codex_agents already skips writing them for Codex entirely, so
|
|
1804
|
+
# the glob below never finds them and there is nothing to classify.
|
|
1805
|
+
def check_agent_model_drift_codex(agent_config)
|
|
1806
|
+
agents_dir = File.join(agent_config[:home_dir], "agents")
|
|
1807
|
+
installed = Dir.glob(File.join(agents_dir, "plastic-*.toml")).sort
|
|
1808
|
+
|
|
1809
|
+
if installed.empty?
|
|
1810
|
+
return [check(
|
|
1811
|
+
category: "core_files", name: "agent_model_drift", status: "pass",
|
|
1812
|
+
message: "No installed plastic-* agent TOML files to check for model drift"
|
|
1813
|
+
)]
|
|
1814
|
+
end
|
|
1815
|
+
|
|
1816
|
+
global_config = load_yaml_safe(File.join(plastic_home, "config.yml")) || {}
|
|
1817
|
+
overrides = AgentModels.override_map(project_config: {}, global_config: global_config, harness: "codex")
|
|
1818
|
+
|
|
1819
|
+
drifted = []
|
|
1820
|
+
sanctioned = []
|
|
1821
|
+
unclassified = []
|
|
1822
|
+
|
|
1823
|
+
installed.each do |path|
|
|
1824
|
+
basename = File.basename(path, ".toml")
|
|
1825
|
+
installed_value = codex_agent_toml_model_value(File.read(path))
|
|
1826
|
+
override = overrides[basename]
|
|
1827
|
+
|
|
1828
|
+
if override
|
|
1829
|
+
sanctioned << "#{basename}: toml=#{installed_value.inspect}, sanctioned override=#{override.inspect}"
|
|
1830
|
+
elsif AgentModels::TIER_DEFAULTS.key?(basename)
|
|
1831
|
+
expected_effort = AgentModels.effort_for(AgentModels::TIER_DEFAULTS[basename])
|
|
1832
|
+
if installed_value != expected_effort
|
|
1833
|
+
drifted << "#{basename}: toml=#{installed_value.inspect}, resolved default effort=#{expected_effort.inspect}"
|
|
1834
|
+
end
|
|
1835
|
+
else
|
|
1836
|
+
unclassified << "#{basename}: toml=#{installed_value.inspect}, no resolved default (basename is in " \
|
|
1837
|
+
"neither AgentModels::TIER_DEFAULTS nor AgentModels::CONSULTATION_AGENTS in " \
|
|
1838
|
+
"scripts/lib/agent_models.rb; add it there, or set agents.models.codex.#{basename} " \
|
|
1839
|
+
"to sanction a model explicitly)"
|
|
1840
|
+
end
|
|
1841
|
+
end
|
|
1842
|
+
|
|
1843
|
+
if drifted.empty? && unclassified.empty?
|
|
1844
|
+
message = if sanctioned.empty?
|
|
1845
|
+
"No agent-model drift (#{installed.size} installed Codex agent TOML(s) match the resolved default)"
|
|
1846
|
+
else
|
|
1847
|
+
"No unsanctioned Codex agent-model drift; #{sanctioned.size} sanctioned override(s) in effect"
|
|
1848
|
+
end
|
|
1849
|
+
[check(
|
|
1850
|
+
category: "core_files", name: "agent_model_drift", status: "pass",
|
|
1851
|
+
message: message,
|
|
1852
|
+
details: sanctioned
|
|
1853
|
+
)]
|
|
1854
|
+
else
|
|
1855
|
+
parts = []
|
|
1856
|
+
parts << "#{drifted.size} installed Codex agent TOML(s) have unsanctioned model drift vs the config-resolved default" if drifted.any?
|
|
1857
|
+
parts << "#{unclassified.size} installed Codex agent TOML(s) have no resolved default in scripts/lib/agent_models.rb" if unclassified.any?
|
|
1858
|
+
[check(
|
|
1859
|
+
category: "core_files", name: "agent_model_drift", status: "warn",
|
|
1860
|
+
message: parts.join("; "),
|
|
1861
|
+
details: drifted + unclassified + sanctioned,
|
|
1862
|
+
fixable: false
|
|
1863
|
+
)]
|
|
1864
|
+
end
|
|
1865
|
+
end
|
|
1866
|
+
|
|
1611
1867
|
# --- Check category: manifest sync (binary core integrity) ---
|
|
1612
1868
|
|
|
1613
1869
|
# Verify, for BOTH the global manifest and the agent-side manifest, that every
|
package/scripts/end-intent
CHANGED
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
require "fileutils"
|
|
68
68
|
require "date"
|
|
69
69
|
require "open3"
|
|
70
|
+
require "pathname"
|
|
70
71
|
require_relative "lib/bridge"
|
|
71
72
|
require_relative "lib/lock"
|
|
72
73
|
require_relative "lib/intent_validator"
|
|
@@ -324,14 +325,25 @@ def git_toplevel(dir)
|
|
|
324
325
|
end
|
|
325
326
|
|
|
326
327
|
# Best-effort store commit: never raises, never blocks the mechanical close.
|
|
327
|
-
# Returns true when a commit was created, false otherwise (not a git repo,
|
|
328
|
-
#
|
|
329
|
-
#
|
|
330
|
-
|
|
328
|
+
# Returns true when a commit was created, false otherwise (not a git repo, nothing to
|
|
329
|
+
# commit, or the commit failed). Pins a local committer identity so the commit succeeds
|
|
330
|
+
# even with no ambient git config (hermetic).
|
|
331
|
+
#
|
|
332
|
+
# SCOPED, never `git add -A` (D17, intent 197): stages only the completing intent's own
|
|
333
|
+
# directory plus the store's INDEX.md, by explicit relative path. An unrelated dirty file
|
|
334
|
+
# elsewhere in the store (another session's uncommitted work, a maintenance session's
|
|
335
|
+
# in-flight change) is left exactly as it was found, never swept into this commit. This is
|
|
336
|
+
# the safety floor that makes a concurrent maintenance session's change-plus-receipt safe on
|
|
337
|
+
# the shared store checkout before intent 178 (store worktrees) lands.
|
|
338
|
+
def store_commit(store, id, disposition, intent_dir:, index_path:)
|
|
331
339
|
root = git_toplevel(store)
|
|
332
340
|
return false if root.nil?
|
|
333
341
|
|
|
334
|
-
|
|
342
|
+
paths = [intent_dir, index_path].select { |p| p && File.exist?(p) }
|
|
343
|
+
.map { |p| relative_to(root, p) }
|
|
344
|
+
return false if paths.empty?
|
|
345
|
+
|
|
346
|
+
Open3.capture3("git", "-C", root, "add", "--", *paths)
|
|
335
347
|
_out, _err, status = Open3.capture3(
|
|
336
348
|
"git", "-C", root,
|
|
337
349
|
"-c", "user.name=Plastic", "-c", "user.email=plastic@localhost",
|
|
@@ -340,6 +352,18 @@ def store_commit(store, id, disposition)
|
|
|
340
352
|
status.success?
|
|
341
353
|
end
|
|
342
354
|
|
|
355
|
+
# `path`, relative to `root` (both absolute). Assumes `path` is inside `root` (true for
|
|
356
|
+
# both intent_dir and index_path here, since both are derived from `store`, itself always
|
|
357
|
+
# inside the same git repo `git_toplevel` resolved `root` from). Uses File.realpath, not
|
|
358
|
+
# File.expand_path: `root` came back from `git rev-parse --show-toplevel`, which resolves
|
|
359
|
+
# symlinked ancestors (e.g. macOS's /var -> /private/var), so an unresolved `path` can start
|
|
360
|
+
# with a different prefix than `root` and produce a bogus, escaping relative path. Both
|
|
361
|
+
# `path` and `root` are known to exist by the time this is called (the caller filters on
|
|
362
|
+
# File.exist? first), so realpath is safe here.
|
|
363
|
+
def relative_to(root, path)
|
|
364
|
+
Pathname.new(File.realpath(path)).relative_path_from(Pathname.new(File.realpath(root))).to_s
|
|
365
|
+
end
|
|
366
|
+
|
|
343
367
|
# --- pre-flight lock guard (D3/D4, intent 188) ------------------------------
|
|
344
368
|
|
|
345
369
|
# Session resolution order (D3): explicit --session, else CLAUDE_CODE_SESSION_ID,
|
|
@@ -617,8 +641,9 @@ def main(argv)
|
|
|
617
641
|
# 3. Savepoint Done bookend (idempotent).
|
|
618
642
|
Bridge.append_terminal_savepoint(intent_dir, disposition)
|
|
619
643
|
|
|
620
|
-
# 4. Store auto-commit, unless --no-commit.
|
|
621
|
-
|
|
644
|
+
# 4. Store auto-commit, unless --no-commit. Scoped to this intent's own paths (D17):
|
|
645
|
+
# never git add -A.
|
|
646
|
+
store_commit(store, id, disposition, intent_dir: intent_dir, index_path: index_path) unless opts[:no_commit]
|
|
622
647
|
|
|
623
648
|
# 5. Disarm (D2/D16): dirty-worktree guard, disarm seam, lock verification.
|
|
624
649
|
result = run_disarm(intent_dir, id, session, discard_worktree_changes: opts[:discard_worktree_changes])
|
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
|