@zalom/plastic 1.14.1 → 2.0.0-alpha.10

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 (213) hide show
  1. package/PLASTIC.md +101 -294
  2. package/README.md +5 -6
  3. package/agents/plastic-advisor.md +18 -17
  4. package/agents/plastic-enforcer.md +57 -69
  5. package/agents/plastic-executor.md +34 -20
  6. package/agents/plastic-faux-advisor.md +18 -18
  7. package/bin/lib/context_budget.rb +453 -0
  8. package/bin/plastic-bench +78 -0
  9. package/config_asks.yml +38 -0
  10. package/hooks/capture +4 -0
  11. package/hooks/close +5 -0
  12. package/hooks/hooks.json +14 -42
  13. package/hooks/message-display +81 -0
  14. package/hooks/record +4 -0
  15. package/hooks/savepoint +5 -5
  16. package/package.json +3 -2
  17. package/scripts/agent-report +15 -10
  18. package/scripts/append-ledger +208 -0
  19. package/scripts/codex-hook +78 -90
  20. package/scripts/dashboard.rb +43 -14
  21. package/scripts/day-summary +53 -0
  22. package/scripts/doctor.rb +269 -43
  23. package/scripts/end-intent +170 -76
  24. package/scripts/exec-worktree +11 -20
  25. package/scripts/file-session-intent +61 -0
  26. package/scripts/hook-capture +255 -0
  27. package/scripts/hook-close +36 -0
  28. package/scripts/hook-message-display +74 -0
  29. package/scripts/hook-record +157 -0
  30. package/scripts/hook-savepoint +45 -0
  31. package/scripts/hook-session-start +128 -12
  32. package/scripts/install.rb +8 -6
  33. package/scripts/intent-screen +77 -0
  34. package/scripts/lib/agent_models.rb +1 -7
  35. package/scripts/lib/arm.rb +308 -0
  36. package/scripts/lib/backfill_intent.rb +316 -0
  37. package/scripts/lib/bridge.rb +24 -1597
  38. package/scripts/lib/compact_instructions.rb +56 -0
  39. package/scripts/lib/day_summary.rb +211 -0
  40. package/scripts/lib/doctor_core.rb +66 -88
  41. package/scripts/lib/doctor_session_ledger.rb +158 -0
  42. package/scripts/lib/exec_worktree.rb +14 -60
  43. package/scripts/lib/handoff.rb +184 -0
  44. package/scripts/lib/harness_text.rb +5 -4
  45. package/scripts/lib/hook_registry.rb +57 -106
  46. package/scripts/lib/insights.rb +3 -4
  47. package/scripts/lib/installer_core.rb +136 -41
  48. package/scripts/lib/intent_screen.rb +309 -0
  49. package/scripts/lib/intent_screen_ansi.rb +262 -0
  50. package/scripts/lib/lock.rb +6 -26
  51. package/scripts/lib/message_display.rb +290 -0
  52. package/scripts/lib/outcome_guard.rb +7 -5
  53. package/scripts/lib/project_validator.rb +30 -1
  54. package/scripts/lib/report_screen.rb +648 -0
  55. package/scripts/lib/roadmap_queue.rb +3 -3
  56. package/scripts/lib/roadmap_savepoint.rb +1 -1
  57. package/scripts/lib/rule_catalog.rb +6 -3
  58. package/scripts/lib/savepoint.rb +334 -0
  59. package/scripts/lib/scaffold_intent.rb +13 -179
  60. package/scripts/lib/screen_paint.rb +276 -0
  61. package/scripts/lib/session_backfill.rb +184 -0
  62. package/scripts/lib/session_close.rb +104 -0
  63. package/scripts/lib/session_git.rb +500 -0
  64. package/scripts/lib/session_ledger.rb +621 -0
  65. package/scripts/lib/store_discovery.rb +1 -1
  66. package/scripts/lib/store_provisioning.rb +1 -1
  67. package/scripts/maintenance-run +7 -7
  68. package/scripts/new-intent +63 -4
  69. package/scripts/plastic-lock +101 -41
  70. package/scripts/promote-session-item +150 -0
  71. package/scripts/read-config +3 -0
  72. package/scripts/rebuild-graph +1 -1
  73. package/scripts/report-screen +120 -0
  74. package/scripts/roadmap-next +2 -2
  75. package/scripts/rollback.rb +48 -3
  76. package/scripts/savepoint-note +67 -0
  77. package/scripts/scaffold-intent +37 -63
  78. package/scripts/session-commit +116 -0
  79. package/scripts/spawn-preamble +14 -7
  80. package/scripts/update.rb +7 -3
  81. package/scripts/write-handoff +60 -0
  82. package/skills/agent-advisor/SKILL.md +3 -3
  83. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  84. package/skills/auto/SKILL.md +223 -317
  85. package/skills/auto/evals/evals.json +10 -10
  86. package/skills/auto/references/agent-architecture.md +102 -90
  87. package/skills/auto/references/agent-report-contract.md +4 -21
  88. package/skills/auto/references/end-tail.md +47 -53
  89. package/skills/auto/references/human-report-contract.md +62 -56
  90. package/skills/conventions/SKILL.md +1 -3
  91. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  92. package/skills/conventions/references/locks-and-worktrees.md +62 -58
  93. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  94. package/skills/dashboard/SKILL.md +1 -1
  95. package/skills/direct/SKILL.md +66 -0
  96. package/skills/direct/references/request-signals.md +59 -0
  97. package/skills/doctor/SKILL.md +58 -11
  98. package/skills/doctor/report.md +3 -1
  99. package/skills/install/SKILL.md +11 -9
  100. package/skills/intent-continuing/SKILL.md +142 -91
  101. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  102. package/skills/intent-continuing/references/context-management.md +3 -3
  103. package/skills/intent-creating/SKILL.md +2 -2
  104. package/skills/intent-creating/references/lifecycle.md +1 -1
  105. package/skills/intent-ending/SKILL.md +34 -37
  106. package/skills/intent-ending/evals/evals.json +4 -4
  107. package/skills/intent-executing/SKILL.md +15 -46
  108. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  109. package/skills/intent-speccing/SKILL.md +108 -51
  110. package/skills/intent-speccing/evals/evals.json +5 -5
  111. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  112. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  113. package/skills/project-creating/SKILL.md +1 -1
  114. package/skills/releasing/SKILL.md +7 -8
  115. package/skills/tutorial/SKILL.md +2 -2
  116. package/skills/tutorial/references/track-1-guided.md +16 -18
  117. package/skills/tutorial/references/track-2-auto.md +12 -13
  118. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  119. package/templates/agents.md +4 -7
  120. package/templates/config.yml +5 -0
  121. package/templates/intent-screen.md +17 -0
  122. package/templates/outcome.md +14 -1
  123. package/templates/project.yml +7 -0
  124. package/templates/report-state.md +11 -0
  125. package/templates/roadmap.md +1 -1
  126. package/templates/savepoint.md +2 -2
  127. package/templates/session-intent.md +29 -0
  128. package/agents/plastic-brainstorming.md +0 -48
  129. package/agents/plastic-future-intent-researcher.md +0 -35
  130. package/agents/plastic-intent-curator.md +0 -47
  131. package/agents/plastic-intent-discovery.md +0 -43
  132. package/agents/plastic-planner.md +0 -56
  133. package/agents/plastic-spec-specialist.md +0 -53
  134. package/hooks/auto-arm +0 -5
  135. package/hooks/bash-gate +0 -3
  136. package/hooks/continue +0 -31
  137. package/hooks/edit-gates +0 -3
  138. package/hooks/future-intent-check +0 -25
  139. package/hooks/gate-check +0 -12
  140. package/hooks/power-tools +0 -8
  141. package/scripts/hook-auto-arm +0 -51
  142. package/scripts/hook-bash-gate +0 -78
  143. package/scripts/hook-code-gate +0 -38
  144. package/scripts/hook-continue +0 -48
  145. package/scripts/hook-create-gate +0 -51
  146. package/scripts/hook-edit-gates +0 -58
  147. package/scripts/hook-future-intent-check +0 -90
  148. package/scripts/hook-gate-check +0 -169
  149. package/scripts/hook-links-gate +0 -54
  150. package/scripts/hook-lock-gate +0 -47
  151. package/scripts/hook-power-tools +0 -38
  152. package/scripts/hook-savepoint-pre +0 -32
  153. package/scripts/lib/codex_edit_gates.rb +0 -138
  154. package/scripts/lib/edit_gates.rb +0 -398
  155. package/scripts/lib/links_gate.rb +0 -140
  156. package/scripts/lib/qmd_hook.rb +0 -41
  157. package/scripts/lib/spec_header.rb +0 -83
  158. package/scripts/lib/start_intent.rb +0 -296
  159. package/scripts/start-intent +0 -89
  160. package/skills/_active-intent-gate.md +0 -26
  161. package/skills/auto/references/tiers.md +0 -100
  162. package/skills/continuing/SKILL.md +0 -34
  163. package/skills/continuing/evals/evals.json +0 -91
  164. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  165. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  166. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  167. package/skills/intent-brainstorming/SKILL.md +0 -118
  168. package/skills/intent-brainstorming/evals/evals.json +0 -67
  169. package/skills/intent-continuing/evals/evals.json +0 -145
  170. package/skills/intent-discovering/SKILL.md +0 -56
  171. package/skills/intent-grilling/SKILL.md +0 -108
  172. package/skills/intent-linking/SKILL.md +0 -128
  173. package/skills/intent-linking/evals/evals.json +0 -22
  174. package/skills/intent-linking/references/zettelkasten.md +0 -45
  175. package/skills/intent-locking/SKILL.md +0 -64
  176. package/skills/intent-planning/SKILL.md +0 -145
  177. package/skills/intent-planning/evals/evals.json +0 -81
  178. package/skills/intent-planning/references/plan-format.md +0 -130
  179. package/skills/intent-researching/SKILL.md +0 -123
  180. package/skills/intent-researching/evals/evals.json +0 -22
  181. package/skills/intent-savepoint/SKILL.md +0 -83
  182. package/skills/intent-savepoint/references/context-management.md +0 -32
  183. package/skills/intent-starting/SKILL.md +0 -151
  184. package/skills/intent-starting/evals/evals.json +0 -117
  185. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  186. package/skills/project-continuing/SKILL.md +0 -119
  187. package/skills/project-continuing/evals/evals.json +0 -100
  188. package/skills/roadmap-continuing/SKILL.md +0 -89
  189. package/skills/roadmap-continuing/evals/evals.json +0 -82
  190. package/skills/skill-creating/SKILL.md +0 -75
  191. package/skills/skill-creating/evals/evals.json +0 -108
  192. package/skills/skill-creating/references/agents.md +0 -168
  193. package/skills/skill-creating/references/defaults-first.md +0 -23
  194. package/skills/skill-creating/references/evals.md +0 -41
  195. package/skills/skill-creating/references/hooks.md +0 -251
  196. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  197. package/skills/skill-creating/references/scripts.md +0 -166
  198. package/skills/skill-creating/references/skills.md +0 -169
  199. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  200. package/skills/skill-evaluating/SKILL.md +0 -141
  201. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  202. package/skills/skill-evaluating/evals/evals.json +0 -75
  203. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  204. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  205. package/skills/store-curating/SKILL.md +0 -64
  206. package/skills/store-curating/evals/evals.json +0 -38
  207. package/skills/store-indexing/SKILL.md +0 -93
  208. package/skills/store-indexing/evals/evals.json +0 -22
  209. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  210. package/skills/store-provisioning/SKILL.md +0 -55
  211. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  212. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  213. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
