@zalom/plastic 2.0.0-alpha.8 → 2.0.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 (322) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +20 -15
  4. package/agents/plastic-executor.md +15 -4
  5. package/agents/plastic-node-research.md +30 -0
  6. package/agents/plastic-node-verify.md +28 -0
  7. package/agents/plastic-node-work.md +33 -0
  8. package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
  9. package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
  10. package/assets/plastic-logo.svg +1 -0
  11. package/bin/crap +4 -0
  12. package/bin/lib/context_budget.rb +43 -6
  13. package/bin/lib/skill_census.rb +839 -0
  14. package/bin/plastic +6 -0
  15. package/bin/plastic-skill-census +114 -0
  16. package/bin/test +24 -4
  17. package/bin/verify-change +345 -0
  18. package/config_asks.yml +4 -4
  19. package/deprecations.yml +1 -1
  20. package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
  21. package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
  22. package/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
  23. package/docs/help/human-report-contract.md +152 -0
  24. package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
  25. package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +23 -13
  26. package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
  27. package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
  28. package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
  29. package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
  30. package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
  31. package/hooks/call-budget +4 -0
  32. package/hooks/hooks.json +24 -0
  33. package/hooks/message-display +62 -1
  34. package/hooks/session-start +5 -1
  35. package/hooks/statusline +32 -27
  36. package/hooks/stop +5 -0
  37. package/package.json +5 -3
  38. package/scripts/append-ledger +2 -1
  39. package/scripts/dashboard.rb +267 -16
  40. package/scripts/day-summary +2 -1
  41. package/scripts/doctor.rb +680 -39
  42. package/scripts/end-intent +238 -22
  43. package/scripts/exec-worktree +5 -5
  44. package/scripts/file-session-intent +2 -1
  45. package/scripts/graph-measure +249 -0
  46. package/scripts/hook-call-budget +222 -0
  47. package/scripts/hook-capture +24 -123
  48. package/scripts/hook-close +2 -1
  49. package/scripts/hook-message-display +19 -5
  50. package/scripts/hook-record +24 -16
  51. package/scripts/hook-savepoint +27 -3
  52. package/scripts/hook-session-start +359 -321
  53. package/scripts/hook-stop +58 -0
  54. package/scripts/index-projection +74 -0
  55. package/scripts/insight-append +17 -4
  56. package/scripts/install.rb +9 -7
  57. package/scripts/lib/action_graph_shim.rb +279 -0
  58. package/scripts/lib/active_delivery.rb +105 -0
  59. package/scripts/lib/agent_models.rb +63 -25
  60. package/scripts/lib/arm.rb +48 -83
  61. package/scripts/lib/atomic_write.rb +31 -0
  62. package/scripts/lib/backup.rb +65 -0
  63. package/scripts/lib/cli/command.rb +92 -0
  64. package/scripts/lib/cli/commands/auto.rb +18 -0
  65. package/scripts/lib/cli/commands/auto_brief.rb +44 -0
  66. package/scripts/lib/cli/commands/auto_lock.rb +99 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +31 -0
  69. package/scripts/lib/cli/commands/backup.rb +43 -0
  70. package/scripts/lib/cli/commands/checkout.rb +25 -0
  71. package/scripts/lib/cli/commands/continue.rb +66 -0
  72. package/scripts/lib/cli/commands/doctor.rb +81 -0
  73. package/scripts/lib/cli/commands/feedback.rb +40 -0
  74. package/scripts/lib/cli/commands/help.rb +69 -0
  75. package/scripts/lib/cli/commands/hook.rb +32 -0
  76. package/scripts/lib/cli/commands/index.rb +23 -0
  77. package/scripts/lib/cli/commands/install.rb +21 -0
  78. package/scripts/lib/cli/commands/installer_verb.rb +37 -0
  79. package/scripts/lib/cli/commands/intent.rb +19 -0
  80. package/scripts/lib/cli/commands/intent_answer.rb +37 -0
  81. package/scripts/lib/cli/commands/intent_command.rb +53 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +62 -0
  83. package/scripts/lib/cli/commands/intent_new.rb +62 -0
  84. package/scripts/lib/cli/commands/intent_note.rb +43 -0
  85. package/scripts/lib/cli/commands/intent_rule.rb +36 -0
  86. package/scripts/lib/cli/commands/intent_show.rb +32 -0
  87. package/scripts/lib/cli/commands/intent_spec.rb +44 -0
  88. package/scripts/lib/cli/commands/intent_step.rb +71 -0
  89. package/scripts/lib/cli/commands/intent_verify.rb +26 -0
  90. package/scripts/lib/cli/commands/migrate.rb +16 -0
  91. package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
  92. package/scripts/lib/cli/commands/next.rb +54 -0
  93. package/scripts/lib/cli/commands/project.rb +19 -0
  94. package/scripts/lib/cli/commands/project_links.rb +42 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +72 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +33 -0
  99. package/scripts/lib/cli/commands/roadmap.rb +19 -0
  100. package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
  101. package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
  102. package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
  103. package/scripts/lib/cli/commands/roadmap_next.rb +70 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
  105. package/scripts/lib/cli/commands/rollback.rb +20 -0
  106. package/scripts/lib/cli/commands/search.rb +60 -0
  107. package/scripts/lib/cli/commands/session.rb +18 -0
  108. package/scripts/lib/cli/commands/session_commit.rb +42 -0
  109. package/scripts/lib/cli/commands/session_handoff.rb +34 -0
  110. package/scripts/lib/cli/commands/session_summary.rb +35 -0
  111. package/scripts/lib/cli/commands/status.rb +68 -0
  112. package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
  113. package/scripts/lib/cli/commands/sync.rb +46 -0
  114. package/scripts/lib/cli/commands/uninstall.rb +20 -0
  115. package/scripts/lib/cli/commands/update.rb +20 -0
  116. package/scripts/lib/cli/commands/version.rb +53 -0
  117. package/scripts/lib/cli/frontier.rb +86 -0
  118. package/scripts/lib/cli/intent_progress.rb +36 -0
  119. package/scripts/lib/cli/legacy.rb +80 -0
  120. package/scripts/lib/cli/output.rb +137 -0
  121. package/scripts/lib/cli/scope.rb +134 -0
  122. package/scripts/lib/cli/table.rb +65 -0
  123. package/scripts/lib/cli.rb +94 -0
  124. package/scripts/lib/codex_adapter.rb +198 -0
  125. package/scripts/lib/compact_instructions.rb +13 -5
  126. package/scripts/lib/core_integrity.rb +71 -0
  127. package/scripts/lib/dashboard_screen.rb +40 -0
  128. package/scripts/lib/data_boundary.rb +132 -0
  129. package/scripts/lib/day_summary.rb +30 -16
  130. package/scripts/lib/doctor_core.rb +114 -38
  131. package/scripts/lib/doctor_session_ledger.rb +5 -53
  132. package/scripts/lib/engine_permissions.rb +88 -0
  133. package/scripts/lib/exec_worktree.rb +24 -21
  134. package/scripts/lib/feedback_report.rb +1 -1
  135. package/scripts/lib/graph_edges.rb +137 -0
  136. package/scripts/lib/graph_file.rb +246 -0
  137. package/scripts/lib/graph_measure.rb +645 -0
  138. package/scripts/lib/graph_measure_budget.rb +409 -0
  139. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  140. package/scripts/lib/graph_measure_models.rb +413 -0
  141. package/scripts/lib/graph_measure_report.rb +532 -0
  142. package/scripts/lib/graph_tree.rb +98 -0
  143. package/scripts/lib/guarded_append.rb +155 -0
  144. package/scripts/lib/handoff.rb +40 -13
  145. package/scripts/lib/harness_adapter.rb +184 -0
  146. package/scripts/lib/hook_registry.rb +27 -3
  147. package/scripts/lib/hook_replay.rb +229 -0
  148. package/scripts/lib/index_entry.rb +62 -0
  149. package/scripts/lib/index_projection.rb +201 -0
  150. package/scripts/lib/insights.rb +1 -1
  151. package/scripts/lib/installer_core.rb +532 -71
  152. package/scripts/lib/intent_screen.rb +9 -6
  153. package/scripts/lib/intent_screen_ansi.rb +73 -12
  154. package/scripts/lib/intent_validator.rb +2 -2
  155. package/scripts/lib/lock.rb +10 -11
  156. package/scripts/lib/message_display.rb +377 -173
  157. package/scripts/lib/meter_watch.rb +185 -0
  158. package/scripts/lib/node_file.rb +234 -0
  159. package/scripts/lib/node_ids.rb +99 -0
  160. package/scripts/lib/node_input.rb +913 -0
  161. package/scripts/lib/node_input_compatibility.rb +62 -0
  162. package/scripts/lib/node_ledger.rb +386 -0
  163. package/scripts/lib/node_progress.rb +153 -0
  164. package/scripts/lib/node_return.rb +204 -0
  165. package/scripts/lib/node_worktree.rb +337 -0
  166. package/scripts/lib/outcome_report.rb +440 -0
  167. package/scripts/lib/preflight.rb +4 -6
  168. package/scripts/lib/project_config.rb +46 -0
  169. package/scripts/lib/project_validator.rb +3 -2
  170. package/scripts/lib/qmd_sync.rb +8 -7
  171. package/scripts/lib/ready_set.rb +462 -0
  172. package/scripts/lib/reference_archive.rb +45 -0
  173. package/scripts/lib/release_guard.rb +18 -0
  174. package/scripts/lib/report_screen.rb +1460 -74
  175. package/scripts/lib/rlm/corpus.rb +13 -0
  176. package/scripts/lib/rlm/probe.rb +29 -0
  177. package/scripts/lib/rlm/query.rb +22 -0
  178. package/scripts/lib/roadmap_graph.rb +210 -0
  179. package/scripts/lib/roadmap_migration.rb +95 -0
  180. package/scripts/lib/roadmap_queue.rb +158 -8
  181. package/scripts/lib/roadmap_render.rb +150 -0
  182. package/scripts/lib/roadmap_savepoint.rb +64 -14
  183. package/scripts/lib/runner_absorb.rb +703 -0
  184. package/scripts/lib/runner_answer.rb +206 -0
  185. package/scripts/lib/runner_core.rb +194 -0
  186. package/scripts/lib/runner_dispatch.rb +525 -0
  187. package/scripts/lib/runner_policy.rb +191 -0
  188. package/scripts/lib/runner_proposals.rb +275 -0
  189. package/scripts/lib/runner_rewind.rb +201 -0
  190. package/scripts/lib/runner_sweep.rb +231 -0
  191. package/scripts/lib/runner_until_empty.rb +252 -0
  192. package/scripts/lib/runner_watch.rb +389 -0
  193. package/scripts/lib/savepoint.rb +141 -19
  194. package/scripts/lib/scaffold_intent.rb +6 -3
  195. package/scripts/lib/screen_paint.rb +555 -0
  196. package/scripts/lib/screens/dashboard.rb +20 -0
  197. package/scripts/lib/screens/plan.rb +18 -0
  198. package/scripts/lib/screens/roadmap.rb +15 -0
  199. package/scripts/lib/search_index.rb +55 -0
  200. package/scripts/lib/session_close.rb +30 -28
  201. package/scripts/lib/session_git.rb +25 -18
  202. package/scripts/lib/session_ledger.rb +48 -4
  203. package/scripts/lib/session_usage.rb +190 -0
  204. package/scripts/lib/sqlite.rb +22 -0
  205. package/scripts/lib/stop_gate.rb +95 -0
  206. package/scripts/lib/store_discovery.rb +7 -6
  207. package/scripts/lib/store_layout.rb +54 -0
  208. package/scripts/lib/store_provisioning.rb +2 -1
  209. package/scripts/lib/store_sync.rb +85 -0
  210. package/scripts/lib/stores_move.rb +93 -0
  211. package/scripts/lib/verify_intent.rb +36 -8
  212. package/scripts/lib/version_number.rb +48 -0
  213. package/scripts/lib/work_graph.rb +59 -0
  214. package/scripts/lib/work_graph_validator.rb +201 -0
  215. package/scripts/lib/worktree.rb +27 -32
  216. package/scripts/lib/worktree_sweep.rb +6 -5
  217. package/scripts/link-suggest +2 -1
  218. package/scripts/meter-watch +57 -0
  219. package/scripts/migrate-to-global +1 -1
  220. package/scripts/new-intent +4 -13
  221. package/scripts/node-input +92 -0
  222. package/scripts/node-run +225 -0
  223. package/scripts/node-transition +291 -0
  224. package/scripts/outcome-report +74 -0
  225. package/scripts/plastic-lock +42 -33
  226. package/scripts/promote-session-item +3 -2
  227. package/scripts/read-config +53 -9
  228. package/scripts/ready-set +126 -0
  229. package/scripts/release-check +123 -0
  230. package/scripts/report-screen +192 -30
  231. package/scripts/roadmap-graph +119 -0
  232. package/scripts/roadmap-savepoint +7 -0
  233. package/scripts/runner +581 -0
  234. package/scripts/savepoint-note +11 -9
  235. package/scripts/session-commit +2 -1
  236. package/scripts/session-usage +56 -0
  237. package/scripts/skill-lint +115 -6
  238. package/scripts/spawn-preamble +2 -2
  239. package/scripts/update.rb +25 -4
  240. package/scripts/validate-work-graph +39 -0
  241. package/scripts/verify-intent +3 -2
  242. package/scripts/write-handoff +2 -1
  243. package/templates/agents.md +7 -7
  244. package/templates/config.yml +16 -9
  245. package/templates/dashboard-screen.md +22 -0
  246. package/templates/display-fixture.md +21 -0
  247. package/templates/graph.md +16 -0
  248. package/templates/index.md +1 -1
  249. package/templates/intent-screen.md +1 -1
  250. package/templates/node-decision.md +11 -0
  251. package/templates/node-research.md +13 -0
  252. package/templates/node-verify.md +13 -0
  253. package/templates/node-work.md +22 -0
  254. package/templates/outcome.md +15 -2
  255. package/templates/project.yml +1 -1
  256. package/templates/render.css +10 -0
  257. package/templates/report-plan.md +15 -0
  258. package/templates/report-roadmap-delivered.md +10 -0
  259. package/templates/report-roadmap-plan.md +9 -0
  260. package/templates/report-roadmap-state.md +9 -0
  261. package/templates/report-state.md +1 -1
  262. package/templates/roadmap.md +13 -0
  263. package/bin/plastic.js +0 -70
  264. package/scripts/lib/bridge.rb +0 -116
  265. package/skills/agent-advisor/SKILL.md +0 -92
  266. package/skills/auto/SKILL.md +0 -295
  267. package/skills/auto/evals/evals.json +0 -255
  268. package/skills/auto/references/end-tail.md +0 -66
  269. package/skills/auto/references/human-report-contract.md +0 -78
  270. package/skills/conventions/SKILL.md +0 -29
  271. package/skills/dashboard/SKILL.md +0 -169
  272. package/skills/dashboard/evals/evals.json +0 -38
  273. package/skills/dashboard/references/classification.md +0 -22
  274. package/skills/dashboard/templates/dashboard-global.md +0 -20
  275. package/skills/dashboard/templates/dashboard-project.md +0 -19
  276. package/skills/direct/SKILL.md +0 -66
  277. package/skills/direct/references/request-signals.md +0 -59
  278. package/skills/doctor/SKILL.md +0 -299
  279. package/skills/doctor/report.md +0 -102
  280. package/skills/feedback/SKILL.md +0 -98
  281. package/skills/feedback/references/transport-and-privacy.md +0 -65
  282. package/skills/feedback/report.md +0 -36
  283. package/skills/install/SKILL.md +0 -217
  284. package/skills/intent-continuing/SKILL.md +0 -154
  285. package/skills/intent-continuing/references/board-fill.md +0 -43
  286. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  287. package/skills/intent-continuing/references/context-management.md +0 -28
  288. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  289. package/skills/intent-creating/SKILL.md +0 -164
  290. package/skills/intent-creating/evals/evals.json +0 -72
  291. package/skills/intent-creating/references/lifecycle.md +0 -81
  292. package/skills/intent-creating/references/wikilinks.md +0 -8
  293. package/skills/intent-ending/SKILL.md +0 -173
  294. package/skills/intent-ending/evals/evals.json +0 -74
  295. package/skills/intent-executing/SKILL.md +0 -170
  296. package/skills/intent-executing/evals/evals.json +0 -66
  297. package/skills/intent-executing/implementer-prompt.md +0 -42
  298. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  299. package/skills/intent-speccing/SKILL.md +0 -130
  300. package/skills/intent-speccing/evals/evals.json +0 -126
  301. package/skills/intent-speccing/references/design-principles.md +0 -44
  302. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  303. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  304. package/skills/project-creating/SKILL.md +0 -162
  305. package/skills/project-creating/references/hubs-projects.md +0 -55
  306. package/skills/project-creating/references/project-scaffolding.md +0 -97
  307. package/skills/releasing/SKILL.md +0 -337
  308. package/skills/releasing/references/deprecations.md +0 -60
  309. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  310. package/skills/releasing/references/release-lines.md +0 -105
  311. package/skills/roadmap/SKILL.md +0 -64
  312. package/skills/roadmap/references/file-format.md +0 -124
  313. package/skills/roadmap/references/operations.md +0 -112
  314. package/skills/rollback/SKILL.md +0 -91
  315. package/skills/tutorial/SKILL.md +0 -65
  316. package/skills/tutorial/evals/evals.json +0 -186
  317. package/skills/uninstall/SKILL.md +0 -75
  318. package/skills/update/SKILL.md +0 -126
  319. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  320. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  321. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  322. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
