@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
package/scripts/doctor.rb CHANGED
@@ -18,6 +18,7 @@ require "digest"
18
18
  require_relative "lib/qmd_sync"
19
19
  require_relative "lib/intent_validator"
20
20
  require_relative "lib/graph_rebuild"
21
+ require_relative "lib/store_discovery"
21
22
  require_relative "lib/links_projection"
22
23
  require_relative "lib/links_section"
23
24
  require_relative "lib/hook_registry"
@@ -26,6 +27,7 @@ require_relative "lib/bridge"
26
27
  require_relative "lib/agent_models"
27
28
  require_relative "lib/legacy_bookend_amnesty"
28
29
  require_relative "lib/skill_lint"
30
+ require_relative "lib/config_asks"
29
31
 
30
32
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
31
33
  # run it hermetically (no eval, no global-constant rewriting).
@@ -228,33 +230,38 @@ class Doctor
228
230
  end
229
231
  end
230
232
 
233
+ # Single source of truth for "what stores exist" (intent 189), shared with
234
+ # rebuild-graph, project-links, and new-intent via StoreDiscovery. Memoized: one Doctor
235
+ # instance runs many checks against the same plastic_home in a single pass.
236
+ def store_discovery
237
+ @store_discovery ||= StoreDiscovery.discover(plastic_home)
238
+ end
239
+
231
240
  def all_intent_dirs
232
241
  dirs = []
233
-
234
- global_store = File.join(plastic_home, "store")
235
- if File.directory?(global_store)
236
- store_intent_dirs(global_store).each do |entry|
237
- full = File.join(global_store, entry)
238
- dirs << { path: full, name: entry, scope: "global" }
239
- end
240
- end
241
-
242
- projects_root = File.join(plastic_home, "projects")
243
- if File.directory?(projects_root)
244
- Dir.children(projects_root).each do |project|
245
- project_store = File.join(projects_root, project, "store")
246
- next unless File.directory?(project_store)
247
-
248
- store_intent_dirs(project_store).each do |entry|
249
- full = File.join(project_store, entry)
250
- dirs << { path: full, name: entry, scope: "project:#{project}" }
251
- end
242
+ store_discovery[:stores].each do |s|
243
+ store_intent_dirs(s[:store]).each do |entry|
244
+ full = File.join(s[:store], entry)
245
+ dirs << { path: full, name: entry, scope: s[:key] }
252
246
  end
253
247
  end
254
-
255
248
  dirs
256
249
  end
257
250
 
251
+ # A store_index Hash seeded with EVERY store StoreDiscovery reports, mapping a
252
+ # store with zero intents to [] rather than leaving its key absent. Mirrors
253
+ # RebuildGraph#run and ProjectLinks#run, which both set `store_index[key] =
254
+ # nodes.keys` for every discovered store (empty array for an empty store). Without
255
+ # this seed, a store_index built only from intents that parse has no key for a
256
+ # freshly provisioned, still-empty store, so GraphRebuild.classify calls it
257
+ # :unknown_store instead of :dead, disagreeing with the repair tools about the
258
+ # same ref (intent 189 review, finding 1).
259
+ def seeded_store_index
260
+ idx = Hash.new { |h, k| h[k] = [] }
261
+ store_discovery[:stores].each { |s| idx[s[:key]] }
262
+ idx
263
+ end
264
+
258
265
  # --- Check category 1: Global store ---
259
266
 
260
267
  def check_global_store
@@ -432,12 +439,13 @@ class Doctor
432
439
  # well-formed arrays of id strings). Delegates to IntentValidator so the
433
440
  # born-complete contract is defined in exactly one place. Read-only: shape
434
441
  # repair is not a single-field inject, so this check is not fixable here.
442
+ known_stores = store_discovery[:stores].map { |s| s[:slug] }
435
443
  malformed = []
436
444
  intent_dirs.each do |d|
437
445
  md_path = File.join(d[:path], "#{d[:name]}.md")
438
446
  next unless File.exist?(md_path)
439
447
 
440
- result = IntentValidator.validate_frontmatter(parse_frontmatter(md_path))
448
+ result = IntentValidator.validate_frontmatter(parse_frontmatter(md_path), known_stores: known_stores)
441
449
  shape_errors = result[:errors].select { |e| e.include?("must be an array") || e.include?("invalid id") }