package/scripts/doctor.rb CHANGED
@@ -21,7 +21,7 @@ require_relative "lib/store_discovery"
21
21
  require_relative "lib/links_projection"
22
22
  require_relative "lib/links_section"
23
23
  require_relative "lib/lock"
24
- require_relative "lib/bridge"
24
+ require_relative "lib/savepoint"
25
25
  require_relative "lib/agent_models"
26
26
  require_relative "lib/outcome_guard"
27
27
  require_relative "lib/skill_lint"
@@ -29,6 +29,7 @@ require_relative "lib/config_asks"
29
29
  require_relative "lib/power_tools"
30
30
  require_relative "lib/preflight"
31
31
  require_relative "lib/ruby_probe"
32
+ require_relative "lib/doctor_session_ledger"
32
33
 
33
34
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
34
35
  # run it hermetically (no eval, no global-constant rewriting).
@@ -37,6 +38,7 @@ require_relative "lib/ruby_probe"
37
38
  # store, conventions, intent and CLI halves, so Doctor stays one class with one
38
39
  # public surface.
39
40
  class Doctor
41
+ include DoctorSessionLedger
40
42
 
41
43
  REQUIRED_INDEX_SECTIONS = ["## Active", "## Future", "## Clusters", "## Abandoned", "## Completed"].freeze