package/scripts/doctor.rb CHANGED
@@ -9,9 +9,15 @@
9
9
  # Exit codes: 0 (all pass), 1 (warnings only), 2 (failures present)
10
10
  # Read-only — never modifies files.
11
11
 
12
+ require_relative "lib/store_layout"
12
13
  require "date"
14
+ require "open3"
15
+ require "timeout"
16
+ require "fileutils"
17
+ require "tmpdir"
13
18
 
14
19
  require_relative "lib/doctor_core"
20
+ require_relative "lib/hook_replay"
15
21
 
16
22
  require_relative "lib/doctor_exclusions"
17
23
  require_relative "lib/qmd_sync"
@@ -22,6 +28,10 @@ require_relative "lib/links_projection"
22
28
  require_relative "lib/links_section"
23
29
  require_relative "lib/lock"
24
30
  require_relative "lib/savepoint"
31
+ require_relative "lib/node_ledger"
32
+ require_relative "lib/ready_set"
33
+ require_relative "lib/graph_measure_models"
34
+ require_relative "lib/index_projection"
25
35
  require_relative "lib/agent_models"
26
36
  require_relative "lib/outcome_guard"
27
37
  require_relative "lib/skill_lint"
@@ -30,6 +40,8 @@ require_relative "lib/power_tools"
30
40
  require_relative "lib/preflight"
