@zalom/plastic 1.3.0 → 1.4.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 (52) hide show
  1. package/PLASTIC-reference.md +8 -6
  2. package/PLASTIC.md +24 -3
  3. package/hooks/hooks.json +5 -0
  4. package/hooks/links-gate +3 -0
  5. package/package.json +1 -1
  6. package/scripts/doctor.rb +164 -58
  7. package/scripts/end-intent +347 -43
  8. package/scripts/hook-links-gate +74 -0
  9. package/scripts/lib/bridge.rb +29 -1
  10. package/scripts/lib/config_asks.rb +110 -0
  11. package/scripts/lib/graph_rebuild.rb +30 -6
  12. package/scripts/lib/hook_registry.rb +2 -1
  13. package/scripts/lib/installer_core.rb +30 -7
  14. package/scripts/lib/intent_validator.rb +38 -10
  15. package/scripts/lib/links_gate.rb +140 -0
  16. package/scripts/lib/links_projection.rb +71 -12
  17. package/scripts/lib/power_tools.rb +57 -14
  18. package/scripts/lib/project_validator.rb +113 -0
  19. package/scripts/lib/qmd_hook.rb +12 -8
  20. package/scripts/lib/restore_intent_v1.rb +154 -0
  21. package/scripts/lib/roadmap_queue.rb +1 -1
  22. package/scripts/lib/roadmap_savepoint.rb +38 -10
  23. package/scripts/lib/store_discovery.rb +77 -0
  24. package/scripts/lib/store_provisioning.rb +21 -12
  25. package/scripts/new-intent +10 -12
  26. package/scripts/project-links +132 -35
  27. package/scripts/provision-project-store +18 -5
  28. package/scripts/read-config +1 -0
  29. package/scripts/rebuild-graph +42 -17
  30. package/scripts/restore-intent-v1 +288 -0
  31. package/scripts/roadmap-next +9 -2
  32. package/scripts/roadmap-savepoint +9 -1
  33. package/scripts/update.rb +50 -1
  34. package/scripts/validate-intent +3 -1
  35. package/scripts/validate-project +53 -0
  36. package/scripts/write-config +105 -0
  37. package/skills/auto/SKILL.md +16 -10
  38. package/skills/auto/references/end-tail.md +27 -13
  39. package/skills/install/SKILL.md +4 -4
  40. package/skills/intent-creating/SKILL.md +5 -0
  41. package/skills/intent-ending/SKILL.md +49 -36
  42. package/skills/project-creating/SKILL.md +29 -1
  43. package/skills/releasing/SKILL.md +37 -19
  44. package/skills/roadmap/SKILL.md +9 -7
  45. package/skills/roadmap/references/file-format.md +14 -10
  46. package/skills/roadmap/references/operations.md +22 -18
  47. package/skills/roadmap-continuing/SKILL.md +5 -5
  48. package/skills/roadmap-continuing/evals/evals.json +3 -3
  49. package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
  50. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
  51. package/skills/update/SKILL.md +30 -17
  52. package/templates/roadmap.md +8 -8
@@ -67,16 +67,18 @@ root, `~/.plastic/projects/{slug}/roadmaps/`, beside `project.yml`; for the glob
67
67
  in-flight) roadmaps: once a roadmap's goal is reached, it moves to `roadmaps/archived/{slug}.md`,
68
68
  a sibling subdirectory scaffolded once with a `.gitkeep`.
69
69
 
70
- A roadmap file has four sections, in order: a title/meta header, `## Goal`, `## Waves`, and an
70
+ A roadmap file has four sections, in order: a title/meta header, `## Goal`, `## Batches`, and an
71
71
  append-only dated `## Log`. `## Goal` is a checkable prose condition read by a human or agent, not
72
- an executable checker. `## Waves` holds ordered waves; entries inside a wave are parallel-safe,
73
- waves run sequentially, top to bottom.
72
+ an executable checker. `## Batches` holds ordered batches; entries inside a batch are
73
+ parallel-safe, batches run sequentially, top to bottom. A roadmap written before owner ruling 145
74
+ may instead use the legacy `## Waves` heading; the tooling accepts both, but never renames an
75
+ existing roadmap file to migrate it.
74
76
 
