@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.
Files changed (44) hide show
  1. package/PLASTIC-reference.md +2 -0
  2. package/PLASTIC.md +160 -42
  3. package/agents/plastic-intent-curator.md +10 -2
  4. package/package.json +1 -1
  5. package/scripts/codex-hook +122 -8
  6. package/scripts/dashboard.rb +323 -71
  7. package/scripts/doctor.rb +271 -15
  8. package/scripts/end-intent +32 -7
  9. package/scripts/hook-lock-gate +8 -3
  10. package/scripts/install.rb +51 -6
  11. package/scripts/lib/bridge.rb +79 -30
  12. package/scripts/lib/hook_registry.rb +44 -2
  13. package/scripts/lib/installer_core.rb +45 -6
  14. package/scripts/lib/lock.rb +186 -11
  15. package/scripts/lib/maintenance_git.rb +94 -0
  16. package/scripts/lib/revisions_writer.rb +69 -0
  17. package/scripts/lib/worktree.rb +14 -32
  18. package/scripts/lib/worktree_sweep.rb +129 -0
  19. package/scripts/maintenance-run +236 -0
  20. package/scripts/plastic-lock +76 -9
  21. package/scripts/project-links +127 -24
  22. package/scripts/rebuild-graph +37 -3
  23. package/scripts/restore-intent-v1 +37 -3
  24. package/scripts/sweep-store-worktrees +53 -0
  25. package/skills/auto/SKILL.md +29 -6
  26. package/skills/auto/references/agent-architecture.md +7 -0
  27. package/skills/auto/references/end-tail.md +8 -6
  28. package/skills/dashboard/SKILL.md +48 -25
  29. package/skills/dashboard/evals/evals.json +4 -4
  30. package/skills/dashboard/templates/dashboard-global.md +3 -5
  31. package/skills/dashboard/templates/dashboard-project.md +6 -18
  32. package/skills/doctor/SKILL.md +6 -0
  33. package/skills/intent-locking/SKILL.md +20 -2
  34. package/skills/intent-starting/SKILL.md +6 -4
  35. package/skills/project-continuing/SKILL.md +10 -0
  36. package/skills/project-continuing/evals/evals.json +3 -3
  37. package/skills/project-continuing/references/board-fill.md +13 -11
  38. package/skills/releasing/SKILL.md +3 -3
  39. package/skills/store-curating/SKILL.md +9 -0
  40. package/skills/store-curating/evals/evals.json +16 -0
  41. package/skills/tutorial/SKILL.md +4 -4
  42. package/skills/tutorial/references/track-1-guided.md +2 -1
  43. package/skills/tutorial/references/track-2-auto.md +2 -1
  44. 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 = CLAUDE_HOOK_SCRIPTS.reject { |h| File.exist?(File.join(hooks_dir, h)) }
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 #{CLAUDE_HOOK_SCRIPTS.size} expected hook scripts exist"
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 = CLAUDE_HOOK_SCRIPTS
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
- checks << codex_hooks_registered_check(config)
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
@@ -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
- # nothing to commit, or the commit failed). Pins a local committer identity so
329
- # the commit succeeds even with no ambient git config (hermetic).
330
- def store_commit(store, id, disposition)
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
- Open3.capture3("git", "-C", root, "add", "-A")
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
- store_commit(store, id, disposition) unless opts[:no_commit]
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])
@@ -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" => {
@@ -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
- warn "Plastic v#{installed_version} is already installed."
44
- warn " - To upgrade: npx @zalom/plastic update"
45
- warn " - To re-sync files: npx @zalom/plastic install --reinstall"
46
- return 1
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
- def run(selected:, force: false, reinstall: false, ledger_action: nil, argv: ARGV, input: $stdin)
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[:success]
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