31
41
  require_relative "lib/ruby_probe"
32
42
  require_relative "lib/doctor_session_ledger"
43
+ require_relative "lib/intent_screen"
44
+ require_relative "lib/store_sync"
33
45
 
34
46
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
35
47
  # run it hermetically (no eval, no global-constant rewriting).
@@ -46,6 +58,13 @@ class Doctor
46
58
  # (intent 60). Alias it here so the two can never drift.
47
59
  REQUIRED_FRONTMATTER_FIELDS = IntentValidator::REQUIRED_FIELDS
48
60
 
61
+ # Repo root, for the three display checks below that read shipped, static
62
+ # package content (the fixture fallback, the harness-adapters doc) rather
63
+ # than a runtime plastic_home path. Same derivation as
64
+ # StoreProvisioning::PACKAGE_ROOT (intent 61): scripts/doctor.rb lives one
65
+ # level under the root.
66
+ PACKAGE_ROOT = File.expand_path("..", __dir__)
67
+
49
68
 
50
69
 
51
70
  # --- Flag parsing ---
@@ -122,7 +141,7 @@ class Doctor
122
141
  --intent ID Per-intent structure gate at intent-end (intent 222): one
123
142
  closing intent only, never a store sweep. Pair with
124
143
  --store <key> to disambiguate an id that collides across
125
- stores, and --disposition delivered|abandoned to fold in
144
+ stores, and --disposition delivered|abandoned to include
126
145
  the outcome.md disposition check. 3-state pass/warn/fail.
127
146
  -h, --help Show this help
128
147
 
@@ -199,7 +218,7 @@ class Doctor
199
218
  def check_global_store
200
219
  checks = []
201
220
 
202
- index_path = File.join(plastic_home, "INDEX.md")
221
+ index_path = File.join(Plastic::StoreLayout.global_root(plastic_home), "INDEX.md")
203
222
 
204
223
  # index_exists
205
224
  if File.exist?(index_path)
@@ -235,7 +254,7 @@ class Doctor
235
254
  end
236
255
 
237
256
  # orphaned_intents — directories in store/ not referenced in INDEX.md
238
- store_dir = File.join(plastic_home, "store")
257
+ store_dir = Plastic::StoreLayout.global_store(plastic_home)
239
258
  if File.directory?(store_dir)
240
259
  intent_dirs = store_intent_dirs(store_dir)
241
260
  orphans = intent_dirs.reject { |d| content.include?("store/#{d}") }
@@ -261,7 +280,7 @@ class Doctor
261
280
  store_paths = content.scan(%r{store/\S+}).map { |ref| ref.gsub(/[)\]>].*/, "").chomp("/") }.uniq
262
281
 
263
282
  ghosts = store_paths.select do |ref|
264
- full_path = File.join(plastic_home, ref)
283
+ full_path = File.join(Plastic::StoreLayout.global_root(plastic_home), ref)
265
284
  !File.exist?(full_path) && !File.directory?(full_path)
266
285
  end
267
286
 
@@ -313,7 +332,7 @@ class Doctor
313
332
  category: "core_files", name: name, status: "warn",
314
333
  message: "#{config[:name]}'s agent-side VERSION file not found at #{tilde(agent_version_path)}",
315
334
  fixable: true,
316
- fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
335
+ fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or `plastic rollback` to a prior version"
317
336
  )
318
337
  else
319
338
  agent_version = File.read(agent_version_path).strip
@@ -331,7 +350,7 @@ class Doctor
331
350
  "#{tilde(agent_version_path)}: #{agent_version}",
332
351
  ],
333
352
  fixable: true,
334
- fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
353
+ fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or `plastic rollback` to a prior version"
335
354
  )
336
355
  end
337
356
  end
@@ -596,7 +615,7 @@ class Doctor
596
615
  fixable: true,
597
616
  fix_hint: "Relocate each unsanctioned section into the " \
598
617
  "intent's revisions.md via move-and-record (a missing required section is restored " \
599
- "or reprojected instead); see plastic-conventions > references/maintenance-and-revisions.md"
618
+ "or reprojected instead); see `plastic help maintenance-and-revisions`"
600
619
  )
601
620
  end
602
621
 
@@ -607,6 +626,28 @@ class Doctor
607
626
  # flagged: validate_graph does not compute it.
608
627
  checks.concat(graph_invariant_checks(intent_dirs))
609
628
 
629
+ # node_graph - ReadySet's own view of every intent carrying a real graph.md
630
+ # (intent 336, n7): dead ends, stale done nodes, and expired running leases,
631
+ # none of which any other doctor rule surfaces. Lives here rather than
632
+ # doctor_core.rb because test/doctor_core_split_test.rb pins that file's
633
+ # boot path to exactly three project files and 781 bytes of headroom;
634
+ # ReadySet's own require chain is 65,648 bytes, far past that budget.
635
+ checks.concat(node_graph_checks(intent_dirs))
636
+
637
+ # model_requalification (D42, intent 343 G10 n7): a recorded model= that no
638
+ # longer matches what RunnerPolicy.model_for resolves for its role today
639
+ # means every measurement taken under the old model measured a different
640
+ # system. Lives here for the same reason node_graph_checks does: pins to
641
+ # test/doctor_core_split_test.rb's exact-set boot-path assertion, never
642
+ # doctor_core.rb (D11) - GraphMeasureModels' own require chain (NodeLedger,
643
+ # NodeFile, RunnerPolicy, AgentModels) is far past that file's byte budget.
644
+ checks.concat(model_requalification_checks(scopes: scopes))
645
+
646
+ # unpromoted_rules (intent 341, G8, C37): an Insights entry tagged
647
+ # `rule:` (via `insight-append --rule`) is a promise the rule will make
648
+ # it into project doctrine. Advisory only.
649
+ checks.concat(unpromoted_rules_checks(intent_dirs))
650
+
610
651
  # cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
611
652
  # `store:id` ref against the FULL store family via the relocation map
612
653
  # (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
@@ -666,14 +707,14 @@ class Doctor
666
707
  if scopes.nil? || scopes.include?("global")
667
708
  stores << {
668
709
  scope: "global",
669
- index: File.join(plastic_home, "INDEX.md"),
670
- store_dir: File.join(plastic_home, "store"),
710
+ index: File.join(Plastic::StoreLayout.global_root(plastic_home), "INDEX.md"),
711
+ store_dir: Plastic::StoreLayout.global_store(plastic_home),
671
712
  }
672
713
  end
673
714
 
674
- projects_root = File.join(plastic_home, "projects")
715
+ projects_root = Plastic::StoreLayout.projects_root(plastic_home)
675
716
  if File.directory?(projects_root)
676
- Dir.children(projects_root).sort.each do |project|
717
+ Plastic::StoreLayout.project_slugs(plastic_home).each do |project|
677
718
  scope = "project:#{project}"
678
719
  next unless scopes.nil? || scopes.include?(scope)
679
720
 
@@ -771,8 +812,19 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
771
812
  # written from the record by `scaffold-intent backfill`, so this is repairable and
772
813
  # reported as a fixable warn (backfilled_complete). Its exclusions go to their OWN
773
814
  # bucket so savepoint_operational's consumed/dead-row bookkeeping above never sees them.
774
- backfill_gaps = %w[spec.md plan.md].reject { |f| Savepoint.stage_file_present?(File.join(dir, f)) }
775
- backfill_gaps << "actions/" unless Savepoint.has_real_action?(dir)
815
+ # A graph intent (real graph.md on disk) never carries spec.md or plan.md by design
816
+ # (D1, no ceremonies, intent 341): the graph IS the spec and the plan, so a missing one
817
+ # is never a gap here.
818
+ is_graph_intent = File.exist?(File.join(dir, "graph.md"))
819
+ backfill_gaps = is_graph_intent ? [] : %w[spec.md plan.md].reject { |f| Savepoint.stage_file_present?(File.join(dir, f)) }
820
+ # Name whichever directory the intent actually used (post-execution
821
+ # review, non-blocking 8), consistent with Savepoint.missing_for_stage:
822
+ # a nodes/ directory on disk means the intent chose the node-graph
823
+ # convention, so a gap here is a missing nodes/, never the literal
824
+ # actions/ has_real_action? no longer implies.
825
+ unless Savepoint.has_real_action?(dir)
826
+ backfill_gaps << (File.directory?(File.join(dir, "nodes")) ? "nodes/" : "actions/")
827
+ end
776
828
  if backfill_gaps.any?
777
829
  suppressed_backfill = excluded_rules.include?("backfilled_complete")
778
830
  target = suppressed_backfill ? findings[:excluded_backfill] : findings[:unbackfilled]
@@ -807,6 +859,9 @@ def check_done_signals(scopes: nil)
807
859
  unbackfilled = [] # spec/plan/actions gaps on terminal intents (intent 308) - repairable
808
860
  excluded_backfill = [] # backfill gaps knowingly exempted via doctor-exclusions (intent 308)