42
44
 
@@ -286,6 +288,167 @@ class Doctor
286
288
  # content scanning. check_global_store (above) is store/full-scope and includes
287
289
  # orphaned_intents / ghost_references, both explicitly forbidden at core by D1.
288
290
 
291
+ # Row E (spec D8, spec 315b): #check_core_files's own `version_match` only ever
292
+ # compares the SELECTED harness's `<dir>/plastic/VERSION` against the global
293
+ # VERSION, so a second installed harness left behind on an old version is never
294
+ # version-checked at all -- exactly how ten stale 1.14.1 Codex registrations
295
+ # survived a `pass`. This runs the same comparison for EVERY installed harness
296
+ # (agent_dir on disk), named `version_match_<key>` so the existing `version_match`
297
+ # name and position stay reserved for the selected harness (pinned tests:
298
+ # test/doctor_test.rb:1846,1861,1873). Full tier only (`run_checks`); never
299
+ # `run_core_checks`, which is `binary: true` and runs on the session-start boot
300
+ # path, where a second-harness warn would become a boot-time error for every user.
301
+ def check_harness_versions
302
+ global_version = read_version
303
+ return [] unless global_version
304
+
305
+ agents.filter_map do |key, config|
306
+ next unless config.is_a?(Hash) && File.directory?(config[:dir].to_s)
307
+
308
+ agent_version_path = File.join(config[:dir], "plastic", "VERSION")
309
+ name = "version_match_#{key}"
310
+
311
+ if !File.exist?(agent_version_path)
312
+ check(
313
+ category: "core_files", name: name, status: "warn",
314
+ message: "#{config[:name]}'s agent-side VERSION file not found at #{tilde(agent_version_path)}",
315
+ fixable: true,
316
+ fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
317
+ )
318
+ else
319
+ agent_version = File.read(agent_version_path).strip
320
+ if global_version == agent_version
321
+ check(
322
+ category: "core_files", name: name, status: "pass",
323
+ message: "Global VERSION (#{global_version}) matches #{config[:name]}'s agent-side VERSION"
324
+ )
325
+ else
326
+ check(
327
+ category: "core_files", name: name, status: "warn",
328
+ message: "Version mismatch for #{config[:name]}: global=#{global_version}, agent=#{agent_version}",
329
+ details: [
330
+ "#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
331
+ "#{tilde(agent_version_path)}: #{agent_version}",
332
+ ],
333
+ fixable: true,
334
+ fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
335
+ )
336
+ end
337
+ end
338
+ end
339
+ end
340
+
341
+ # The Codex hook NAME expected per event, keyed off HookRegistry's own
342
+ # constants rather than a full built command string: naming it, not the
343
+ # literal dispatcher path, is what #check_codex_stale_registrations needs,
344
+ # so this never depends on plastic_home resolving to the same install the
345
+ # live hooks.json's commands were written under.
346
+ def codex_expected_names_by_event
347
+ names = {}
348
+ post_order = HookRegistry.events["PostToolUse"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
349
+ names["PostToolUse"] = HookRegistry::CODEX_POST_HOOKS & post_order
350
+
351
+ HookRegistry::CODEX_LIVE_STATE_EVENTS.each do |event|
352
+ names[event] = HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
353
+ end
354
+
355
+ end_order = HookRegistry.events["SessionEnd"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
356
+ names["SessionEnd"] = HookRegistry::CODEX_SESSION_END_HOOKS & end_order
357
+ names
358
+ end
359
+
360
+ # Row E (spec D8, spec 315b): #codex_hooks_registered_check computes
361
+ # `want - got` and iterates `expected.each`, so it can see a MISSING registration
362
+ # but never an EXTRA one, and it can never even visit an event `expected` does not
363
+ # list at all (a retired event such as PreToolUse, which a stale ~/.codex/hooks.json
364
+ # can still carry). This scans the UNION of expected and live event keys instead, so
365
+ # a stale registration parked in a retired event is visible. Compares hook NAMES
366
+ # (see #codex_expected_names_by_event), not full command strings: a full-command
367
+ # comparison would embed this Doctor instance's own `plastic_home` in the expected
368
+ # dispatcher path, which is only guaranteed to match the live hooks.json's own
369
+ # dispatcher path in a real, single install, never a test that fakes one without the
370
+ # other. Full tier only (`run_checks`); never `run_core_checks` (D8).
371
+ #
372
+ # Post-execution review item 5: name-only comparison alone is blind to two real
373
+ # cases, both closed here without reintroducing any plastic_home coupling: (a) the
374
+ # SAME (event, name) pair registered more than once (each copy's name is
375
+ # individually expected, so a "not in the expected set" scan never sees the
376
+ # duplicate), and (b) a correctly-named entry whose dispatcher path is an OLD
377
+ # install, different from the path every OTHER Plastic entry in this same file
378
+ # actually uses -- the "correct" path here is whichever path the file's own
379
+ # majority of entries already agrees on, never a path this Doctor instance derives
380
+ # from its own plastic_home.
381
+ def check_codex_stale_registrations
382
+ config = agents["codex"]
383
+ return [] unless config.is_a?(Hash) && File.directory?(config[:dir].to_s)
384
+
385
+ home_dir = config[:home_dir] || config[:dir]
386
+ hooks_json = File.join(home_dir, "hooks.json")
387
+ data = read_json_safe(hooks_json)
388
+ return [] if data.nil? || !data.is_a?(Hash)
389
+
390
+ expected_names = codex_expected_names_by_event
391
+ live = data["hooks"].is_a?(Hash) ? data["hooks"] : {}
392
+
393
+ # One pass over every live Plastic entry (event, hook name, and the literal
394
+ # dispatcher-path token it actually invokes), shared by all three checks below.
395
+ entries = []
396
+ (expected_names.keys | live.keys).each do |event|
397
+ Array(live[event]).each do |group|
398
+ Array(group["hooks"]).each do |h|
399
+ cmd = h["command"]
400
+ next unless HookRegistry.codex_purge_command?(cmd)
401
+
402
+ dispatcher_path = cmd.to_s.split(/\s+/).reject(&:empty?).first.to_s.delete("\"'")
403
+ name = HookRegistry.command_basenames(cmd).last
404
+ entries << { event: event, name: name, dispatcher_path: dispatcher_path, cmd: cmd }
405
+ end
406
+ end
407
+ end
408
+
409
+ stale = []
410
+
411
+ # Not in the expected name set for this event at all (the original check).
412
+ entries.each do |e|
413
+ want_names = Array(expected_names[e[:event]])
414
+ stale << "#{e[:event]}: #{e[:cmd]}" unless want_names.include?(e[:name])
415
+ end
416
+
417
+ # (a) the same (event, name) pair registered more than once.
418
+ entries.group_by { |e| [e[:event], e[:name]] }.each_value do |group|
419
+ next if group.size <= 1
420
+
421
+ group.each { |e| stale << "#{e[:event]}: #{e[:cmd]} (duplicate registration of #{e[:name]})" }
422
+ end
423
+
424
+ # (b) a dispatcher path that differs from the path every other Plastic entry in
425
+ # this same file actually uses.
426
+ unless entries.empty?
427
+ majority_path = entries.map { |e| e[:dispatcher_path] }.tally.max_by { |(_path, count)| count }&.first
428
+ entries.each do |e|
429
+ next if e[:dispatcher_path] == majority_path
430
+
431
+ stale << "#{e[:event]}: #{e[:cmd]} (dispatcher path #{e[:dispatcher_path]} differs from " \
432
+ "this file's other Plastic entries at #{majority_path})"
433
+ end
434
+ end
435
+
436
+ stale.uniq!
437
+
438
+ if stale.empty?
439
+ [check(
440
+ category: "agent_registration", name: "codex_stale_registrations", status: "pass",
441
+ message: "No stale Plastic Codex registrations found outside the expected hook set"
442
+ )]
443
+ else
444
+ [check(
445
+ category: "agent_registration", name: "codex_stale_registrations", status: "warn",
446
+ message: "#{stale.size} stale Codex registration(s) found outside the expected hook set",
447
+ details: stale, fixable: true,
448
+ fix_hint: "Re-run the Plastic installer with --codex --reinstall to purge stale entries"
449
+ )]
450
+ end
451
+ end
289
452
  # --- Check category 2: Conventions ---
290
453
 
291
454
  # When `scopes` is a non-nil Array of scope strings (e.g. ["global"] or
@@ -431,7 +594,7 @@ class Doctor
431
594
  message: "#{bad_sections.size} intent file(s) have non-sanctioned ## sections",
432
595
  details: bad_sections.map { |b| "#{b[:dir]}: #{b[:issues].join(", ")}" },
433
596
  fixable: true,
434
- fix_hint: "Dispatch plastic-store-curating to relocate each unsanctioned section into the " \
597
+ fix_hint: "Relocate each unsanctioned section into the " \
435
598
  "intent's revisions.md via move-and-record (a missing required section is restored " \
436
599
  "or reprojected instead); see plastic-conventions > references/maintenance-and-revisions.md"
437
600
  )
@@ -553,9 +716,9 @@ class Doctor
553
716
  # suppress) is exactly what makes the row dead.
554
717
  def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, active:, excluded_rules: [])