75
- Each wave entry carries a status token (`queued`/`delivering`/`delivered`/`abandoned`/`blocked`)
77
+ Each batch entry carries a status token (`queued`/`delivering`/`delivered`/`abandoned`/`blocked`)
76
78
  that mirrors that intent's status in `INDEX.md`. `INDEX.md` is the single writer of intent status;
77
79
  on any conflict INDEX wins and the roadmap entry is corrected to match.
78
80
 
79
- **Human-comprehension surface.** A roadmap is also written to be read cold. Wave entries render as
81
+ **Human-comprehension surface.** A roadmap is also written to be read cold. Batch entries render as
80
82
  checkboxes (checked once delivered, unchecked otherwise) next to the status token, and each `## Log`
81
83
  line is one plain-language sentence, starting `YYYY-MM-DD HH:MM UTC`, written the way an
82
84
  engineering manager would brief a non-expert executive: what shipped and why it matters, no jargon
@@ -86,7 +88,7 @@ opening the file with no other context can tell what shipped, what is running no
86
88
  next in under a minute.
87
89
 
88
90
  **Relationship to loop engineering (intent 69).** A roadmap is the planning half of the work; the
89
- loop is its runtime. Waves lay out the parallelism plan: what can run together, and in what order.
91
+ loop is its runtime. Batches lay out the parallelism plan: what can run together, and in what order.
90
92
  Loop engineering (intent 69, not yet delivered) is expected to consume that plan and supply the
91
93
  running parts, the heartbeat, how many dispatches run at once, checking the goal, and resuming
92
94
  after a stop. This section only states the relationship and points to intent 69 as the future
package/PLASTIC.md CHANGED
@@ -456,9 +456,10 @@ Advisory. Hard gates guard writes, locks, and structure, never reads or searches
456
456
  Grep, Glob, and bash search are always allowed, including over the stores. When QMD is
457
457
  present and fresh, a content search over store markdown receives an advisory hint pointing
458
458
  at `qmd search` alongside its result; when QMD is present but stale, a background reindex
459
- fires so the next turn's hint runs against a fresh index (never synchronous). QMD and
460
- Serena are recommendations, not obligations: the UserPromptSubmit power-tools hook appends
461
- one recommendation line per present tool. The legacy trailing `# qmd-ok` token is still
459
+ fires so the next turn's hint runs against a fresh index (never synchronous). QMD, Enola,
460
+ and Serena are recommendations, not obligations: the UserPromptSubmit power-tools hook
461
+ appends one recommendation line per present tool, naming Enola only, not both, when Enola
462
+ and Serena are both present (Enola-first, one code-navigation slot). The legacy trailing `# qmd-ok` token is still
462
463
  accepted on Bash commands and simply silences the hint. Scope stays the agent's own tool
463
464
  calls; Ruby `File.read` inside a script is invisible to the hook by design.
464
465
 
@@ -603,6 +604,14 @@ purge: `outcome.md -> INDEX terminal -> savepoint Done -> commit -> disarm (Work
603
604
  -> Lock.release -> purge) -> QMD reindex`. Running the reindex last keeps the index from
604
605
  ever referencing a bridge or lock that disarm is about to remove.
605
606
 
607
+ `scripts/end-intent` performs this order's disarm step (verify the code worktree is clean,
608
+ then merge/remove worktrees, then clear the lock) as its own step 5, mechanically, since
609
+ intent 188: a session no longer needs a separate one-liner for it, and the script's own
610
+ exit code (0) is the single fact a caller needs that the intent is closed AND its delivery
611
+ lock is gone. A pre-flight lock guard runs before anything is written (refuses a live
612
+ foreign session, reclaims a stale one with an audit line), and a dirty code worktree
613
+ refuses before removal rather than force-discarding uncommitted changes.
614
+
606
615
  The post-done access window is lock-bounded: `[INDEX terminal -> Lock.release]`. Through it