809
861
  dead_rows_by_rule = Hash.new { |h, k| h[k] = [] }
862
+ index_drift = [] # intent 337 (G4): INDEX vs. the intent ledgers, scoped to what
863
+ # savepoint_operational does not already report (row 6.7 - a silent or
864
+ # absent ledger is already IndexProjection's own exclusion, row 5.13)
810
865
 
811
866
  done_signal_stores(scopes).each do |store|
812
867
  exclusions = DoctorExclusions.load(store[:index])
@@ -815,6 +870,14 @@ def check_done_signals(scopes: nil)
815
870
  exclusion_error_paths << exclusions[:path]
816
871
  end
817
872
 
873
+ if File.exist?(store[:index])
874
+ projection = IndexProjection.analyze(store[:store_dir], index_path: store[:index])
875
+ projection[:drift].each do |row|
876
+ index_drift << "#{store[:scope]}: #{row[:id]} - INDEX says #{row[:index_status]}, " \
877
+ "the ledger's last line says #{row[:ledger_status]}"
878
+ end
879
+ end
880
+
818
881
  consumed = { "savepoint_operational" => [], "backfilled_complete" => [] }
819
882
  # `known_ids` (post-review fix): every intent id with a REAL DIRECTORY in this store, scanned
820
883
  # directly from disk - independent of INDEX.md. An id can have a directory on disk without
@@ -931,14 +994,14 @@ def check_done_signals(scopes: nil)
931
994
  # rebuild-savepoint for most gaps, or knowingly excluded for the ones 219 D6 forbids ever
932
995
  # repairing (no real outcome.md to echo a disposition from). Three branches (spec D4/D5):
933
996
  # a malformed exclusion file can never report pass (loud), a clean remaining gap set reports
934
- # pass with the exclusion count folded in, and a real remaining gap set stays warn, same as
935
- # before intent 274, with the same count folded in when exclusions applied.
997
+ # pass with the exclusion count included, and a real remaining gap set stays warn, same as
998
+ # before intent 274, with the same count included when exclusions applied.
936
999
  #
937
- # `dead_suffix` (intent 280) folds in a second, independent drift notice: exclusion rows that
1000
+ # `dead_suffix` (intent 280) adds a second, independent drift notice: exclusion rows that
938
1001
  # suppressed nothing this run. It is purely informational, exactly like `exclusion_suffix` - it
939
1002
  # never changes status on any of the three branches below, because a stale governance-record row
940
1003
  # is bookkeeping drift, not a store regression (219 D6 is untouched: no disposition is invented).
941
- # Per rule (intent 308): savepoint_operational and backfilled_complete each fold in only
1004
+ # Per rule (intent 308): savepoint_operational and backfilled_complete each include only
942
1005
  # their own exclusions and dead rows, so one check never carries the other's counts.
943
1006
  suffixes = lambda do |excluded_rows, dead|
944
1007
  ex = excluded_rows.empty? ? "" : " (#{excluded_rows.size} excluded via #{exclusion_paths.join(", ")})"
@@ -989,7 +1052,7 @@ def check_done_signals(scopes: nil)
989
1052
  # backfilled_complete (intent 308): spec.md, plan.md, or a real action file missing on a
990
1053
  # terminal intent. Same three branches as savepoint_operational: a malformed exclusion
991
1054
  # file is always loud, a clean set passes with its own exclusion and dead-row counts
992
- # folded in, a real gap set warns with the backfill verb as the fix.
1055
+ # included, a real gap set warns with the backfill verb as the fix.
993
1056
  backfill_hint = "Write the missing documents from the record via " \
994
1057
  "`scaffold-intent backfill --store <store> --id <id> --disposition " \
995
1058
  "<delivered|abandoned>` (never touches real content, one intent per invocation)."
@@ -1055,7 +1118,31 @@ def check_done_signals(scopes: nil)
1055
1118
  fix_hint: "For a live (Active) intent, rebuild the ledger via " \
1056
1119
  "Savepoint.rebuild_savepoint. Terminal (Completed/Abandoned) intents are immutable: " \
1057
1120
  "a phantom there stays advisory unless an explicit human grant authorizes the " \
1058
- "124a manual Done-bookend repair."
1121
+ "124a manual terminal-bookend repair."
1122
+ )
1123
+ end
1124
+
1125
+ # index_ledger_drift (intent 337, G4): a REAL terminal ledger line (Done
1126
+ # delivered or abandoned) that disagrees with the INDEX section an intent
1127
+ # currently sits in. Never fires for a silent or absent ledger - that gap
1128
+ # is savepoint_operational's own concern, and IndexProjection's own drift
1129
+ # computation already excludes it (row 5.13), so no id is ever double
1130
+ # reported across the two checks (row 6.7). Warn+fixable: `index-projection
1131
+ # <store_root> --write` is the repair, never invented here.
1132
+ if index_drift.empty?
1133
+ checks << check(
1134
+ category: "done_signals", name: "index_ledger_drift", status: "pass",
1135
+ message: "No INDEX section disagrees with an intent's own terminal ledger line"
1136
+ )
1137
+ else
1138
+ checks << check(
1139
+ category: "done_signals", name: "index_ledger_drift", status: "warn",
1140
+ message: "#{index_drift.size} intent#{index_drift.size == 1 ? "" : "s"} whose terminal " \
1141
+ "ledger line disagrees with the INDEX section it currently sits in",
1142
+ details: index_drift, fixable: true,
1143
+ fix_hint: "Reconcile via `index-projection <store_root> --write` (the ledger wins; only the " \
1144
+ "conflicting entries move, every other byte of INDEX.md, including ## Clusters and " \
1145
+ "## Relocated, is left untouched)."
1059
1146
  )
1060
1147
  end
1061
1148
 
@@ -1143,7 +1230,7 @@ end
1143
1230
  "project-links --intent <id> --apply` over running project-links directly: it detects " \
1144
1231
  "(never acquires) the target's delivery lock, requires a clean store working tree, and " \
1145
1232
  "commits the scoped change plus its revisions.md receipt as one merged operation " \
1146
- "(plastic-conventions > references/maintenance-and-revisions.md)."
1233
+ "(`plastic help maintenance-and-revisions`)."
1147
1234
  )
1148
1235
  end
1149
1236
 
@@ -1161,9 +1248,12 @@ end
1161
1248
  #
1162
1249
  # Doctor's fourth check scope, invoked by `--intent <id>`. Never a store-wide sweep:
1163
1250
  # resolves exactly one intent directory (mirrors scripts/end-intent's resolve_intent_dir /
1164
- # scripts/project-links's --intent disambiguation) and returns five checks, four
1165
- # FAIL-severity, one WARN-severity (intent_savepoint_truthful stays WARN per intent 134's
1166
- # binding advisory-only ruling; see spec.md D2/D8 - do NOT escalate it to FAIL).
1251
+ # scripts/project-links's --intent disambiguation) and returns seven checks, four
1252
+ # FAIL-severity, three WARN-severity: intent_savepoint_truthful stays WARN per intent 134's
1253
+ # binding advisory-only ruling (see spec.md D2/D8 - do NOT escalate it to FAIL),
1254
+ # intent_ticks_lag is WARN-only per intent 329's ruling that a lagging tick warns rather
1255
+ # than blocks, and intent_reports_printed is WARN-only per intent 331f (never re-litigates
1256
+ # a ledger predating the Report kind).
1167
1257
  def check_intent_end(id, store: nil, disposition: nil)
1168
1258
  intent_dir, scope = resolve_single_intent_dir(id, store: store)
1169
1259
  unless intent_dir
@@ -1185,6 +1275,8 @@ end
1185
1275
  intent_structure_check(intent_dir),
1186
1276
  intent_lifecycle_artifacts_check(intent_dir, disposition),
1187
1277
  intent_checklist_complete_check(intent_dir),
1278
+ intent_ticks_lag_check(intent_dir),
1279
+ intent_reports_printed_check(intent_dir),
1188
1280
  intent_links_projection_check_for(id, scope),
1189
1281
  intent_savepoint_truthful_check(intent_dir, index_path: index_path),
1190
1282
  ]
@@ -1223,9 +1315,13 @@ end
1223
1315
  end
1224
1316
 
1225
1317
  INTENT_END_LIFECYCLE_FILES = %w[spec.md plan.md checklist.md outcome.md].freeze
1318
+ # A graph intent (D1, no ceremonies, intent 341) never carries spec.md, plan.md, or
1319
+ # checklist.md by design; the graph IS the spec and the plan. outcome.md stays mandatory.
1320
+ GRAPH_INTENT_LIFECYCLE_FILES = %w[outcome.md].freeze
1226
1321
 
1227
1322
  def intent_lifecycle_artifacts_check(intent_dir, disposition)
1228
- missing = INTENT_END_LIFECYCLE_FILES.select { |f| !Savepoint.stage_file_present?(File.join(intent_dir, f)) }
1323
+ files = File.exist?(File.join(intent_dir, "graph.md")) ? GRAPH_INTENT_LIFECYCLE_FILES : INTENT_END_LIFECYCLE_FILES
1324
+ missing = files.select { |f| !Savepoint.stage_file_present?(File.join(intent_dir, f)) }
1229
1325
  unless Savepoint.stage_file_present?(Savepoint.intent_file(intent_dir))
1230
1326
  missing = [File.basename(Savepoint.intent_file(intent_dir))] + missing