555
718
  outcome = File.join(dir, "outcome.md")
556
- outcome_real = Bridge.stage_file_present?(outcome)
719
+ outcome_real = Savepoint.stage_file_present?(outcome)
557
720
  findings = { conflict: nil, phantom: nil, gap: [], operational_gap: [], excluded: [],
558
- excluded_rules_fired: [], stalled: nil }
721
+ excluded_rules_fired: [], stalled: nil, unbackfilled: [], excluded_backfill: [] }
559
722
 
560
723
  # HARD conflict: the deliverable exists but INDEX still says Active. This
561
724
  # is the one true INDEX-wins disagreement, so it stays a fail.
@@ -569,7 +732,7 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
569
732
  # the generic replacement for the frozen 170a amnesty list is simply "never suppress" -
570
733
  # the message already labels a terminal phantom report-only, so dropping the id-based
571
734
  # suppression is strictly more general and needs no per-store state at all).
572
- phantom_lines = Bridge.savepoint_phantom_lines(dir)
735
+ phantom_lines = Savepoint.savepoint_phantom_lines(dir)
573
736
  if phantom_lines.any?
574
737
  detail = phantom_lines.map { |line, reason| "#{line} (#{reason})" }.join("; ")
575
738
  scope_note = terminal ? "terminal in INDEX, report-only (immutable history)" : "live intent, auto-rebuildable"
