@zalom/plastic 1.3.0 → 1.4.1

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