1231
1327
  end
@@ -1263,6 +1359,80 @@ end
1263
1359
  end
1264
1360
  end
1265
1361
 
1362
+ # Intent 329: the checklist is the only input to the state screen's Progress bar, so an
1363
+ # intent whose commit ledger has entries and whose checklist has no ticked item is
1364
+ # reporting 0 progress on work that already landed. Advisory only (WARN), like
1365
+ # intent_savepoint_truthful: a lagging tick is a lead's review finding, never a machine
1366
+ # refusal. The commit count comes from this intent's own savepoint.md Commit ledger
1367
+ # (intent 317 D17), never from git: git's detected base is the repo default branch, which
1368
+ # is not the base a 2.0 intent branch forks from.
1369
+ def intent_ticks_lag_check(intent_dir)
1370
+ items = IntentScreen.checklist_items(intent_dir)
1371
+ if items.empty?
1372
+ return check(category: "intent_end", name: "intent_ticks_lag", status: "pass",
1373
+ message: "n/a: checklist.md has no items to tick")
1374
+ end
1375
+
1376
+ ticked = items.count { |i| i[:done] }
1377
+ commits = savepoint_commit_count(intent_dir)
1378
+
1379
+ if commits.positive? && ticked.zero?
1380
+ check(category: "intent_end", name: "intent_ticks_lag", status: "warn",
1381
+ message: "ticks lag the branch: #{commits} commit(s) recorded, " \
1382
+ "0 of #{items.size} checklist items ticked")
1383
+ else
1384
+ check(category: "intent_end", name: "intent_ticks_lag", status: "pass",
1385
+ message: "#{ticked} of #{items.size} ticked, #{commits} commit(s) recorded")
1386
+ end
1387
+ end
1388
+
1389
+ # Intent 331f: every skill that shows state during Exec is bound to print a report screen and
1390
+ # log a `Report` savepoint line (`savepoint-note --kind Report`). A commit landed with no
1391
+ # Report line anywhere in the ledger is the exact defect this check exists to catch - the
1392
+ # delivery ran but nothing on disk proves a screen was ever printed. WARN-only, like
1393
+ # intent_ticks_lag: a lead's review finding, never a machine refusal. R6: never re-litigate
1394
+ # history - an intent whose newest Commit line predates the day the Report kind shipped
1395
+ # (Savepoint::REPORT_KIND_SINCE) passes, so every pre-existing ledger keeps passing
1396
+ # `doctor --intent` (which exits 1 on an overall warn).
1397
+ def intent_reports_printed_check(intent_dir)
1398
+ path = File.join(intent_dir, "savepoint.md")
1399
+ lines = File.exist?(path) ? File.readlines(path) : []
1400
+ kinds = lines.filter_map { |l| l.strip.match(IntentScreen::SAVEPOINT_RE) }
1401
+
1402
+ commit_timestamps = kinds.select { |m| m[2] == "Commit" }.map { |m| m[1] }
1403
+ if commit_timestamps.empty?
1404
+ return check(category: "intent_end", name: "intent_reports_printed", status: "pass",
1405
+ message: "n/a: no commits recorded")
1406
+ end
1407
+
1408
+ if kinds.any? { |m| m[2] == "Report" }
1409
+ return check(category: "intent_end", name: "intent_reports_printed", status: "pass",
1410
+ message: "a Report line is recorded")
1411
+ end
1412
+
1413
+ newest_commit_date = commit_timestamps.max[0, 10]
1414
+ if newest_commit_date < Savepoint::REPORT_KIND_SINCE
1415
+ return check(category: "intent_end", name: "intent_reports_printed", status: "pass",
1416
+ message: "n/a: newest commit (#{newest_commit_date}) predates the Report " \
1417
+ "kind (#{Savepoint::REPORT_KIND_SINCE})")
1418
+ end
1419
+
1420
+ check(category: "intent_end", name: "intent_reports_printed", status: "warn",
1421
+ message: "commits are recorded and no Report line exists")
1422
+ end
1423
+
1424
+ # Count `Commit` lines in the intent's savepoint ledger, through the one regex that parses
1425
+ # a savepoint line (IntentScreen::SAVEPOINT_RE; field 2 is the kind).
1426
+ def savepoint_commit_count(intent_dir)
1427
+ path = File.join(intent_dir, "savepoint.md")
1428
+ return 0 unless File.exist?(path)
1429
+
1430
+ File.readlines(path).count do |line|
1431
+ m = line.strip.match(IntentScreen::SAVEPOINT_RE)
1432
+ m && m[2] == "Commit"
1433
+ end
1434
+ end
1435
+
1266
1436
  def intent_links_projection_check_for(id, scope)
1267
1437
  ctx = build_links_projection_context
1268
1438
  node = ctx[:intents].find { |n| n[:id] == id && n[:scope] == scope }
@@ -1369,20 +1539,37 @@ end
1369
1539
  expected_pair = Savepoint.savepoint_milestone(intent_dir, File.basename(Savepoint.intent_file(intent_dir)))
1370
1540
 
1371
1541
  phantoms = Savepoint.savepoint_phantom_lines(intent_dir)
1542
+ # Intent 335 (spec C2/C5): a torn or unattributed node/Intent transition
1543
+ # line has no reader anywhere else in the tree, so this is where it
1544
+ # surfaces. NodeLedger.anomalies never touches a stage line (its own
1545
+ # transition-candidate gate excludes them), so this is purely additive
1546
+ # beside the phantom check above.
1547
+ transition_anomalies = NodeLedger.anomalies(savepoint)
1372
1548
  problems = []
1373
1549
  problems << "born line #{born_pair.inspect} does not match the expected #{expected_pair.inspect}" \
1374
1550
  if born_pair != expected_pair
1375
1551
  problems << "#{phantoms.size} phantom savepoint line(s): " \
1376
1552
  "#{phantoms.map { |l, r| "#{l} (#{r})" }.join("; ")}" if phantoms.any?
1553
+ problems << "#{transition_anomalies.size} torn/unattributed transition line(s): " \
1554
+ "#{transition_anomalies.map { |a| "#{a[:line]} (#{a[:reason]})" }.join("; ")}" \
1555
+ if transition_anomalies.any?
1377
1556
 
1378
1557
  if problems.empty?
1379
1558
  check(category: "intent_end", name: "intent_savepoint_truthful", status: "pass",
1380
1559
  message: "Savepoint born line and phantom-line state are truthful")
1381
1560
  else
1561
+ # A torn or unattributed transition line's CONTENT cannot be repaired by
1562
+ # a rebuild (rebuild_savepoint preserves it verbatim, spec D13), unlike a
1563
+ # born-line mismatch or a stage phantom, which rebuild does fix. Say so
1564
+ # explicitly rather than pointing at a fix that will not fix it.
1565
+ fix_hint = "Rebuild the ledger via Savepoint.rebuild_savepoint"
1566
+ fix_hint += "; note: rebuild preserves a torn or unattributed transition line " \
1567
+ "verbatim and does not repair one - hand-edit it or accept the finding" \
1568
+ if transition_anomalies.any?
1382
1569
  check(category: "intent_end", name: "intent_savepoint_truthful", status: "warn",
1383
1570
  message: "#{problems.size} savepoint truthfulness issue(s) (advisory, never blocking)",
1384
1571
  details: problems,
1385
- fixable: true, fix_hint: "Rebuild the ledger via Savepoint.rebuild_savepoint")
1572
+ fixable: true, fix_hint: fix_hint)
1386
1573
  end
1387
1574
  end
1388
1575
 
@@ -1507,7 +1694,7 @@ end
1507
1694
  "Every sources/chain id resolves to a real intent (I4)",
1508
1695
  "Record the dangling sources/chain edge as a " \
1509
1696
  "broken-source/broken-chain move-and-record entry in the intent's revisions.md (see " \
1510
- "plastic-conventions > references/maintenance-and-revisions.md), or restore the missing intent"
1697
+ "`plastic help maintenance-and-revisions`), or restore the missing intent"
1511
1698
  )
1512
1699
  checks
1513
1700
  end
@@ -1528,6 +1715,210 @@ end
1528
1715
  end
1529
1716
  end
1530
1717
 