442
450
  malformed << { dir: tilde(d[:path]), errors: shape_errors } unless shape_errors.empty?
443
451
  end
@@ -750,7 +758,7 @@ class Doctor
750
758
  def links_projection_check(scopes: nil)
751
759
  all_dirs = all_intent_dirs
752
760
 
753
- store_index = Hash.new { |h, k| h[k] = [] }
761
+ store_index = seeded_store_index
754
762
  node_index = Hash.new { |h, k| h[k] = {} }
755
763
  intents = [] # { scope:, id:, sources:, chain:, path: }
756
764
 
@@ -803,7 +811,11 @@ class Doctor
803
811
  graph_finding_check(
804
812
  "graph_links_projection", findings,
805
813
  "Every intent's ## Links equals its frontmatter projection (membership and ordering)",
806
- "Run scripts/project-links to regenerate the canonical ## Links sections"
814
+ "Run scripts/project-links to regenerate the canonical ## Links sections. By default " \
815
+ "it PRESERVES any line unbacked by frontmatter that still resolves to a real intent " \
816
+ "(reported as an orphan candidate, never silently deleted); add the missing " \
817
+ "sources/chain edge if the relationship is real, or pass --drop-unbacked-links to " \
818
+ "delete an orphan candidate deliberately."
807
819
  )
808
820
  end
809
821
 
@@ -829,7 +841,7 @@ class Doctor
829
841
 
830
842
  # Per-scope node maps + store_index over the WHOLE family.
831
843
  nodes_by_scope = Hash.new { |h, k| h[k] = {} }
832
- store_index = Hash.new { |h, k| h[k] = [] }
844
+ store_index = seeded_store_index
833
845
  all_dirs.each do |d|
834
846
  md = File.join(d[:path], "#{d[:name]}.md")
835
847
  next unless File.exist?(md)
@@ -860,6 +872,9 @@ class Doctor
860
872
  relocation_map: relocation_map,
861
873
  store_index: store_index)
862
874
  case res[:status]
875
+ when :unknown_store
876
+ findings << "#{id}.#{field} cross-store ref #{ref} names a store this scan does " \
877
+ "not recognize (#{res[:store]}); left untouched, verify store discovery"
863
878
  when :dead
864
879
  findings << "#{id}.#{field} cross-store ref #{ref} resolves to no intent (dead)"
865
880
  when :same_store
@@ -880,18 +895,12 @@ class Doctor
880
895
  end
881
896
 
882
897
  # { store_key => INDEX.md text } for every store (global + all projects), for the
883
- # relocation-map builder. Reads INDEX.md one level above each store dir.
898
+ # relocation-map builder. Sourced from the same StoreDiscovery list all_intent_dirs
899
+ # uses, so the two can never enumerate a different set of stores.
884
900
  def cross_store_index_texts
885
901
  texts = {}
886
- global_index = File.join(plastic_home, "INDEX.md")
887
- texts["global"] = File.read(global_index) if File.exist?(global_index)
888
-
889
- projects_root = File.join(plastic_home, "projects")
890
- if File.directory?(projects_root)
891
- Dir.children(projects_root).each do |project|
892
- idx = File.join(projects_root, project, "INDEX.md")
893
- texts["project:#{project}"] = File.read(idx) if File.exist?(idx)
894
- end
902
+ store_discovery[:stores].each do |s|
903
+ texts[s[:key]] = File.read(s[:index]) if File.exist?(s[:index])
895
904
  end
896
905
  texts
897
906
  end
@@ -1259,12 +1268,35 @@ class Doctor
1259
1268
  end
1260
1269
  end
1261
1270
 
1262
- checks << codex_hooks_registered_check(config)
1271
+ hooks_check = codex_hooks_registered_check(config)
1272
+ checks << hooks_check
1273
+ checks << codex_hooks_implemented_check(config)
1274
+ checks << codex_hook_trust_advisory_check if hooks_check[:status] == "pass"
1263
1275
  codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
1264
1276
 
1265
1277
  checks
1266
1278
  end
1267
1279
 