@@ -583,7 +746,7 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
583
746
  if terminal && !outcome_real
584
747
  state = File.exist?(outcome) ? "still a placeholder" : "missing"
585
748
  findings[:gap] << "#{label}: terminal in INDEX but outcome.md is #{state} " \
586
- "(delivery claim - never fabricated)"
749
+ "(delivery claim; written at close by end-intent since intent 308)"
587
750
  end
588
751
 
589
752
  if terminal
@@ -603,6 +766,21 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
603
766
  end
604
767
  findings[:excluded_rules_fired] << "savepoint_operational" if suppressed && findings[:excluded].any?
605
768
 
769
+ # Unbackfilled documents (intent 308): spec.md or plan.md missing or still the
770
+ # placeholder, or no real action file, on a terminal intent. Every one of these is
771
+ # written from the record by `scaffold-intent backfill`, so this is repairable and
772
+ # reported as a fixable warn (backfilled_complete). Its exclusions go to their OWN
773
+ # 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)
776
+ if backfill_gaps.any?
777
+ suppressed_backfill = excluded_rules.include?("backfilled_complete")
778
+ target = suppressed_backfill ? findings[:excluded_backfill] : findings[:unbackfilled]
779
+ target << "#{label}: terminal in INDEX but #{backfill_gaps.join(", ")} " \
780
+ "#{backfill_gaps.size == 1 ? "is" : "are"} missing or still a placeholder (backfillable)"
781
+ findings[:excluded_rules_fired] << "backfilled_complete" if suppressed_backfill
782
+ end
783
+
606
784
  # Stalled completion: unchanged, never consulted amnesty.
607
785
  if File.exist?(Lock.path(dir))
608
786
  note = Lock.fresh?(dir) ? "delivery.lock still present (post-done window not closed)"
@@ -626,6 +804,9 @@ def check_done_signals(scopes: nil)
626
804
  dead_row_paths = []
627
805
  stalled = []
628
806
  phantoms = []
807
+ unbackfilled = [] # spec/plan/actions gaps on terminal intents (intent 308) - repairable
808
+ excluded_backfill = [] # backfill gaps knowingly exempted via doctor-exclusions (intent 308)
809
+ dead_rows_by_rule = Hash.new { |h, k| h[k] = [] }
629
810
 
630
811
  done_signal_stores(scopes).each do |store|
631
812
  exclusions = DoctorExclusions.load(store[:index])
@@ -634,7 +815,7 @@ def check_done_signals(scopes: nil)
634
815
  exclusion_error_paths << exclusions[:path]
635
816
  end
636
817
 
637
- consumed = { "savepoint_operational" => [] }
818
+ consumed = { "savepoint_operational" => [], "backfilled_complete" => [] }
638
819
  # `known_ids` (post-review fix): every intent id with a REAL DIRECTORY in this store, scanned
639
820
  # directly from disk - independent of INDEX.md. An id can have a directory on disk without
640
821
  # being listed in INDEX (a de-indexed "ghost"), and the walk below alone would never visit it;
@@ -672,6 +853,11 @@ def check_done_signals(scopes: nil)
672
853
  phantoms << findings[:phantom] if findings[:phantom]
673
854
  gaps.concat(findings[:gap])
674
855
  operational_gaps.concat(findings[:operational_gap])
856
+ unbackfilled.concat(findings[:unbackfilled])
857
+ if findings[:excluded_backfill].any?
858
+ excluded_backfill.concat(findings[:excluded_backfill])
859
+ exclusion_paths << exclusions[:path]
860
+ end
675
861
  if findings[:excluded].any?
676
862
  excluded.concat(findings[:excluded])
677
863
  exclusion_paths << exclusions[:path]
@@ -692,7 +878,9 @@ def check_done_signals(scopes: nil)
692
878
  else
693
879
  "names an intent with no current #{row[:rule]} finding"
694
880
  end
695
- dead_rows << "#{store[:scope]}: #{exclusions[:path]}: #{row[:rule]} #{row[:id]} - #{reason}"
881
+ dead_row = "#{store[:scope]}: #{exclusions[:path]}: #{row[:rule]} #{row[:id]} - #{reason}"
882
+ dead_rows << dead_row
883
+ dead_rows_by_rule[row[:rule]] << dead_row
696
884
  dead_row_paths << exclusions[:path]
697
885
  end
698
886
  end