1718
+ # ReadySet over every intent carrying a real graph.md (intent 336, n7):
1719
+ # dead ends, stale done nodes, and expired running leases. An intent with
1720
+ # no real graph.md is skipped entirely (never touches ReadySet); a
1721
+ # malformed or cyclic one is reported by name, never raised out of doctor.
1722
+ def node_graph_checks(intent_dirs)
1723
+ dead_ends = []
1724
+ stale = []
1725
+ expired = []
1726
+ malformed = []
1727
+
1728
+ intent_dirs.each do |d|
1729
+ graph_path = File.join(d[:path], "graph.md")
1730
+ next unless Savepoint.stage_file_present?(graph_path)
1731
+
1732
+ analysis = ReadySet.analyze(d[:path])
1733
+ unless analysis[:ok]
1734
+ malformed << "#{d[:name]}: #{analysis[:errors].join('; ')}"
1735
+ next
1736
+ end
1737
+
1738
+ savepoint_path = File.join(d[:path], "savepoint.md")
1739
+ analysis[:nodes].each do |id, view|
1740
+ dead_ends << "#{d[:name]}/#{id}" if view[:dead_end]
1741
+ stale << "#{d[:name]}/#{id}" if view[:stale]
1742
+ expired << "#{d[:name]}/#{id}" if view[:state] == "running" && expired_running_lease?(savepoint_path, id)
1743
+ end
1744
+ end
1745
+
1746
+ [
1747
+ node_graph_finding_check(
1748
+ "node_graph_dead_ends", dead_ends,
1749
+ "No node waits on a need that resolves to superseded or abandoned",
1750
+ "Re-plan or supersede the dead-end node's need chain, or abandon the node itself"
1751
+ ),
1752
+ node_graph_finding_check(
1753
+ "node_graph_stale_done", stale,
1754
+ "No done node rests on a need superseded after it finished",
1755
+ "Re-verify the stale node against the superseding work, or record the staleness in revisions.md"
1756
+ ),
1757
+ node_graph_finding_check(
1758
+ "node_graph_expired_running_lease", expired,
1759
+ "No running node's lease has expired unreclaimed",
1760
+ "Run `node-transition <intent_dir> --node <id> --state reclaimed --field holder=<h> " \
1761
+ "--field expired=<iso>` to sweep it"
1762
+ ),
1763
+ node_graph_finding_check(
1764
+ "node_graph_malformed", malformed,
1765
+ "Every intent's graph.md parses cleanly",
1766
+ "Fix the malformed graph.md or nodes/ file named in the details"
1767
+ ),
1768
+ ]
1769
+ end
1770
+
1771
+ # True iff `id`'s last running line in the ledger at `savepoint_path` carries
1772
+ # an `expires=` strictly in the past. An unparseable or absent expires=
1773
+ # never counts as expired (fail milder than the bug).
1774
+ def expired_running_lease?(savepoint_path, id, now: Time.now)
1775
+ entry = NodeLedger.last_running(savepoint_path, id)
1776
+ return false unless entry
1777
+
1778
+ expires_raw = (entry[:fields] || {})["expires"]
1779
+ expiry = begin
1780
+ expires_raw && Time.iso8601(expires_raw)
1781
+ rescue ArgumentError
1782
+ nil
1783
+ end
1784
+ !!(expiry && now > expiry)
1785
+ end
1786
+
1787
+ # One node-graph check: pass when `findings` is empty, otherwise warn
1788
+ # (never fail, matching graph_finding_check's own precedent - an existing
1789
+ # store never turns red on an advisory graph finding).
1790
+ def node_graph_finding_check(name, findings, pass_message, fix_hint)
1791
+ if findings.empty?
1792
+ check(category: "conventions", name: name, status: "pass", message: pass_message)
1793
+ else
1794
+ check(
1795
+ category: "conventions", name: name, status: "warn",
1796
+ message: "#{findings.size} #{name} violation(s)",
1797
+ details: findings, fixable: false, fix_hint: fix_hint
1798
+ )
1799
+ end
1800
+ end
1801
+
1802
+ # --- Check: re-qualification on model change (D42, intent 343 G10 n7) ---
1803
+ #
1804
+ # When the model behind a role changes, every measurement GraphMeasureModels
1805
+ # took under the old model measured a different system, and the numbers
1806
+ # need re-qualifying before anyone acts on them (D42). This warns, never
1807
+ # fails (D10): a model change is routine, and a red doctor over it would
1808
+ # train people to stop reading the report. The comparison itself is
1809
+ # GraphMeasureModels' own (D16, via RunnerPolicy.model_for) - this method
1810
+ # never re-derives it, it only names the drift rows GraphMeasureModels
1811
+ # already computed.
1812
+ #
1813
+ # Runs once per discovered store (StoreDiscovery, shared with every other
1814
+ # store-scoped check here), never per intent directory: GraphMeasureModels
1815
+ # walks a whole store_dir itself in one pass. A store this process cannot
1816
+ # read - or any other exception the store walk raises - is skipped, never
1817
+ # fatal: one bad store must never take every other doctor check down with
1818
+ # it.
1819
+ #
1820
+ # NEW-4 (v2 review, D22): this rule used to pass `project_config: {}` on
1821
+ # purpose, reasoning there was no live project scope at doctor-run time -
1822
+ # false: each discovered store DOES have a real sibling `config.yml`
1823
+ # (`GraphMeasureModels.project_config_path`), the same file the `cohorts`
1824
+ # verb resolves. Loading it here means the doctor and `cohorts` can never
1825
+ # disagree about the same store's project-scope override again.
1826
+ def model_requalification_checks(scopes: nil)
1827
+ global_config = load_yaml_safe(File.join(plastic_home, "config.yml")) || {}
1828
+ findings = []
1829
+
1830
+ store_discovery[:stores].each do |s|
1831
+ next if scopes && !scopes.include?(s[:key])
1832
+
1833
+ project_config = load_yaml_safe(GraphMeasureModels.project_config_path(s[:store])) || {}
1834
+ record = begin
1835
+ GraphMeasureModels.read(s[:store], project_config: project_config, global_config: global_config)
1836
+ rescue StandardError
1837
+ next
1838
+ end
1839
+
1840
+ %i[executor advisor].each do |role|
1841
+ Array(record[:drift][role]).each do |row|
1842
+ findings << "#{s[:key]}/#{row[:intent]}/#{row[:node]} (#{role}): recorded model=#{row[:recorded]}, " \
1843
+ "current config resolves #{row[:expected]} for kind #{row[:kind]} - re-qualify any " \
1844
+ "measurement taken while model=#{row[:recorded]} was in effect"
1845
+ end
1846
+ end
1847
+ end
1848
+
1849
+ if findings.empty?
1850
+ [check(
1851
+ category: "conventions", name: "model_requalification", status: "pass",
1852
+ message: "No recorded model= differs from what config resolves today for its role"
1853
+ )]
1854
+ else
1855
+ [check(
1856
+ category: "conventions", name: "model_requalification", status: "warn",
1857
+ message: "#{findings.size} node(s) recorded a model that no longer matches the role's " \
1858
+ "config-resolved model",
1859
+ details: findings, fixable: false,
1860
+ fix_hint: "The old measurements were taken against a different model; re-qualify them under " \
1861
+ "the current model before acting on their numbers"
1862
+ )]
1863
+ end
1864
+ end
1865
+
1866
+ # --- Check: unpromoted rule: findings (intent 341, G8, C37) ---------------
1867
+ #
1868
+ # `insight-append --rule` tags an entry "... - rule: <text>". A tag is a
1869
+ # promise the rule will make it into project doctrine; until the exact
1870
+ # rule text shows up in some docs/help/*.md chapter, it is only visible to
1871
+ # a session that happens to read this one intent file, and the next
1872
+ # session repeats the mistake the rule names. Advisory only (warn, never
1873
+ # fail): a freshly tagged rule is not yet promoted by design, and nothing
1874
+ # here can auto-promote it (that is an editorial call, not a mechanical
1875
+ # one).
1876
+ RULE_ENTRY_RE = /—\s*rule:\s*(.+?)\s*\z/.freeze
1877
+
1878
+ def unpromoted_rules_checks(intent_dirs, package_root: PACKAGE_ROOT)
1879
+ # docs/help installs straight under PACKAGE_ROOT for every harness alike
1880
+ # (~/.plastic/docs/help once installed, repo docs/help in a checkout), so
1881
+ # a single path covers both the repo and the installed layout; unlike
1882
+ # the old skills-based chapters, help topics are no longer agent-home
1883
+ # specific, so this needs no `home:` split.
1884
+ chapter_dir = File.join(package_root, "docs", "help")
1885
+
1886
+ chapters_text = Dir.glob(File.join(chapter_dir, "*.md"))
1887
+ .map { |f| File.read(f) }
1888
+ .join("\n\n")
1889
+
1890
+ unpromoted = []
1891
+ intent_dirs.each do |d|
1892
+ md_path = File.join(d[:path], "#{d[:name]}.md")
1893
+ next unless File.exist?(md_path)
1894
+
1895
+ File.readlines(md_path).each do |line|
1896
+ m = line.chomp.match(RULE_ENTRY_RE)
1897
+ next unless m
1898
+
1899
+ rule_text = m[1].strip
1900
+ next if rule_text.empty?
1901
+
1902
+ unpromoted << "#{tilde(d[:path])}: #{rule_text}" unless chapters_text.include?(rule_text)
1903
+ end
1904
+ end
1905
+
1906
+ if unpromoted.empty?
1907
+ [check(
1908
+ category: "conventions", name: "unpromoted_rules", status: "pass",
1909
+ message: "Every tagged rule: finding is carried by a conventions chapter"
1910
+ )]
1911
+ else
1912
+ [check(
1913
+ category: "conventions", name: "unpromoted_rules", status: "warn",
1914
+ message: "#{unpromoted.size} tagged rule(s) not yet carried by any conventions chapter",
1915
+ details: unpromoted, fixable: false,
1916
+ fix_hint: "Promote the rule into the right docs/help/*.md chapter, " \
1917
+ "or drop the tag if the finding does not belong in doctrine"
1918
+ )]
1919
+ end
1920
+ end
1921
+
1531
1922
  # --- Check category 3: Agent registration ---
1532
1923
 
1533
1924
 
@@ -1539,8 +1930,7 @@ end
1539
1930
  # skill directory under agent_dir/skills that has NO corresponding entry in the
1540
1931
  # current install manifest is a stray (e.g. a leftover old-name copy the
1541
1932
  # install/update prune should have removed, or one it never saw because the