607
616
  the completing session keeps full read and write access to the terminal directory and no
608
617
  purge can fire (108's lock-held keep-guard keeps the bridge while `delivery.lock` exists).
@@ -621,6 +630,18 @@ always allowed and unbounded (curator reindex, dashboards, and future intents th
621
630
  its id or chain), so a done intent stays fully readable forever. Intent 93 states this rule;
622
631
  intent 112 builds the gate that enforces it.
623
632
 
633
+ Restore-to-v1 (the owner rule that a completed intent is immutable: a late ruling goes to a
634
+ new `--parent` branch intent, and the completed intent is restored to v1) is performed ONLY by
635
+ `scripts/restore-intent-v1`, run under the maintenance lock. Its prose (the intent narrative,
636
+ checklist.md, outcome.md, spec.md, plan.md) is immutable and reverts to v1; its frontmatter
637
+ graph (`sources`/`chain`) is metadata about OTHER intents, not content of this one, and is
638
+ APPEND-ONLY: it is preserved as the union of the v1 snapshot and the current snapshot, never
639
+ subtracted. A hand-run whole-file `git checkout`/revert of a completed intent is FORBIDDEN,
640
+ because it cannot distinguish prose from graph metadata and silently destroys backlinks written
641
+ after v1 (proven on intent 124: a legitimately accrued chain edge was destroyed by a hand-run
642
+ restore and went undetected for a week). This governs the restore mechanism only; it does not
643
+ loosen terminal immutability itself.
644
+
624
645
  Fail-safe lock doctrine (the contract intent 111 implements): the lock system never traps a
625
646
  session or burns credits. When a gate cannot verify lock integrity it fails open, degrading
626
647
  to advisory (warn) rather than hard-blocking. Repair is orchestrator-driven: on a lock-issue
package/hooks/hooks.json CHANGED
@@ -52,6 +52,11 @@
52
52
  "type": "command",
53
53
  "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" savepoint-pre",
54
54
  "statusMessage": "Recording stage start..."
55
+ },
56
+ {
57
+ "type": "command",
58
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" links-gate",
59
+ "statusMessage": "Checking Links gate..."
55
60
  }
56
61
  ]
57
62
  },
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
3
+ exec ruby "$SCRIPT_DIR/../scripts/hook-links-gate"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
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
@@ -1489,25 +1498,51 @@ class Doctor
1489
1498
  end
1490
1499
 
1491
1500
  # 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.