@@ -733,7 +921,7 @@ def check_done_signals(scopes: nil)
733
921
  checks << check(
734
922
  category: "done_signals", name: "signals_complete", status: "pass",
735
923
  message: "#{gaps.size} terminal intent#{gaps.size == 1 ? "" : "s"} missing a real outcome.md " \
736
- "(delivery claim - never fabricated; informational only, does not affect doctor's exit code)",
924
+ "(delivery claim; end-intent writes outcome.md at close since intent 308, so this is pre-308 history or a close outside end-intent; informational only, does not affect doctor's exit code)",
737
925
  details: gaps
738
926
  )
739
927
  end
@@ -750,10 +938,19 @@ def check_done_signals(scopes: nil)
750
938
  # suppressed nothing this run. It is purely informational, exactly like `exclusion_suffix` - it
751
939
  # never changes status on any of the three branches below, because a stale governance-record row
752
940
  # is bookkeeping drift, not a store regression (219 D6 is untouched: no disposition is invented).
753
- exclusion_suffix = excluded.empty? ? "" : " (#{excluded.size} excluded via #{exclusion_paths.join(", ")})"
754
- dead_suffix = dead_rows.empty? ? "" : " (#{dead_rows.size} dead row#{dead_rows.size == 1 ? "" : "s"} " \
755
- "in #{dead_row_paths.join(", ")}, suppressing nothing - prune with " \
756
- "`maintenance-run --tool register-exclusions --prune`)"
941
+ # Per rule (intent 308): savepoint_operational and backfilled_complete each fold in only
942
+ # their own exclusions and dead rows, so one check never carries the other's counts.
943
+ suffixes = lambda do |excluded_rows, dead|
944
+ ex = excluded_rows.empty? ? "" : " (#{excluded_rows.size} excluded via #{exclusion_paths.join(", ")})"
945
+ dd = dead.empty? ? "" : " (#{dead.size} dead row#{dead.size == 1 ? "" : "s"} " \
946
+ "in #{dead_row_paths.join(", ")}, suppressing nothing - prune with " \
947
+ "`maintenance-run --tool register-exclusions --prune`)"
948
+ [ex, dd]
949
+ end
950
+ savepoint_dead = dead_rows_by_rule["savepoint_operational"]
951
+ backfill_dead = dead_rows_by_rule["backfilled_complete"]
952
+ exclusion_suffix, dead_suffix = suffixes.call(excluded, savepoint_dead)
953
+ backfill_exclusion_suffix, backfill_dead_suffix = suffixes.call(excluded_backfill, backfill_dead)
757
954
 
758
955
  if exclusion_errors.any?
759
956
  checks << check(
@@ -762,7 +959,7 @@ def check_done_signals(scopes: nil)
762
959
  "missing an operational savepoint.md or its Done echo, and " \
763
960
  "#{exclusion_errors.size} doctor-exclusions error#{exclusion_errors.size == 1 ? "" : "s"} " \
764
961
  "(a malformed exclusion file never suppresses a finding)#{exclusion_suffix}#{dead_suffix}",
765
- details: operational_gaps + exclusion_errors + dead_rows, fixable: true,
962
+ details: operational_gaps + exclusion_errors + savepoint_dead, fixable: true,
766
963
  fix_hint: "Fix the malformed doctor-exclusions file(s) (#{exclusion_error_paths.join(", ")}) - " \
767
964
  "format `rule_name id id id`, blank lines and # comments ignored - then reconstruct " \
768
965
  "any remaining real gap via `maintenance-run --tool rebuild-savepoint --intent <id> " \
@@ -774,7 +971,7 @@ def check_done_signals(scopes: nil)
774
971
  category: "done_signals", name: "savepoint_operational", status: "pass",
775
972
  message: "No terminal intent is missing an operational savepoint.md or its Done echo" \
776
973
  "#{exclusion_suffix}#{dead_suffix}",
777
- details: dead_rows
974
+ details: savepoint_dead
778
975
  )
779
976
  else
780
977
  checks << check(
@@ -782,13 +979,50 @@ def check_done_signals(scopes: nil)
782
979
  message: "#{operational_gaps.size} terminal intent#{operational_gaps.size == 1 ? "" : "s"} " \
783
980
  "missing an operational savepoint.md or its Done echo (reconstructible)" \
784
981
  "#{exclusion_suffix}#{dead_suffix}",
785
- details: operational_gaps + dead_rows, fixable: true,
982
+ details: operational_gaps + savepoint_dead, fixable: true,
786
983
  fix_hint: "Reconstruct the minimal two-line started/Done echo via " \
787
984
  "`maintenance-run --tool rebuild-savepoint --intent <id> --apply` (197-conformant: " \
788
985
  "receipt-before-write via RevisionsWriter, one intent per invocation, owner-approval-gated)."
789
986
  )
790
987
  end
791
988
 