1542
- # manifest predates it). Complements flat_skills_check (which only confirms at
1543
- # least one skill exists). Defers to the manifest check when the manifest itself
1933
+ # manifest predates it). Defers to the manifest check when the manifest itself
1544
1934
  # is missing or malformed, so the two checks never double-report the same gap.
1545
1935
 
1546
1936
  # Auto-mode role agents install as <dir>/agents/plastic-*.md. Pass if at least
@@ -1764,6 +2154,8 @@ end
1764
2154
 
1765
2155
  global_config = load_yaml_safe(File.join(plastic_home, "config.yml")) || {}
1766
2156
  overrides = AgentModels.override_map(project_config: {}, global_config: global_config, harness: "codex")
2157
+ effort_overrides = AgentModels.effort_override_map(project_config: {}, global_config: global_config,
2158
+ harness: "codex")
1767
2159
 
1768
2160
  drifted = []
1769
2161
  sanctioned = []
@@ -1774,13 +2166,13 @@ end
1774
2166
  fields = codex_agent_toml_model_fields(File.read(path))
1775
2167
  override = overrides[basename]
1776
2168
 
1777
- if override
2169
+ if override || effort_overrides[basename]
1778
2170
  sanctioned << "#{basename}: toml model=#{fields[:model].inspect}, " \
1779
- "toml effort=#{fields[:effort].inspect}, sanctioned override=#{override.inspect}"
1780
- elsif AgentModels::TIER_DEFAULTS.key?(basename)
1781
- tier = AgentModels::TIER_DEFAULTS[basename]
1782
- expected_model = AgentModels.codex_model_for(tier)
1783
- expected_effort = AgentModels.effort_for(tier)
2171
+ "toml effort=#{fields[:effort].inspect}, sanctioned model override=#{override.inspect}, " \
2172
+ "effort override=#{effort_overrides[basename].inspect}"
2173
+ elsif AgentModels::SHIPPED_MODEL_DEFAULTS.key?(basename)
2174
+ expected_model = AgentModels.shipped_model_for(basename, harness: "codex")
2175
+ expected_effort = AgentModels.shipped_effort_for(basename)
1784
2176
  mismatches = []
1785
2177
  if expected_model && fields[:model] != expected_model
1786
2178
  mismatches << "model=#{fields[:model].inspect}, resolved default model=#{expected_model.inspect}"
@@ -1792,7 +2184,7 @@ end
1792
2184
  else
1793
2185
  unclassified << "#{basename}: toml model=#{fields[:model].inspect}, " \
1794
2186
  "toml effort=#{fields[:effort].inspect}, no resolved default (basename is in " \
1795
- "neither AgentModels::TIER_DEFAULTS nor AgentModels::CONSULTATION_AGENTS in " \
2187
+ "AgentModels::SHIPPED_MODEL_DEFAULTS in " \
1796
2188
  "scripts/lib/agent_models.rb; add it there, or set agents.models.codex.#{basename} " \
1797
2189
  "to sanction a model explicitly)"
1798
2190
  end
@@ -1904,7 +2296,7 @@ end
1904
2296
  # project can be checked in isolation (used by `--store <slug>`).
1905
2297
  def check_project_store(slug, project_info)
1906
2298
  checks = []
1907
- project_dir = File.join(plastic_home, "projects", slug)
2299
+ project_dir = Plastic::StoreLayout.project_root(plastic_home, slug)
1908
2300
 
1909
2301
  # project_dir_exists
1910
2302
  if File.directory?(project_dir)
@@ -1954,7 +2346,7 @@ end
1954
2346
  end
1955
2347
 
1956
2348
  # project_yml_exists
1957
- project_yml_path = File.join(plastic_home, "projects", slug, "project.yml")
2349
+ project_yml_path = File.join(Plastic::StoreLayout.project_root(plastic_home, slug), "project.yml")
1958
2350
  project_yml_data = nil
1959
2351
 
1960
2352
  if File.exist?(project_yml_path)
@@ -2001,7 +2393,7 @@ end
2001
2393
  return checks unless parent_id
2002
2394
 
2003
2395
  # Find the intent directory for the parent ID
2004
- store_dir = File.join(plastic_home, "store")
2396
+ store_dir = Plastic::StoreLayout.global_store(plastic_home)
2005
2397
  parent_dir = nil
2006
2398
  if File.directory?(store_dir)
2007
2399
  parent_dir = Dir.children(store_dir).find { |d| d.start_with?("#{parent_id}--") }
@@ -2158,7 +2550,7 @@ end
2158
2550
  # A config-asks warn in that tier would flip every post-update doctor to
2159
2551
  # "fail" on an otherwise healthy install until the question is answered,
2160
2552
  # re-noising the post-update surface intent 126 deliberately quieted. The
2161
- # recoverable-later path for a pending question is a full `/plastic-doctor`
2553
+ # recoverable-later path for a pending question is a full `plastic doctor`
2162
2554
  # run, the declared maintenance front door; the moment-it-happens path is
2163
2555
  # update.rb#announce_pending_config_asks, which already runs on every hop.
2164
2556
  #
@@ -2388,6 +2780,250 @@ end
2388
2780
  end
2389
2781
  end
2390
2782
 