1501
+ # for every installed <agent_dir>/agents/plastic-*.md file (intent 170, D1;
1502
+ # reclassified intent 191). Resolution mirrors read-config's own precedence
1503
+ # (project override -> global override -> shipped AgentModels::TIER_DEFAULTS),
1504
+ # reusing AgentModels' `override_map` directly so this stays hermetically
1505
+ # DI-testable (no shell-out to `read-config`, no ENV/global seam). There is
1506
+ # no live project scope at doctor-run time for a globally-installed agent
1507
+ # file, so the project layer is empty here; the resolver still supports one,
1508
+ # matching the same `override_map(project_config:, global_config:)` shape
1509
+ # the installer already calls.
1501
1510
  #
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`)
1511
+ # This check deliberately never diffs installed frontmatter against the
1512
+ # shipped agents/*.md source tree directly (considered and rejected, intent
1513
+ # 191): at real doctor run time doctor.rb lives at ~/.plastic/scripts/doctor.rb,
1514
+ # and the installer copies only scripts/ into ~/.plastic, never the agents/
1515
+ # tree, so a "compare against shipped frontmatter" check would find nothing
1516
+ # in the field and pass silently on every real installation while only ever
1517
+ # exercising itself inside the repo, the same dead-in-production shape
1518
+ # already documented above check_skill_lint. The only registry of sanctioned
1519
+ # defaults reachable at doctor run time is scripts/lib/agent_models.rb (it
1520
+ # IS copied into ~/.plastic/scripts/lib), so this check classifies each
1521
+ # installed basename using ONLY the two registries that module already
1522
+ # ships, TIER_DEFAULTS and CONSULTATION_AGENTS. No new model values are
1523
+ # added anywhere.
1524
+ #
1525
+ # Classification per installed agent basename, checked in this order:
1526
+ # 1. an `agents.models.<basename>` override IS configured -> sanctioned,
1527
+ # pass/informational, LISTED regardless of whether frontmatter matches
1528
+ # (that mismatch is the override working, e.g. plastic-brainstorming: fable).
1529
+ # 2. no override AND basename is a TIER_DEFAULTS key -> compare frontmatter
1530
+ # to that default; match is pass (clean), mismatch is real drift (warn).
1531
+ # 3. no override AND basename is a CONSULTATION_AGENTS member -> not a
1532
+ # lifecycle stage role (intent 185); its model is user configuration by
1533
+ # contract (PLASTIC.md: "their models are user configuration"), so this
1534
+ # check does not compare and does not warn, it only lists the agent
1535
+ # informationally as a consultation role.
1536
+ # 4. no override AND basename is in NEITHER registry -> unclassified. This
1537
+ # check has no ground truth for this agent, so it does not claim
1538
+ # "drift vs default nil" (that claim would be false); it reports,
1539
+ # advisory warn, that the agent is present in the installed agents dir
1540
+ # but absent from both registries in scripts/lib/agent_models.rb, and
1541
+ # names that file as the place to add it. Setting an
1542
+ # agents.models.<basename> override also silences this line, by
1543
+ # routing the agent through bucket 1.
1509
1544
  # - zero installed agent files -> pass (nothing to check)
1510
- # This check never returns "fail".
1545
+ # This check never returns "fail" (bucket 4 is an advisory warn, not a fail).
1511
1546
  def check_agent_model_drift(agent_key)
1512
1547
  agent_config = agents[agent_key]
1513
1548
  return [] unless agent_config
@@ -1527,21 +1562,29 @@ class Doctor
1527
1562
 
1528
1563
  drifted = []
1529
1564
  sanctioned = []
1565
+ consultation = []
1566
+ unclassified = []
1530
1567
 
1531
1568
  installed.each do |path|
1532
1569
  basename = File.basename(path, ".md")
1533
1570
  installed_model = (parse_frontmatter(path) || {})["model"]
1534
- resolved_default = AgentModels::TIER_DEFAULTS[basename]
1535
1571
  override = overrides[basename]
1536
1572
 
1537
1573
  if override
1538
1574
  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}"
1575
+ elsif AgentModels::TIER_DEFAULTS.key?(basename)
1576
+ resolved_default = AgentModels::TIER_DEFAULTS[basename]
1577
+ if installed_model != resolved_default
1578
+ drifted << "#{basename}: frontmatter=#{installed_model.inspect}, resolved default=#{resolved_default.inspect}"
1579
+ end
1580
+ elsif AgentModels::CONSULTATION_AGENTS.include?(basename)
1581
+ consultation << "#{basename}: frontmatter=#{installed_model.inspect} (consultation role, not a lifecycle default, model is user configuration)"
1582
+ else
1583
+ 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
1584
  end
1542
1585
  end
1543
1586
 
1544
- if drifted.empty?
1587
+ if drifted.empty? && unclassified.empty?
1545
1588
  message = if sanctioned.empty?
1546
1589
  "No agent-model drift (#{installed.size} installed agent(s) match the resolved default)"
1547
1590
  else
@@ -1550,13 +1593,16 @@ class Doctor
1550
1593
  [check(
1551
1594
  category: "core_files", name: "agent_model_drift", status: "pass",
1552
1595
  message: message,
1553
- details: sanctioned
1596
+ details: sanctioned + consultation
1554
1597
  )]
1555
1598
  else
1599
+ parts = []
1600
+ parts << "#{drifted.size} installed agent(s) have unsanctioned model drift vs the config-resolved default" if drifted.any?
1601
+ parts << "#{unclassified.size} installed agent(s) have no resolved default in scripts/lib/agent_models.rb" if unclassified.any?
1556
1602
  [check(
1557
1603
  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,
1604
+ message: parts.join("; "),
1605
+ details: drifted + unclassified + sanctioned + consultation,
1560
1606
  fixable: false
1561
1607
  )]
1562
1608
  end
@@ -1920,6 +1966,65 @@ class Doctor
1920
1966
  a_pre <=> b_pre
1921
1967
  end
1922
1968
 
1969
+ # --- Check category: config asks (release-introduced config questions) ---
1970
+ #
1971
+ # Reads config_asks.yml via ConfigAsks (intent 194): a shipped, declarative
1972
+ # manifest so a release can announce a new config question and collect the
1973
+ # answer without this file, update.rb, or any skill needing to change again.
1974
+ # Rolled up into one check (mirrors check_deprecations shape). Full-tier
1975
+ # (run_checks) ONLY, deliberately excluded from run_core_checks: the post-
1976
+ # update path (update.rb#run_post_update_doctor) defaults to the fast/core
1977
+ # tier, which rolls up with binary: true (any warn becomes overall "fail").
1978
+ # A config-asks warn in that tier would flip every post-update doctor to
1979
+ # "fail" on an otherwise healthy install until the question is answered,
1980
+ # re-noising the post-update surface intent 126 deliberately quieted. The
1981
+ # recoverable-later path for a pending question is a full `/plastic-doctor`
1982
+ # run, the declared maintenance front door; the moment-it-happens path is
1983
+ # update.rb#announce_pending_config_asks, which already runs on every hop.
1984
+ #
1985
+ # A manifest that exists but cannot be read or parsed is reported as its own
1986
+ # WARN (config_asks_manifest), never as a silent pass: an unreadable
1987
+ # manifest is not the same as "nothing declared", and reporting it as
1988
+ # healthy would be the exact silent-failure this whole batch exists to
1989
+ # close. agent_key filters entries by their optional agents scoping (an
1990
+ # entry with no agents field applies to every agent).
1991
+ def check_config_asks(agent_key = "claude")
1992
+ manifest_problem = ConfigAsks.manifest_error(plastic_home)
1993
+ if manifest_problem
1994
+ return [check(
1995
+ category: "config_asks", name: "config_asks_manifest", status: "warn",
1996
+ message: "config_asks.yml problem: #{manifest_problem}",
1997
+ fixable: false
1998
+ )]
1999
+ end
2000
+
2001
+ pending = ConfigAsks.pending(plastic_home, agent_key)
2002
+
2003
+ if pending.empty?
2004
+ return [check(
2005
+ category: "config_asks", name: "pending_asks", status: "pass",
2006
+ message: "No pending config questions"
2007
+ )]
2008
+ end
2009
+
2010
+ details = pending.map do |entry|
2011
+ lines = ["#{entry["question"]} (id: #{entry["id"]})"]
2012
+ Array(entry["options"]).each do |opt|
2013
+ lines << " - #{opt["label"]}"
2014
+ lines << " #{ConfigAsks.write_config_command(plastic_home, entry["key"], opt["value"])}"
2015
+ end
2016
+ lines << " - Not now (keep the default)"
2017
+ lines << " #{ConfigAsks.dismiss_command(plastic_home, entry["id"])}"
2018
+ lines.join("\n")
2019
+ end
2020
+
2021
+ [check(
2022
+ category: "config_asks", name: "pending_asks", status: "warn",
2023
+ message: "#{pending.size} pending config question(s)", details: details,
2024
+ fixable: false
2025
+ )]
2026
+ end
2027
+
1923
2028
  # --- Check category: QMD integration (read-only, optional) ---
1924
2029
  #
1925
2030
  # QMD is an optional integration. When `qmd` is not on PATH we emit a single
@@ -2014,6 +2119,7 @@ class Doctor
2014
2119
  all_checks += check_core_files(agent_key)
2015
2120
  all_checks += check_project_stores
2016
2121
  all_checks += check_deprecations
2122
+ all_checks += check_config_asks(agent_key)
2017
2123
  all_checks += check_qmd
2018
2124
  all_checks += check_done_signals
2019
2125
  all_checks += check_skill_lint