989
+ # backfilled_complete (intent 308): spec.md, plan.md, or a real action file missing on a
990
+ # terminal intent. Same three branches as savepoint_operational: a malformed exclusion
991
+ # 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.
993
+ backfill_hint = "Write the missing documents from the record via " \
994
+ "`scaffold-intent backfill --store <store> --id <id> --disposition " \
995
+ "<delivered|abandoned>` (never touches real content, one intent per invocation)."
996
+ if exclusion_errors.any?
997
+ checks << check(
998
+ category: "done_signals", name: "backfilled_complete", status: "warn",
999
+ message: "#{unbackfilled.size} terminal intent#{unbackfilled.size == 1 ? "" : "s"} " \
1000
+ "missing a backfillable document, and #{exclusion_errors.size} doctor-exclusions " \
1001
+ "error#{exclusion_errors.size == 1 ? "" : "s"} (a malformed exclusion file never " \
1002
+ "suppresses a finding)#{backfill_exclusion_suffix}#{backfill_dead_suffix}",
1003
+ details: unbackfilled + exclusion_errors + backfill_dead, fixable: true,
1004
+ fix_hint: "Fix the malformed doctor-exclusions file(s) (#{exclusion_error_paths.join(", ")}), " \
1005
+ "then write the missing documents from the record via `scaffold-intent backfill " \
1006
+ "--store <store> --id <id> --disposition <delivered|abandoned>`."
1007
+ )
1008
+ elsif unbackfilled.empty?
1009
+ checks << check(
1010
+ category: "done_signals", name: "backfilled_complete", status: "pass",
1011
+ message: "Every terminal intent carries a real spec.md, plan.md, and action file" \
1012
+ "#{backfill_exclusion_suffix}#{backfill_dead_suffix}",
1013
+ details: backfill_dead
1014
+ )
1015
+ else
1016
+ checks << check(
1017
+ category: "done_signals", name: "backfilled_complete", status: "warn",
1018
+ message: "#{unbackfilled.size} terminal intent#{unbackfilled.size == 1 ? "" : "s"} " \
1019
+ "missing a backfillable document (spec.md, plan.md, or a real action file)" \
1020
+ "#{backfill_exclusion_suffix}#{backfill_dead_suffix}",
1021
+ details: unbackfilled + backfill_dead, fixable: true,
1022
+ fix_hint: backfill_hint
1023
+ )
1024
+ end
1025
+
792
1026
  if stalled.empty?
793
1027
  checks << check(
794
1028
  category: "done_signals", name: "stalled_completion", status: "pass",
@@ -818,8 +1052,8 @@ def check_done_signals(scopes: nil)
818
1052
  message: "#{phantoms.size} intent#{phantoms.size == 1 ? "" : "s"} carry savepoint.md " \
819
1053
  "line(s) contradicted by disk (advisory; terminal history stays report-only)",
820
1054
  details: phantoms, fixable: true,
821
- fix_hint: "For a live (Active) intent, run plastic-intent-savepoint to rebuild via " \
822
- "Bridge.rebuild_savepoint. Terminal (Completed/Abandoned) intents are immutable: " \
1055
+ fix_hint: "For a live (Active) intent, rebuild the ledger via " \
1056
+ "Savepoint.rebuild_savepoint. Terminal (Completed/Abandoned) intents are immutable: " \
823
1057
  "a phantom there stays advisory unless an explicit human grant authorizes the " \
824
1058
  "124a manual Done-bookend repair."
825
1059
  )
@@ -991,9 +1225,9 @@ end
991
1225
  INTENT_END_LIFECYCLE_FILES = %w[spec.md plan.md checklist.md outcome.md].freeze
992
1226
 
993
1227
  def intent_lifecycle_artifacts_check(intent_dir, disposition)
994
- missing = INTENT_END_LIFECYCLE_FILES.select { |f| !Bridge.stage_file_present?(File.join(intent_dir, f)) }
995
- unless Bridge.stage_file_present?(Bridge.intent_file(intent_dir))
996
- missing = [File.basename(Bridge.intent_file(intent_dir))] + missing
1228
+ missing = INTENT_END_LIFECYCLE_FILES.select { |f| !Savepoint.stage_file_present?(File.join(intent_dir, f)) }
1229
+ unless Savepoint.stage_file_present?(Savepoint.intent_file(intent_dir))
1230
+ missing = [File.basename(Savepoint.intent_file(intent_dir))] + missing
997
1231
  end
998
1232
 
999
1233
  outcome_note = nil
@@ -1014,7 +1248,7 @@ end
1014
1248
 
1015
1249
  def intent_checklist_complete_check(intent_dir)
1016
1250
  path = File.join(intent_dir, "checklist.md")
1017
- unless Bridge.stage_file_present?(path)
1251
+ unless Savepoint.stage_file_present?(path)
1018
1252
  return check(category: "intent_end", name: "intent_checklist_complete", status: "pass",
1019
1253
  message: "n/a: checklist.md absent or placeholder (flagged above if that is a gap)")
1020
1254
  end
@@ -1074,7 +1308,8 @@ end
1074
1308
  # Terminal-gated (281 D3): done_signal_findings_for_dir only ever produces this finding
1075
1309
  # inside `if terminal`, so honoring the exclusion for a still-Active intent would suppress a
1076
1310
  # strictly larger set of facts than the rule id names - and would let a mistyped id silence
1077
- # the live, repairable warning scripts/end-intent's pre-write gate exists to raise.
1311
+ # the live, repairable warning scripts/end-intent's structure self-check exists to raise
1312
+ # (a report, never a refusal, since intent 308).
1078
1313
  #
1079
1314
  # Never raises: DoctorExclusions is fail-open by contract (274 D5) and index_sections_by_dir
1080
1315
  # returns an empty map for a missing INDEX.
@@ -1131,9 +1366,9 @@ end
1131
1366
  first_line = File.read(savepoint).each_line.find { |l| !l.strip.empty? }.to_s.strip
1132
1367
  parts = first_line.split(/\s{2,}/)
1133
1368
  born_pair = parts.length >= 3 ? [parts[1], parts[2]] : nil
1134
- expected_pair = Bridge.savepoint_milestone(intent_dir, File.basename(Bridge.intent_file(intent_dir)))
1369
+ expected_pair = Savepoint.savepoint_milestone(intent_dir, File.basename(Savepoint.intent_file(intent_dir)))
1135
1370
 
1136
- phantoms = Bridge.savepoint_phantom_lines(intent_dir)
1371
+ phantoms = Savepoint.savepoint_phantom_lines(intent_dir)
1137
1372
  problems = []