2783
+ # --- Check category: display (intent 331e, D1) ---
2784
+ #
2785
+ # Three of the four `display` checks live HERE, not in doctor_core.rb: they
2786
+ # need Open3/Timeout to spawn the installed hook as a real subprocess, and
2787
+ # that must never attach to the SessionStart boot path
2788
+ # (test/doctor_core_split_test.rb T2 pins doctor_core.rb's require set
2789
+ # exactly). check_display_registration (the fourth, --core-scoped) lives in
2790
+ # doctor_core.rb instead, alongside display_hook_launcher_name, which this
2791
+ # file's check_display_paints reuses to name the SAME installed launcher.
2792
+
2793
+ # The ambient defeater active for THIS invocation, or nil. `no_color` is
2794
+ # DI'd (default ENV["NO_COLOR"]) rather than read deep inside this method,
2795
+ # so a test can force it on or off without touching the real process
2796
+ # environment. Shared by check_display_paints (a defeater turns a
2797
+ # would-be fail into a pass, R3) and check_display_not_defeated (a
2798
+ # defeater is what it warns about).
2799
+ def active_display_defeater(no_color: ENV["NO_COLOR"])
2800
+ return "NO_COLOR" unless no_color.to_s.empty?
2801
+
2802
+ cfg = load_yaml_safe(File.join(plastic_home, "config.yml"))
2803
+ display_cfg = cfg.is_a?(Hash) ? cfg["display"] : nil
2804
+ return "display.ansi_screen: false in config.yml" if display_cfg.is_a?(Hash) &&
2805
+ display_cfg.fetch("ansi_screen", true) == false
2806
+
2807
+ nil
2808
+ end
2809
+
2810
+ # The shipped fixture's path: plastic_home's own templates/ first (a real
2811
+ # install), the package's own templates/ otherwise (running from a repo
2812
+ # checkout, or an install whose templates/ predates this fixture). Neither
2813
+ # existing means no fixture at all.
2814
+ def display_fixture_path(package_root: PACKAGE_ROOT)
2815
+ [File.join(plastic_home, "templates", "display-fixture.md"),
2816
+ File.join(package_root, "templates", "display-fixture.md")].find { |p| File.file?(p) }
2817
+ end
2818
+
2819
+ # The fixture's replayable text: its header comment (see
2820
+ # templates/display-fixture.md) is for a human reading the file on disk,
2821
+ # never sent through the hook.
2822
+ def display_fixture_text(path)
2823
+ File.read(path).sub(/\A<!--.*?-->\n\n?/m, "")
2824
+ end
2825
+
2826
+ # display_hook_paints (D1, R1/R2/R3): replays the shipped fixture through
2827
+ # the INSTALLED launcher (`<agent_dir>/hooks/plastic-message-display`,
2828
+ # never this package's own hooks/message-display. That is R1's whole point: an
2829
+ # installed launcher can predate the package's, and replaying the wrong
2830
+ # one reports pass while the real stack is stale) and expects a painted
2831
+ # (ANSI) screen back.
2832
+ #
2833
+ # A known defeater active (NO_COLOR, or display.ansi_screen: false) turns
2834
+ # a no-SGR result into a PASS, naming the defeater and noting it reflects
2835
+ # this invocation's own environment (R3). display_not_defeated is what
2836
+ # warns about a defeater; this check never fails because of one.
2837
+ def check_display_paints(agent_key, no_color: ENV["NO_COLOR"],
2838
+ tmp_dir_factory: -> { Dir.mktmpdir("plastic-doctor-display") },
2839
+ timeout_seconds: 10, package_root: PACKAGE_ROOT)
2840
+ config = agents[agent_key]
2841
+ unless agent_key == "claude"
2842
+ return [check(
2843
+ category: "display", name: "display_hook_paints", status: "pass",
2844
+ message: "#{config[:name]} is plain by contract; no display hook to replay"
2845
+ )]
2846
+ end
2847
+
2848
+ fixture_path = display_fixture_path(package_root: package_root)
2849
+ unless fixture_path
2850
+ return [check(
2851
+ category: "display", name: "display_hook_paints", status: "fail",
2852
+ message: "Shipped display fixture is missing (templates/display-fixture.md)",
2853
+ fixable: false
2854
+ )]
2855
+ end
2856
+
2857
+ agent_dir = config[:dir]
2858
+ launcher_path = File.join(agent_dir, "hooks", display_hook_launcher_name)
2859
+ unless File.file?(launcher_path) && File.executable?(launcher_path)
2860
+ return [check(
2861
+ category: "display", name: "display_hook_paints", status: "fail",
2862
+ message: "Installed launcher #{tilde(launcher_path)} is missing or not executable; cannot replay",
2863
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
2864
+ )]
2865
+ end
2866
+
2867
+ text = display_fixture_text(fixture_path)
2868
+ tmp_dir = tmp_dir_factory.call
2869
+ outs =
2870
+ begin
2871
+ HookReplay.replay(hook_path: launcher_path, tmp_root: tmp_dir, text: text,
2872
+ env: { "PLASTIC_HOME" => plastic_home }, timeout: timeout_seconds)
2873
+ rescue StandardError => e
2874
+ # Process.spawn (inside HookReplay) can raise before a pid ever
2875
+ # exists: a permissions race, or a launcher that vanishes between the
2876
+ # executable? check above and the spawn, or any other unexpected
2877
+ # error. Unlike this codebase's defensive style elsewhere
2878
+ # (read_json_safe, load_yaml_safe), nothing here degraded that into
2879
+ # a clean check result, so an unlucky replay crashed the entire
2880
+ # doctor run instead of failing just this one check (intent 331e,
2881
+ # F6). `return` still runs the `ensure` below before unwinding.
2882
+ return [check(
2883
+ category: "display", name: "display_hook_paints", status: "fail",
2884
+ message: "Replaying the installed launcher raised #{e.class}: #{e.message}",
2885
+ fixable: false
2886
+ )]
2887
+ ensure
2888
+ FileUtils.remove_entry(tmp_dir) if tmp_dir && File.exist?(tmp_dir)
2889
+ end
2890
+
2891
+ if HookReplay.timed_out?(outs)
2892
+ return [check(
2893
+ category: "display", name: "display_hook_paints", status: "fail",
2894
+ message: "Replaying the installed launcher timed out after #{timeout_seconds}s; painting could not be verified",
2895
+ fixable: false
2896
+ )]
2897
+ end
2898
+
2899
+ defeater = active_display_defeater(no_color: no_color)
2900
+ if defeater
2901
+ return [check(
2902
+ category: "display", name: "display_hook_paints", status: "pass",
2903
+ message: "Painting is defeated by #{defeater} for this invocation; the replay's plain " \
2904
+ "output reflects that setting, not a broken hook"
2905
+ )]
2906
+ end
2907
+
2908
+ content = HookReplay.final_display_content(outs)
2909
+ if content.to_s.include?("\e[")
2910
+ [check(
2911
+ category: "display", name: "display_hook_paints", status: "pass",
2912
+ message: "The installed hook returned a painted (ANSI) screen"
2913
+ )]
2914
+ else
2915
+ [check(
2916
+ category: "display", name: "display_hook_paints", status: "fail",
2917
+ message: "The installed hook returned no ANSI escape sequence; painting may be broken",
2918
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
2919
+ )]
2920
+ end
2921
+ end
2922
+
2923
+ # display_not_defeated (D1, R3/R4): warns, never fails, on each active
2924
+ # defeater, naming the setting and its effect. Every result, pass or warn alike,
2925
+ # names the verbose transcript view (Ctrl+O, R4): it redraws every
2926
+ # screen as plain tables too, but it has no on-disk setting doctor can
2927
+ # read, so its absence from these warnings is never proof that view paints.
2928
+ def check_display_not_defeated(agent_key, no_color: ENV["NO_COLOR"])
2929
+ config = agents[agent_key]
2930
+ unless agent_key == "claude"
2931
+ return [check(
2932
+ category: "display", name: "display_not_defeated", status: "pass",
2933
+ message: "#{config[:name]} is plain by contract; no display defeaters apply"
2934
+ )]
2935
+ end
2936
+
2937
+ warnings = []
2938
+ warnings << "NO_COLOR is set: forces every screen to plain text for this invocation" unless no_color.to_s.empty?
2939
+
2940
+ cfg = load_yaml_safe(File.join(plastic_home, "config.yml"))
2941
+ display_cfg = cfg.is_a?(Hash) ? cfg["display"] : nil
2942
+ if display_cfg.is_a?(Hash) && display_cfg.fetch("ansi_screen", true) == false
2943
+ warnings << "config.yml sets display.ansi_screen: false: forces every screen to plain text"
2944
+ end
2945
+
2946
+ settings = read_json_safe(File.join(config[:dir], "settings.json"))
2947
+ if settings.is_a?(Hash) && settings["verbose"] == true
2948
+ warnings << "settings.json sets verbose: true: the verbose transcript view (Ctrl+O) redraws every screen as plain tables"
2949
+ end
2950
+
2951
+ transcript_note = "The Ctrl+O verbose transcript view also redraws every screen as plain " \
2952
+ "tables and has no on-disk setting; its absence above is never proof that view paints."
2953
+
2954
+ if warnings.empty?
2955
+ [check(
2956
+ category: "display", name: "display_not_defeated", status: "pass",
2957
+ message: "No known display defeaters active", details: [transcript_note]
2958
+ )]
2959
+ else
2960
+ [check(
2961
+ category: "display", name: "display_not_defeated", status: "warn",
2962
+ message: "#{warnings.size} display defeater(s) active",
2963
+ details: warnings + [transcript_note]
2964
+ )]
2965
+ end
2966
+ end
2967
+
2968
+ # display_surfaces_documented (D1/D4): the harness-adapters doc names the
2969
+ # three surface classes. Reads the package's own shipped doc: static
2970
+ # content, not a runtime path, the same shape as check_skill_lint reading the
2971
+ # package's own skills/ tree.
2972
+ #
2973
+ # `docs/` ships in NEITHER package.json's `files` list NOR
2974
+ # InstallerCore's manifest (grep confirms zero references), so on every
2975
+ # real install `package_root` resolves to a `~/.plastic` that has no
2976
+ # `docs/` tree at all; only a repo checkout carries it. Absence of the
2977
+ # doc there is therefore not a defect to report; it is this install
2978
+ # having nothing to verify, the same skip-as-pass vocabulary D3 and R3
2979
+ # already use elsewhere in this category. This check fails only when the
2980
+ # doc DOES exist (a repo checkout) but has rotted: no `## Surfaces`
2981
+ # section, or one missing a required literal.
2982
+ def check_display_surfaces_documented(package_root: PACKAGE_ROOT)
2983
+ doc_path = File.join(package_root, "docs", "reference", "harness-adapters.md")
2984
+
2985
+ unless File.file?(doc_path)
2986
+ return [check(
2987
+ category: "display", name: "display_surfaces_documented", status: "pass",
2988
+ message: "Reference docs are not shipped to this install (#{tilde(doc_path)} absent); " \
2989
+ "nothing to verify"
2990
+ )]
2991
+ end
2992
+
2993
+ content = File.read(doc_path)
2994
+ section = content[/^## Surfaces\n(.*?)(?=\n## |\z)/m, 1].to_s
2995
+
2996
+ required = ["Claude Code normal view", "agents view", "Codex", "claude -p", "verbose transcript view"]
2997
+ missing = required.reject { |literal| section.include?(literal) }
2998
+
2999
+ if section.empty?
3000
+ [check(
3001
+ category: "display", name: "display_surfaces_documented", status: "fail",
3002
+ message: "docs/reference/harness-adapters.md has no ## Surfaces section", fixable: false
3003
+ )]
3004
+ elsif missing.empty?
3005
+ [check(
3006
+ category: "display", name: "display_surfaces_documented", status: "pass",
3007
+ message: "harness-adapters.md documents every display surface class"
3008
+ )]
3009
+ else
3010
+ [check(
3011
+ category: "display", name: "display_surfaces_documented", status: "fail",
3012
+ message: "## Surfaces section is missing: #{missing.join(', ')}", details: missing, fixable: false
3013
+ )]
3014
+ end
3015
+ end
3016
+
3017
+ def check_graph_links
3018
+ return [] unless File.exist?(Plastic::WorkGraph.path(plastic_home))
3019
+
3020
+ orphans = Plastic::StoreSync.orphans(plastic_home)
3021
+ return [check(category: "graph_links", name: "graph_links", status: "pass", message: "every stored file belongs to an intent in work_graph.db")] if orphans.empty?
3022
+
3023
+ [check(category: "graph_links", name: "graph_links", status: "warn", details: orphans, fixable: false,
3024
+ message: "#{orphans.size} intent id(s) hold files and have no row in work_graph.db")]
3025
+ end
3026
+
2391
3027
  # --- Run all checks ---
2392
3028
 
2393
3029
  def run_checks(agent_key)
@@ -2406,6 +3042,11 @@ end
2406
3042
  all_checks += check_session_ledger(scopes: ["global"])
2407
3043
  all_checks += check_skill_lint
2408
3044
  all_checks += check_install_integrity
3045
+ all_checks += check_graph_links
3046
+ all_checks += check_display_registration(agent_key)
3047
+ all_checks += check_display_paints(agent_key)
3048
+ all_checks += check_display_not_defeated(agent_key)
3049
+ all_checks += check_display_surfaces_documented
2409
3050
 
2410
3051
  summarize(all_checks, agent_key)
2411
3052
  end