1280
+ # Hooks being REGISTERED (hooks.json content matches HookRegistry) is not
1281
+ # the same as hooks being TRUSTED: Codex gates every non-managed hook
1282
+ # command behind a human /hooks review, keyed by the command's current
1283
+ # hash, so a release that changes a hook command re-arms the review
1284
+ # (intent 198, Decision D2). Whether Codex persists a queryable trust
1285
+ # record anywhere under ~/.codex is undocumented and unverified, so this
1286
+ # can never be a real pass or fail on trust state; it is an advisory that
1287
+ # always names the /hooks step, and it fires only once hooks are actually
1288
+ # registered (an unregistered hook is already reported by
1289
+ # codex_hooks_registered_check, so reminding about trust on top of that
1290
+ # would be noise, not signal).
1291
+ def codex_hook_trust_advisory_check
1292
+ check(
1293
+ category: "agent_registration", name: "codex_hooks_trust", status: "warn",
1294
+ message: "Open Codex, run /hooks, and trust the Plastic hook definitions. " \
1295
+ "Codex re-arms this review whenever a hook's command changes.",
1296
+ fixable: false
1297
+ )
1298
+ end
1299
+
1268
1300
  # Codex-specific agent presence + structural sanity: ~/.codex/agents/plastic-*.toml
1269
1301
  # exist and each carries the mandatory fields. No TOML parser (doctor depends on none):
1270
1302
  # "structural" means the mandatory keys appear as lines and the multi-line
@@ -1306,6 +1338,15 @@ class Doctor
1306
1338
  has_name && has_desc && !di.nil? && !balanced.nil?
1307
1339
  end
1308
1340
 