1138
1373
  problems << "born line #{born_pair.inspect} does not match the expected #{expected_pair.inspect}" \
1139
1374
  if born_pair != expected_pair
@@ -1147,7 +1382,7 @@ end
1147
1382
  check(category: "intent_end", name: "intent_savepoint_truthful", status: "warn",
1148
1383
  message: "#{problems.size} savepoint truthfulness issue(s) (advisory, never blocking)",
1149
1384
  details: problems,
1150
- fixable: true, fix_hint: "Run plastic-intent-savepoint to rebuild via Bridge.rebuild_savepoint")
1385
+ fixable: true, fix_hint: "Rebuild the ledger via Savepoint.rebuild_savepoint")
1151
1386
  end
1152
1387
  end
1153
1388
 
@@ -1270,7 +1505,7 @@ end
1270
1505
  checks << graph_finding_check(
1271
1506
  "graph_i4_danglers", i4,
1272
1507
  "Every sources/chain id resolves to a real intent (I4)",
1273
- "Dispatch plastic-store-curating to record the dangling sources/chain edge as a " \
1508
+ "Record the dangling sources/chain edge as a " \
1274
1509
  "broken-source/broken-chain move-and-record entry in the intent's revisions.md (see " \
1275
1510
  "plastic-conventions > references/maintenance-and-revisions.md), or restore the missing intent"
1276
1511
  )
@@ -1297,18 +1532,6 @@ end
1297
1532
 
1298
1533
 
1299
1534
 
1300
- # claude_hooks_implemented (intent 244, the Claude twin of intent 200's Codex
1301
- # check): the edit-path gates now live as branches inside one dispatcher,
1302
- # which is exactly the shape that let links-gate ship registered and dead on
1303
- # Codex. HookRegistry::GATE_TOOLS is the registry side; scripts/hook-edit-gates
1304
- # is the implementation side. Checked in BOTH directions, so a registered gate
1305
- # with no branch (always allows, silently) and a branch nobody registers
1306
- # (dead code) are both reported.
1307
- #
1308
- # Read as plain text, never required: the dispatcher has top-level side effects
1309
- # (it reads $stdin and exits), the same reason codex_dispatcher_gate_names reads
1310
- # scripts/codex-hook as text.
1311
-
1312
1535
  # Plastic skills install as ~/.claude/skills/plastic-<name>/SKILL.md. Pass if at
1313
1536
  # least one such skill is present.
1314
1537
 
@@ -1357,7 +1580,7 @@ end
1357
1580
  # dispatcher is an executable script with real top-level side effects (it reads
1358
1581
  # $stdin and may exit), so it can never be required or executed to introspect it,
1359
1582
  # only read as plain text, mirroring codex_agent_toml_well_formed? above. Pulls
1360
- # gate names from the STATE_HOOKS and SHELL_HOOKS %w[] literals, plus the `when
1583
+ # hook names from the STATE_HOOKS %w[] literal, plus the `when
1361
1584
  # "<name>"` labels of the top-level `case gate` statement (stopping at its
1362
1585
  # trailing `else`). Line-shape dependent, not AST-safe, disclosed as such in
1363
1586
  # docs; the healthy-install pass test against the REAL dispatcher is what proves
@@ -1407,7 +1630,7 @@ end
1407
1630
  # Classification per installed agent basename, checked in this order:
1408
1631
  # 1. an `agents.models.<basename>` override IS configured -> sanctioned,
1409
1632
  # pass/informational, LISTED regardless of whether frontmatter matches
1410
- # (that mismatch is the override working, e.g. plastic-brainstorming: fable).
1633
+ # (that mismatch is the override working, e.g. plastic-executor: fable).
1411
1634
  # 2. no override AND basename is a TIER_DEFAULTS key -> compare frontmatter
1412
1635
  # to that default; match is pass (clean), mismatch is real drift (warn).
1413
1636
  # 3. no override AND basename is a CONSULTATION_AGENTS member -> not a
@@ -2173,11 +2396,14 @@ end
2173
2396
  all_checks += check_conventions(scopes: ["global"])
2174
2397
  all_checks += check_agent_registration(agent_key)
2175
2398
  all_checks += check_core_files(agent_key)
2399
+ all_checks += check_harness_versions
2400
+ all_checks += check_codex_stale_registrations
2176
2401
  all_checks += check_deprecations
2177
2402
  all_checks += check_config_asks(agent_key)
2178
2403
  all_checks += check_qmd
2179
2404
  all_checks += check_ruby_runtime
2180
2405
  all_checks += check_done_signals(scopes: ["global"])
2406
+ all_checks += check_session_ledger(scopes: ["global"])
2181
2407
  all_checks += check_skill_lint
2182
2408
  all_checks += check_install_integrity
2183
2409
 
@@ -2228,10 +2454,10 @@ end
2228
2454
  case store
2229
2455
  when :all
2230
2456
  check_global_store + check_project_stores + check_conventions + check_done_signals +
2231
- check_qmd(detector: qmd_detector, runner: qmd_runner)
2457
+ check_session_ledger + check_qmd(detector: qmd_detector, runner: qmd_runner)
2232
2458
  when :global
2233
2459
  check_global_store + check_conventions(scopes: ["global"]) +
2234
- check_done_signals(scopes: ["global"]) +
2460
+ check_done_signals(scopes: ["global"]) + check_session_ledger(scopes: ["global"]) +
2235
2461
  check_qmd(detector: qmd_detector, runner: qmd_runner, collection: "plastic-global")
2236
2462
  else
2237
2463
  all_checks_for_project_slug(store, qmd_detector: qmd_detector, qmd_runner: qmd_runner,