1341
+ # Plain string extraction of the single model-selection line a generated
1342
+ # Codex agent TOML carries (model_reasoning_effort for a tier alias, model
1343
+ # for a literal override id; codex_model_fields never emits both). No TOML
1344
+ # parser dependency, mirroring codex_agent_toml_well_formed? above.
1345
+ def codex_agent_toml_model_value(content)
1346
+ m = content.match(/^model_reasoning_effort\s*=\s*"([^"]*)"/) || content.match(/^model\s*=\s*"([^"]*)"/)
1347
+ m && m[1]
1348
+ end
1349
+
1309
1350
  # codex_hooks_registered (intent 102, the owner-facing first-run validation
1310
1351
  # path, Decision 14): ~/.codex/hooks.json must carry EXACTLY the commands
1311
1352
  # HookRegistry.codex_hooks_json defines, mirroring the Claude
@@ -1347,6 +1388,124 @@ class Doctor
1347
1388
  end
1348
1389
  end
1349
1390
 
1391
+ # codex_hooks_implemented (intent 200): codex_hooks_registered_check above proves
1392
+ # hooks.json content matches what HookRegistry would emit; both sides of THAT
1393
+ # comparison come from the registry, so a pass only proves the registry agrees
1394
+ # with itself. It never looks at the actual dispatcher, so it cannot see a
1395
+ # registered gate with no real branch there (links-gate shipped exactly this way,
1396
+ # dead, in v1.4.0/intent 192, invisible to doctor and the suite until 198 found it
1397
+ # by hand), or a dispatcher branch nobody registers (bash-gate's shape, intent
1398
+ # 203, in the opposite direction). This check closes both directions at once.
1399
+
1400
+ # The Codex gate names HookRegistry actually registers: the six apply_patch-gated
1401
+ # names (CODEX_PRE_HOOKS + CODEX_POST_HOOKS), the two Bash-matcher shell gates
1402
+ # (CODEX_BASH_HOOKS), and the live-state hook names Codex inherits whole from
1403
+ # `events` (CODEX_LIVE_STATE_EVENTS). No parsing needed: these are HookRegistry's
1404
+ # own Ruby constants.
1405
+ def codex_registry_gate_names
1406
+ live_state = HookRegistry::CODEX_LIVE_STATE_EVENTS.flat_map do |event|
1407
+ HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
1408
+ end
1409
+ (HookRegistry::CODEX_PRE_HOOKS + HookRegistry::CODEX_POST_HOOKS +
1410
+ HookRegistry::CODEX_BASH_HOOKS + live_state).uniq
1411
+ end
1412
+
1413
+ # Source-text extraction of scripts/codex-hook's supported gate names (D3): the
1414
+ # dispatcher is an executable script with real top-level side effects (it reads
1415
+ # $stdin and may exit), so it can never be required or executed to introspect it,
1416
+ # only read as plain text, mirroring codex_agent_toml_well_formed? above. Pulls
1417
+ # gate names from the STATE_HOOKS and SHELL_HOOKS %w[] literals, plus the `when
1418
+ # "<name>"` labels of the top-level `case gate` statement (stopping at its
1419
+ # trailing `else`). Line-shape dependent, not AST-safe, disclosed as such in
1420
+ # docs; the healthy-install pass test against the REAL dispatcher is what proves
1421
+ # this shape still holds.
1422
+ #
1423
+ # SELF-CHECKING: returns nil, never [], when nothing recognizable is found. A
1424
+ # reshaped dispatcher (combined `when "a", "b"` arms, a multi-line array, a
1425
+ # Hash-dispatch rewrite) would otherwise silently read as zero gate names, and a
1426
+ # zero-gate dispatcher would make the diff below either flag every registered
1427
+ # hook as "missing" or, worse, quietly under-report a real gap. A check that
1428
+ # finds nothing and calls that healthy IS the exact disease this whole check
1429
+ # exists to catch, one level up, so nil forces the caller (below) to fail loudly
1430
+ # instead of reporting health it never actually verified.
1431
+ def codex_dispatcher_gate_names(source)
1432
+ names = []
1433
+ %w[STATE_HOOKS SHELL_HOOKS].each do |const|
1434
+ m = source.match(/^#{const}\s*=\s*%w\[([^\]]*)\]/)
1435
+ names.concat(m[1].split(/\s+/)) if m
1436
+ end
1437
+
1438
+ case_start = source.index(/^case gate\b/)
1439
+ if case_start
1440
+ case_body = source[case_start..-1]
1441
+ else_idx = case_body.index(/^else\b/)
1442
+ scanned = else_idx ? case_body[0...else_idx] : case_body
1443
+ names.concat(scanned.scan(/^when\s+"([^"]+)"/).flatten)
1444
+ end
1445
+
1446
+ names.uniq!
1447
+ names.empty? ? nil : names
1448
+ end
1449
+
1450
+ # The both-direction diff. Each mismatch becomes one detail line naming the
1451
+ # hook, the direction, the harness ("Codex"), and the concrete runtime effect,
1452
+ # never a generic "Codex hooks drift" message (D4).
1453
+ def codex_hooks_implemented_check(config)
1454
+ dispatcher_path = File.join(plastic_home, "scripts", "codex-hook")
1455
+
1456
+ unless File.exist?(dispatcher_path)
1457
+ return check(
1458
+ category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
1459
+ message: "scripts/codex-hook not found at #{tilde(dispatcher_path)}; cannot verify " \
1460
+ "the Codex hook registry and dispatcher agree",
1461
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1462
+ )
1463
+ end
1464
+
1465
+ dispatcher_names = codex_dispatcher_gate_names(File.read(dispatcher_path))
1466
+
1467
+ if dispatcher_names.nil?
1468
+ return check(
1469
+ category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
1470
+ message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the " \
1471
+ "STATE_HOOKS/SHELL_HOOKS constants and the `case gate` statement no longer " \
1472
+ "match the shape this check expects, so the registry could not be checked " \
1473
+ "against the real dispatcher. This is exactly the silent-pass failure this " \
1474
+ "check exists to prevent; update codex_dispatcher_gate_names in doctor.rb " \
1475
+ "to the file's new shape.",
1476
+ fixable: false
1477
+ )
1478
+ end
1479
+
1480
+ registry_names = codex_registry_gate_names
1481
+ missing_dispatcher_branch = registry_names - dispatcher_names
1482
+ dead_branch = dispatcher_names - registry_names
1483
+
1484
+ if missing_dispatcher_branch.empty? && dead_branch.empty?
1485
+ return check(
1486
+ category: "agent_registration", name: "codex_hooks_implemented", status: "pass",
1487
+ message: "Every Codex-registered gate has a scripts/codex-hook branch, and every " \
1488
+ "dispatcher branch is registered"
1489
+ )
1490
+ end
1491
+
1492
+ details = missing_dispatcher_branch.map do |name|
1493
+ "#{name} is registered in ~/.codex/hooks.json but scripts/codex-hook has no branch " \
1494
+ "for it, so it always falls through to the fail-open else and always allows the write"
1495
+ end
1496
+ details += dead_branch.map do |name|
1497
+ "#{name} has a branch in scripts/codex-hook but is not registered in HookRegistry for " \
1498
+ "Codex, so it is dead code Codex never reaches"
1499
+ end
1500
+
1501
+ check(
1502
+ category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
1503
+ message: "Codex's hook registry and scripts/codex-hook disagree on #{details.size} gate(s)",
1504
+ details: details,
1505
+ fixable: false
1506
+ )
1507
+ end
1508
+
1350
1509
  # config.toml advisory (intent 102, Decision 2, R2): READ ONLY. Warns on the two
1351
1510
  # documented footguns (hooks disabled, sandbox read-only); never writes, and
1352
1511
  # returns nil (no check emitted) when config.toml is absent or carries neither.
@@ -1489,29 +1648,57 @@ class Doctor
1489
1648
  end
1490
1649
 
1491
1650
  # agent_model_drift - advisory (never fail) config-vs-frontmatter comparison
1492
- # for every installed <agent_dir>/agents/plastic-*.md file (intent 170, D1).
1493
- # Resolution mirrors read-config's own precedence (project override -> global
1494
- # override -> shipped AgentModels::TIER_DEFAULTS), reusing AgentModels'
1495
- # `override_map` directly so this stays hermetically DI-testable (no
1496
- # shell-out to `read-config`, no ENV/global seam). There is no live project
1497
- # scope at doctor-run time for a globally-installed agent file, so the
1498
- # project layer is empty here; the resolver still supports one, matching the
1499
- # same `override_map(project_config:, global_config:)` shape the installer
1500
- # already calls.
1651
+ # for every installed <agent_dir>/agents/plastic-*.md file (intent 170, D1;
1652
+ # reclassified intent 191). Resolution mirrors read-config's own precedence
1653
+ # (project override -> global override -> shipped AgentModels::TIER_DEFAULTS),
1654
+ # reusing AgentModels' `override_map` directly so this stays hermetically
1655
+ # DI-testable (no shell-out to `read-config`, no ENV/global seam). There is
1656
+ # no live project scope at doctor-run time for a globally-installed agent
1657
+ # file, so the project layer is empty here; the resolver still supports one,
1658
+ # matching the same `override_map(project_config:, global_config:)` shape
1659
+ # the installer already calls.
1501
1660
  #
1502
- # Classification per installed agent basename:
1503
- # - no `agents.models.<basename>` override AND frontmatter == resolved
1504
- # default -> pass (clean)
1505
- # - no override AND frontmatter != resolved default -> warn (real drift)
1506
- # - an override IS configured -> pass/informational, LISTED as a
1507
- # sanctioned override, regardless of whether frontmatter matches (that
1508
- # mismatch is the override working, e.g. `plastic-brainstorming: fable`)
1661
+ # This check deliberately never diffs installed frontmatter against the
1662
+ # shipped agents/*.md source tree directly (considered and rejected, intent
1663
+ # 191): at real doctor run time doctor.rb lives at ~/.plastic/scripts/doctor.rb,
1664
+ # and the installer copies only scripts/ into ~/.plastic, never the agents/
1665
+ # tree, so a "compare against shipped frontmatter" check would find nothing
1666
+ # in the field and pass silently on every real installation while only ever
1667
+ # exercising itself inside the repo, the same dead-in-production shape
1668
+ # already documented above check_skill_lint. The only registry of sanctioned
1669
+ # defaults reachable at doctor run time is scripts/lib/agent_models.rb (it
1670
+ # IS copied into ~/.plastic/scripts/lib), so this check classifies each
1671
+ # installed basename using ONLY the two registries that module already
1672
+ # ships, TIER_DEFAULTS and CONSULTATION_AGENTS. No new model values are
1673
+ # added anywhere.
1674
+ #
1675
+ # Classification per installed agent basename, checked in this order:
1676
+ # 1. an `agents.models.<basename>` override IS configured -> sanctioned,
1677
+ # pass/informational, LISTED regardless of whether frontmatter matches
1678
+ # (that mismatch is the override working, e.g. plastic-brainstorming: fable).
1679
+ # 2. no override AND basename is a TIER_DEFAULTS key -> compare frontmatter
1680
+ # to that default; match is pass (clean), mismatch is real drift (warn).
1681
+ # 3. no override AND basename is a CONSULTATION_AGENTS member -> not a
1682
+ # lifecycle stage role (intent 185); its model is user configuration by
1683
+ # contract (PLASTIC.md: "their models are user configuration"), so this
1684
+ # check does not compare and does not warn, it only lists the agent
1685
+ # informationally as a consultation role.
1686
+ # 4. no override AND basename is in NEITHER registry -> unclassified. This
1687
+ # check has no ground truth for this agent, so it does not claim
1688
+ # "drift vs default nil" (that claim would be false); it reports,
1689
+ # advisory warn, that the agent is present in the installed agents dir
1690
+ # but absent from both registries in scripts/lib/agent_models.rb, and
1691
+ # names that file as the place to add it. Setting an
1692
+ # agents.models.<basename> override also silences this line, by
1693
+ # routing the agent through bucket 1.
1509
1694
  # - zero installed agent files -> pass (nothing to check)
1510
- # This check never returns "fail".
1695
+ # This check never returns "fail" (bucket 4 is an advisory warn, not a fail).
1511
1696
  def check_agent_model_drift(agent_key)
1512
1697
  agent_config = agents[agent_key]
1513
1698
  return [] unless agent_config
1514
1699
 
1700
+ return check_agent_model_drift_codex(agent_config) if agent_key == "codex"
1701
+
1515
1702
  agents_dir = File.join(agent_config[:dir], "agents")
1516
1703
  installed = Dir.glob(File.join(agents_dir, "plastic-*.md")).sort
1517
1704
 
@@ -1527,36 +1714,124 @@ class Doctor
1527
1714
 
1528
1715
  drifted = []
1529
1716
  sanctioned = []
1717
+ consultation = []
1718
+ unclassified = []
1530
1719
 
1531
1720
  installed.each do |path|
1532
1721
  basename = File.basename(path, ".md")
1533
1722
  installed_model = (parse_frontmatter(path) || {})["model"]
1534
- resolved_default = AgentModels::TIER_DEFAULTS[basename]
1535
1723
  override = overrides[basename]
1536
1724
 
1537
1725
  if override
1538
1726
  sanctioned << "#{basename}: frontmatter=#{installed_model.inspect}, sanctioned override=#{override.inspect}"
1539
- elsif installed_model != resolved_default
1540
- drifted << "#{basename}: frontmatter=#{installed_model.inspect}, resolved default=#{resolved_default.inspect}"
1727
+ elsif AgentModels::TIER_DEFAULTS.key?(basename)
1728
+ resolved_default = AgentModels::TIER_DEFAULTS[basename]
1729
+ if installed_model != resolved_default
1730
+ drifted << "#{basename}: frontmatter=#{installed_model.inspect}, resolved default=#{resolved_default.inspect}"
1731
+ end
1732
+ elsif AgentModels::CONSULTATION_AGENTS.include?(basename)
1733
+ consultation << "#{basename}: frontmatter=#{installed_model.inspect} (consultation role, not a lifecycle default, model is user configuration)"
1734
+ else
1735
+ unclassified << "#{basename}: frontmatter=#{installed_model.inspect}, no resolved default (basename is in neither AgentModels::TIER_DEFAULTS nor AgentModels::CONSULTATION_AGENTS in scripts/lib/agent_models.rb; add it there, or set agents.models.#{basename} to sanction a model explicitly)"
1541
1736
  end
1542
1737
  end
1543
1738
 
1544
- if drifted.empty?
1739
+ if drifted.empty? && unclassified.empty?
1545
1740
  message = if sanctioned.empty?
1546
1741
  "No agent-model drift (#{installed.size} installed agent(s) match the resolved default)"
1547
1742
  else
1548
1743
  "No unsanctioned agent-model drift; #{sanctioned.size} sanctioned override(s) in effect"
1549
1744
  end
1745
+ [check(
1746
+ category: "core_files", name: "agent_model_drift", status: "pass",
1747
+ message: message,
1748
+ details: sanctioned + consultation
1749
+ )]
1750
+ else
1751
+ parts = []
1752
+ parts << "#{drifted.size} installed agent(s) have unsanctioned model drift vs the config-resolved default" if drifted.any?
1753
+ parts << "#{unclassified.size} installed agent(s) have no resolved default in scripts/lib/agent_models.rb" if unclassified.any?
1754
+ [check(
1755
+ category: "core_files", name: "agent_model_drift", status: "warn",
1756
+ message: parts.join("; "),
1757
+ details: drifted + unclassified + sanctioned + consultation,
1758
+ fixable: false
1759
+ )]
1760
+ end
1761
+ end
1762
+
1763
+ # Codex leg of agent_model_drift (intent 198, Decision D4): the shared .md
1764
+ # path above is structurally blind on Codex (codex agents are TOML under
1765
+ # ~/.codex/agents/, never ~/.agents/agents/*.md), so it always passed on an
1766
+ # empty glob without opening a single Codex file. This mirrors the same
1767
+ # four buckets (sanctioned override, matches default, drifted,
1768
+ # unclassified) against ~/.codex/agents/plastic-*.toml instead, reading
1769
+ # model / model_reasoning_effort with the same plain string matching
1770
+ # codex_agent_toml_well_formed? already uses (no TOML parser dependency).
1771
+ # The expected value resolves through AgentModels::TIER_DEFAULTS mapped
1772
+ # through AgentModels.effort_for, honoring the Codex-scoped
1773
+ # agents.models.codex.<name> override precedence install_codex's own
1774
+ # agent_model_overrides(harness: "codex") already applies.
1775
+ # AgentModels::CONSULTATION_AGENTS need no special-case bucket here:
1776
+ # generate_codex_agents already skips writing them for Codex entirely, so
1777
+ # the glob below never finds them and there is nothing to classify.
1778
+ def check_agent_model_drift_codex(agent_config)
1779
+ agents_dir = File.join(agent_config[:home_dir], "agents")
1780
+ installed = Dir.glob(File.join(agents_dir, "plastic-*.toml")).sort
1781
+
1782
+ if installed.empty?
1783
+ return [check(
1784
+ category: "core_files", name: "agent_model_drift", status: "pass",
1785
+ message: "No installed plastic-* agent TOML files to check for model drift"
1786
+ )]
1787
+ end
1788
+
1789
+ global_config = load_yaml_safe(File.join(plastic_home, "config.yml")) || {}
1790
+ overrides = AgentModels.override_map(project_config: {}, global_config: global_config, harness: "codex")
1791
+
1792
+ drifted = []
1793
+ sanctioned = []
1794
+ unclassified = []
1795
+
1796
+ installed.each do |path|
1797
+ basename = File.basename(path, ".toml")
1798
+ installed_value = codex_agent_toml_model_value(File.read(path))
1799
+ override = overrides[basename]
1800
+
1801
+ if override
1802
+ sanctioned << "#{basename}: toml=#{installed_value.inspect}, sanctioned override=#{override.inspect}"
1803
+ elsif AgentModels::TIER_DEFAULTS.key?(basename)
1804
+ expected_effort = AgentModels.effort_for(AgentModels::TIER_DEFAULTS[basename])
1805
+ if installed_value != expected_effort
1806
+ drifted << "#{basename}: toml=#{installed_value.inspect}, resolved default effort=#{expected_effort.inspect}"
1807
+ end
1808
+ else
1809
+ unclassified << "#{basename}: toml=#{installed_value.inspect}, no resolved default (basename is in " \
1810
+ "neither AgentModels::TIER_DEFAULTS nor AgentModels::CONSULTATION_AGENTS in " \
1811
+ "scripts/lib/agent_models.rb; add it there, or set agents.models.codex.#{basename} " \
1812
+ "to sanction a model explicitly)"
1813
+ end
1814
+ end
1815
+
1816
+ if drifted.empty? && unclassified.empty?
1817
+ message = if sanctioned.empty?
1818
+ "No agent-model drift (#{installed.size} installed Codex agent TOML(s) match the resolved default)"
1819
+ else
1820
+ "No unsanctioned Codex agent-model drift; #{sanctioned.size} sanctioned override(s) in effect"
1821
+ end
1550
1822
  [check(
1551
1823
  category: "core_files", name: "agent_model_drift", status: "pass",
1552
1824
  message: message,
1553
1825
  details: sanctioned
1554
1826
  )]
1555
1827
  else
1828
+ parts = []
1829
+ parts << "#{drifted.size} installed Codex agent TOML(s) have unsanctioned model drift vs the config-resolved default" if drifted.any?
1830
+ parts << "#{unclassified.size} installed Codex agent TOML(s) have no resolved default in scripts/lib/agent_models.rb" if unclassified.any?
1556
1831
  [check(
1557
1832
  category: "core_files", name: "agent_model_drift", status: "warn",
1558
- message: "#{drifted.size} installed agent(s) have unsanctioned model drift vs the config-resolved default",
1559
- details: drifted + sanctioned,
1833
+ message: parts.join("; "),
1834
+ details: drifted + unclassified + sanctioned,
1560
1835
  fixable: false
1561
1836
  )]
1562
1837
  end
@@ -1920,6 +2195,65 @@ class Doctor
1920
2195
  a_pre <=> b_pre
1921
2196
  end
1922
2197
 
2198
+ # --- Check category: config asks (release-introduced config questions) ---
2199
+ #
2200
+ # Reads config_asks.yml via ConfigAsks (intent 194): a shipped, declarative
2201
+ # manifest so a release can announce a new config question and collect the
2202
+ # answer without this file, update.rb, or any skill needing to change again.
2203
+ # Rolled up into one check (mirrors check_deprecations shape). Full-tier
2204
+ # (run_checks) ONLY, deliberately excluded from run_core_checks: the post-
2205
+ # update path (update.rb#run_post_update_doctor) defaults to the fast/core
2206
+ # tier, which rolls up with binary: true (any warn becomes overall "fail").
2207
+ # A config-asks warn in that tier would flip every post-update doctor to
2208
+ # "fail" on an otherwise healthy install until the question is answered,
2209
+ # re-noising the post-update surface intent 126 deliberately quieted. The
2210
+ # recoverable-later path for a pending question is a full `/plastic-doctor`
2211
+ # run, the declared maintenance front door; the moment-it-happens path is
2212
+ # update.rb#announce_pending_config_asks, which already runs on every hop.
2213
+ #
2214
+ # A manifest that exists but cannot be read or parsed is reported as its own
2215
+ # WARN (config_asks_manifest), never as a silent pass: an unreadable
2216
+ # manifest is not the same as "nothing declared", and reporting it as
2217
+ # healthy would be the exact silent-failure this whole batch exists to
2218
+ # close. agent_key filters entries by their optional agents scoping (an
2219
+ # entry with no agents field applies to every agent).
2220
+ def check_config_asks(agent_key = "claude")
2221
+ manifest_problem = ConfigAsks.manifest_error(plastic_home)
2222
+ if manifest_problem
2223
+ return [check(
2224
+ category: "config_asks", name: "config_asks_manifest", status: "warn",
2225
+ message: "config_asks.yml problem: #{manifest_problem}",
2226
+ fixable: false
2227
+ )]
2228
+ end
2229
+
2230
+ pending = ConfigAsks.pending(plastic_home, agent_key)
2231
+
2232
+ if pending.empty?
2233
+ return [check(
2234
+ category: "config_asks", name: "pending_asks", status: "pass",
2235
+ message: "No pending config questions"
2236
+ )]
2237
+ end
2238
+
2239
+ details = pending.map do |entry|
2240
+ lines = ["#{entry["question"]} (id: #{entry["id"]})"]
2241
+ Array(entry["options"]).each do |opt|
2242
+ lines << " - #{opt["label"]}"
2243
+ lines << " #{ConfigAsks.write_config_command(plastic_home, entry["key"], opt["value"])}"
2244
+ end
2245
+ lines << " - Not now (keep the default)"
2246
+ lines << " #{ConfigAsks.dismiss_command(plastic_home, entry["id"])}"
2247
+ lines.join("\n")
2248
+ end
2249
+
2250
+ [check(
2251
+ category: "config_asks", name: "pending_asks", status: "warn",
2252
+ message: "#{pending.size} pending config question(s)", details: details,
2253
+ fixable: false
2254
+ )]
2255
+ end
2256
+
1923
2257
  # --- Check category: QMD integration (read-only, optional) ---
1924
2258
  #
1925
2259
  # QMD is an optional integration. When `qmd` is not on PATH we emit a single
@@ -2014,6 +2348,7 @@ class Doctor
2014
2348
  all_checks += check_core_files(agent_key)
2015
2349
  all_checks += check_project_stores
2016
2350
  all_checks += check_deprecations
2351
+ all_checks += check_config_asks(agent_key)
2017
2352
  all_checks += check_qmd
2018
2353
  all_checks += check_done_signals
2019
2354
  all_checks += check_skill_lint