@zalom/plastic 1.14.0 → 2.0.0-alpha.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 (190) 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/config_asks.yml +38 -0
  8. package/hooks/capture +4 -0
  9. package/hooks/check-update +12 -3
  10. package/hooks/close +5 -0
  11. package/hooks/hooks.json +9 -49
  12. package/hooks/record +4 -0
  13. package/package.json +3 -2
  14. package/scripts/agent-report +8 -9
  15. package/scripts/append-ledger +195 -0
  16. package/scripts/codex-hook +101 -95
  17. package/scripts/dashboard.rb +4 -4
  18. package/scripts/doctor.rb +106 -43
  19. package/scripts/end-intent +77 -76
  20. package/scripts/exec-worktree +11 -20
  21. package/scripts/file-session-intent +61 -0
  22. package/scripts/hook-capture +242 -0
  23. package/scripts/hook-close +34 -0
  24. package/scripts/hook-record +149 -0
  25. package/scripts/hook-session-start +95 -12
  26. package/scripts/install.rb +8 -6
  27. package/scripts/lib/agent_models.rb +1 -7
  28. package/scripts/lib/arm.rb +283 -0
  29. package/scripts/lib/backfill_intent.rb +316 -0
  30. package/scripts/lib/bridge.rb +24 -1597
  31. package/scripts/lib/doctor_core.rb +17 -88
  32. package/scripts/lib/doctor_session_ledger.rb +106 -0
  33. package/scripts/lib/exec_worktree.rb +14 -60
  34. package/scripts/lib/harness_text.rb +5 -4
  35. package/scripts/lib/hook_registry.rb +44 -107
  36. package/scripts/lib/insights.rb +3 -4
  37. package/scripts/lib/installer_core.rb +19 -30
  38. package/scripts/lib/lock.rb +6 -26
  39. package/scripts/lib/outcome_guard.rb +7 -5
  40. package/scripts/lib/project_validator.rb +30 -1
  41. package/scripts/lib/roadmap_queue.rb +3 -3
  42. package/scripts/lib/roadmap_savepoint.rb +1 -1
  43. package/scripts/lib/rule_catalog.rb +6 -3
  44. package/scripts/lib/savepoint.rb +320 -0
  45. package/scripts/lib/scaffold_intent.rb +13 -179
  46. package/scripts/lib/session_backfill.rb +184 -0
  47. package/scripts/lib/session_close.rb +84 -0
  48. package/scripts/lib/session_git.rb +469 -0
  49. package/scripts/lib/session_ledger.rb +497 -0
  50. package/scripts/lib/store_discovery.rb +1 -1
  51. package/scripts/lib/store_provisioning.rb +1 -1
  52. package/scripts/maintenance-run +7 -7
  53. package/scripts/new-intent +63 -4
  54. package/scripts/plastic-lock +94 -41
  55. package/scripts/promote-session-item +150 -0
  56. package/scripts/rebuild-graph +1 -1
  57. package/scripts/roadmap-next +2 -2
  58. package/scripts/rollback.rb +42 -3
  59. package/scripts/scaffold-intent +37 -63
  60. package/scripts/session-commit +116 -0
  61. package/scripts/spawn-preamble +5 -5
  62. package/scripts/update.rb +7 -3
  63. package/skills/agent-advisor/SKILL.md +3 -3
  64. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  65. package/skills/auto/SKILL.md +218 -317
  66. package/skills/auto/evals/evals.json +10 -10
  67. package/skills/auto/references/agent-architecture.md +102 -90
  68. package/skills/auto/references/agent-report-contract.md +4 -21
  69. package/skills/auto/references/end-tail.md +47 -53
  70. package/skills/auto/references/human-report-contract.md +8 -8
  71. package/skills/conventions/SKILL.md +1 -3
  72. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  73. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  74. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  75. package/skills/dashboard/SKILL.md +1 -1
  76. package/skills/direct/SKILL.md +66 -0
  77. package/skills/direct/references/request-signals.md +59 -0
  78. package/skills/doctor/SKILL.md +58 -11
  79. package/skills/doctor/report.md +3 -1
  80. package/skills/install/SKILL.md +11 -9
  81. package/skills/intent-continuing/SKILL.md +131 -89
  82. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  83. package/skills/intent-continuing/references/context-management.md +2 -2
  84. package/skills/intent-creating/SKILL.md +2 -2
  85. package/skills/intent-creating/references/lifecycle.md +1 -1
  86. package/skills/intent-ending/SKILL.md +28 -37
  87. package/skills/intent-ending/evals/evals.json +4 -4
  88. package/skills/intent-executing/SKILL.md +12 -49
  89. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  90. package/skills/intent-speccing/SKILL.md +108 -51
  91. package/skills/intent-speccing/evals/evals.json +5 -5
  92. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  93. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  94. package/skills/project-creating/SKILL.md +1 -1
  95. package/skills/releasing/SKILL.md +7 -8
  96. package/skills/tutorial/SKILL.md +2 -2
  97. package/skills/tutorial/references/track-1-guided.md +16 -18
  98. package/skills/tutorial/references/track-2-auto.md +12 -13
  99. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  100. package/templates/agents.md +4 -7
  101. package/templates/project.yml +7 -0
  102. package/templates/roadmap.md +1 -1
  103. package/templates/savepoint.md +2 -2
  104. package/templates/session-intent.md +29 -0
  105. package/agents/plastic-brainstorming.md +0 -48
  106. package/agents/plastic-future-intent-researcher.md +0 -35
  107. package/agents/plastic-intent-curator.md +0 -47
  108. package/agents/plastic-intent-discovery.md +0 -43
  109. package/agents/plastic-planner.md +0 -56
  110. package/agents/plastic-spec-specialist.md +0 -53
  111. package/hooks/auto-arm +0 -5
  112. package/hooks/bash-gate +0 -3
  113. package/hooks/continue +0 -31
  114. package/hooks/edit-gates +0 -3
  115. package/hooks/future-intent-check +0 -25
  116. package/hooks/gate-check +0 -12
  117. package/hooks/power-tools +0 -8
  118. package/scripts/hook-auto-arm +0 -51
  119. package/scripts/hook-bash-gate +0 -78
  120. package/scripts/hook-code-gate +0 -38
  121. package/scripts/hook-continue +0 -48
  122. package/scripts/hook-create-gate +0 -51
  123. package/scripts/hook-edit-gates +0 -58
  124. package/scripts/hook-future-intent-check +0 -90
  125. package/scripts/hook-gate-check +0 -169
  126. package/scripts/hook-links-gate +0 -54
  127. package/scripts/hook-lock-gate +0 -47
  128. package/scripts/hook-power-tools +0 -38
  129. package/scripts/hook-savepoint-pre +0 -32
  130. package/scripts/lib/codex_edit_gates.rb +0 -138
  131. package/scripts/lib/edit_gates.rb +0 -398
  132. package/scripts/lib/links_gate.rb +0 -140
  133. package/scripts/lib/qmd_hook.rb +0 -41
  134. package/scripts/lib/spec_header.rb +0 -83
  135. package/scripts/lib/start_intent.rb +0 -296
  136. package/scripts/start-intent +0 -89
  137. package/skills/_active-intent-gate.md +0 -26
  138. package/skills/auto/references/tiers.md +0 -100
  139. package/skills/continuing/SKILL.md +0 -34
  140. package/skills/continuing/evals/evals.json +0 -91
  141. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  142. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  143. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  144. package/skills/intent-brainstorming/SKILL.md +0 -118
  145. package/skills/intent-brainstorming/evals/evals.json +0 -67
  146. package/skills/intent-continuing/evals/evals.json +0 -145
  147. package/skills/intent-discovering/SKILL.md +0 -56
  148. package/skills/intent-grilling/SKILL.md +0 -108
  149. package/skills/intent-linking/SKILL.md +0 -128
  150. package/skills/intent-linking/evals/evals.json +0 -22
  151. package/skills/intent-linking/references/zettelkasten.md +0 -45
  152. package/skills/intent-locking/SKILL.md +0 -64
  153. package/skills/intent-planning/SKILL.md +0 -145
  154. package/skills/intent-planning/evals/evals.json +0 -81
  155. package/skills/intent-planning/references/plan-format.md +0 -130
  156. package/skills/intent-researching/SKILL.md +0 -123
  157. package/skills/intent-researching/evals/evals.json +0 -22
  158. package/skills/intent-savepoint/SKILL.md +0 -83
  159. package/skills/intent-savepoint/references/context-management.md +0 -32
  160. package/skills/intent-starting/SKILL.md +0 -151
  161. package/skills/intent-starting/evals/evals.json +0 -117
  162. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  163. package/skills/project-continuing/SKILL.md +0 -119
  164. package/skills/project-continuing/evals/evals.json +0 -100
  165. package/skills/roadmap-continuing/SKILL.md +0 -89
  166. package/skills/roadmap-continuing/evals/evals.json +0 -82
  167. package/skills/skill-creating/SKILL.md +0 -75
  168. package/skills/skill-creating/evals/evals.json +0 -108
  169. package/skills/skill-creating/references/agents.md +0 -168
  170. package/skills/skill-creating/references/defaults-first.md +0 -23
  171. package/skills/skill-creating/references/evals.md +0 -41
  172. package/skills/skill-creating/references/hooks.md +0 -251
  173. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  174. package/skills/skill-creating/references/scripts.md +0 -166
  175. package/skills/skill-creating/references/skills.md +0 -169
  176. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  177. package/skills/skill-evaluating/SKILL.md +0 -141
  178. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  179. package/skills/skill-evaluating/evals/evals.json +0 -75
  180. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  181. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  182. package/skills/store-curating/SKILL.md +0 -64
  183. package/skills/store-curating/evals/evals.json +0 -38
  184. package/skills/store-indexing/SKILL.md +0 -93
  185. package/skills/store-indexing/evals/evals.json +0 -22
  186. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  187. package/skills/store-provisioning/SKILL.md +0 -55
  188. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  189. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  190. /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
 
@@ -431,7 +433,7 @@ class Doctor
431
433
  message: "#{bad_sections.size} intent file(s) have non-sanctioned ## sections",
432
434
  details: bad_sections.map { |b| "#{b[:dir]}: #{b[:issues].join(", ")}" },
433
435
  fixable: true,
434
- fix_hint: "Dispatch plastic-store-curating to relocate each unsanctioned section into the " \
436
+ fix_hint: "Relocate each unsanctioned section into the " \
435
437
  "intent's revisions.md via move-and-record (a missing required section is restored " \
436
438
  "or reprojected instead); see plastic-conventions > references/maintenance-and-revisions.md"
437
439
  )
@@ -553,9 +555,9 @@ class Doctor
553
555
  # suppress) is exactly what makes the row dead.
554
556
  def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, active:, excluded_rules: [])
555
557
  outcome = File.join(dir, "outcome.md")
556
- outcome_real = Bridge.stage_file_present?(outcome)
558
+ outcome_real = Savepoint.stage_file_present?(outcome)
557
559
  findings = { conflict: nil, phantom: nil, gap: [], operational_gap: [], excluded: [],
558
- excluded_rules_fired: [], stalled: nil }
560
+ excluded_rules_fired: [], stalled: nil, unbackfilled: [], excluded_backfill: [] }
559
561
 
560
562
  # HARD conflict: the deliverable exists but INDEX still says Active. This
561
563
  # is the one true INDEX-wins disagreement, so it stays a fail.
@@ -569,7 +571,7 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
569
571
  # the generic replacement for the frozen 170a amnesty list is simply "never suppress" -
570
572
  # the message already labels a terminal phantom report-only, so dropping the id-based
571
573
  # suppression is strictly more general and needs no per-store state at all).
572
- phantom_lines = Bridge.savepoint_phantom_lines(dir)
574
+ phantom_lines = Savepoint.savepoint_phantom_lines(dir)
573
575
  if phantom_lines.any?
574
576
  detail = phantom_lines.map { |line, reason| "#{line} (#{reason})" }.join("; ")
575
577
  scope_note = terminal ? "terminal in INDEX, report-only (immutable history)" : "live intent, auto-rebuildable"
@@ -583,7 +585,7 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
583
585
  if terminal && !outcome_real
584
586
  state = File.exist?(outcome) ? "still a placeholder" : "missing"
585
587
  findings[:gap] << "#{label}: terminal in INDEX but outcome.md is #{state} " \
586
- "(delivery claim - never fabricated)"
588
+ "(delivery claim; written at close by end-intent since intent 308)"
587
589
  end
588
590
 
589
591
  if terminal
@@ -603,6 +605,21 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
603
605
  end
604
606
  findings[:excluded_rules_fired] << "savepoint_operational" if suppressed && findings[:excluded].any?
605
607
 
608
+ # Unbackfilled documents (intent 308): spec.md or plan.md missing or still the
609
+ # placeholder, or no real action file, on a terminal intent. Every one of these is
610
+ # written from the record by `scaffold-intent backfill`, so this is repairable and
611
+ # reported as a fixable warn (backfilled_complete). Its exclusions go to their OWN
612
+ # bucket so savepoint_operational's consumed/dead-row bookkeeping above never sees them.
613
+ backfill_gaps = %w[spec.md plan.md].reject { |f| Savepoint.stage_file_present?(File.join(dir, f)) }
614
+ backfill_gaps << "actions/" unless Savepoint.has_real_action?(dir)
615
+ if backfill_gaps.any?
616
+ suppressed_backfill = excluded_rules.include?("backfilled_complete")
617
+ target = suppressed_backfill ? findings[:excluded_backfill] : findings[:unbackfilled]
618
+ target << "#{label}: terminal in INDEX but #{backfill_gaps.join(", ")} " \
619
+ "#{backfill_gaps.size == 1 ? "is" : "are"} missing or still a placeholder (backfillable)"
620
+ findings[:excluded_rules_fired] << "backfilled_complete" if suppressed_backfill
621
+ end
622
+
606
623
  # Stalled completion: unchanged, never consulted amnesty.
607
624
  if File.exist?(Lock.path(dir))
608
625
  note = Lock.fresh?(dir) ? "delivery.lock still present (post-done window not closed)"
@@ -626,6 +643,9 @@ def check_done_signals(scopes: nil)
626
643
  dead_row_paths = []
627
644
  stalled = []
628
645
  phantoms = []
646
+ unbackfilled = [] # spec/plan/actions gaps on terminal intents (intent 308) - repairable
647
+ excluded_backfill = [] # backfill gaps knowingly exempted via doctor-exclusions (intent 308)
648
+ dead_rows_by_rule = Hash.new { |h, k| h[k] = [] }
629
649
 
630
650
  done_signal_stores(scopes).each do |store|
631
651
  exclusions = DoctorExclusions.load(store[:index])
@@ -634,7 +654,7 @@ def check_done_signals(scopes: nil)
634
654
  exclusion_error_paths << exclusions[:path]
635
655
  end
636
656
 
637
- consumed = { "savepoint_operational" => [] }
657
+ consumed = { "savepoint_operational" => [], "backfilled_complete" => [] }
638
658
  # `known_ids` (post-review fix): every intent id with a REAL DIRECTORY in this store, scanned
639
659
  # directly from disk - independent of INDEX.md. An id can have a directory on disk without
640
660
  # being listed in INDEX (a de-indexed "ghost"), and the walk below alone would never visit it;
@@ -672,6 +692,11 @@ def check_done_signals(scopes: nil)
672
692
  phantoms << findings[:phantom] if findings[:phantom]
673
693
  gaps.concat(findings[:gap])
674
694
  operational_gaps.concat(findings[:operational_gap])
695
+ unbackfilled.concat(findings[:unbackfilled])
696
+ if findings[:excluded_backfill].any?
697
+ excluded_backfill.concat(findings[:excluded_backfill])
698
+ exclusion_paths << exclusions[:path]
699
+ end
675
700
  if findings[:excluded].any?
676
701
  excluded.concat(findings[:excluded])
677
702
  exclusion_paths << exclusions[:path]
@@ -692,7 +717,9 @@ def check_done_signals(scopes: nil)
692
717
  else
693
718
  "names an intent with no current #{row[:rule]} finding"
694
719
  end
695
- dead_rows << "#{store[:scope]}: #{exclusions[:path]}: #{row[:rule]} #{row[:id]} - #{reason}"
720
+ dead_row = "#{store[:scope]}: #{exclusions[:path]}: #{row[:rule]} #{row[:id]} - #{reason}"
721
+ dead_rows << dead_row
722
+ dead_rows_by_rule[row[:rule]] << dead_row
696
723
  dead_row_paths << exclusions[:path]
697
724
  end
698
725
  end
@@ -733,7 +760,7 @@ def check_done_signals(scopes: nil)
733
760
  checks << check(
734
761
  category: "done_signals", name: "signals_complete", status: "pass",
735
762
  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)",
763
+ "(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
764
  details: gaps
738
765
  )
739
766
  end
@@ -750,10 +777,19 @@ def check_done_signals(scopes: nil)
750
777
  # suppressed nothing this run. It is purely informational, exactly like `exclusion_suffix` - it
751
778
  # never changes status on any of the three branches below, because a stale governance-record row
752
779
  # 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`)"
780
+ # Per rule (intent 308): savepoint_operational and backfilled_complete each fold in only
781
+ # their own exclusions and dead rows, so one check never carries the other's counts.
782
+ suffixes = lambda do |excluded_rows, dead|
783
+ ex = excluded_rows.empty? ? "" : " (#{excluded_rows.size} excluded via #{exclusion_paths.join(", ")})"
784
+ dd = dead.empty? ? "" : " (#{dead.size} dead row#{dead.size == 1 ? "" : "s"} " \
785
+ "in #{dead_row_paths.join(", ")}, suppressing nothing - prune with " \
786
+ "`maintenance-run --tool register-exclusions --prune`)"
787
+ [ex, dd]
788
+ end
789
+ savepoint_dead = dead_rows_by_rule["savepoint_operational"]
790
+ backfill_dead = dead_rows_by_rule["backfilled_complete"]
791
+ exclusion_suffix, dead_suffix = suffixes.call(excluded, savepoint_dead)
792
+ backfill_exclusion_suffix, backfill_dead_suffix = suffixes.call(excluded_backfill, backfill_dead)
757
793
 
758
794
  if exclusion_errors.any?
759
795
  checks << check(
@@ -762,7 +798,7 @@ def check_done_signals(scopes: nil)
762
798
  "missing an operational savepoint.md or its Done echo, and " \
763
799
  "#{exclusion_errors.size} doctor-exclusions error#{exclusion_errors.size == 1 ? "" : "s"} " \
764
800
  "(a malformed exclusion file never suppresses a finding)#{exclusion_suffix}#{dead_suffix}",
765
- details: operational_gaps + exclusion_errors + dead_rows, fixable: true,
801
+ details: operational_gaps + exclusion_errors + savepoint_dead, fixable: true,
766
802
  fix_hint: "Fix the malformed doctor-exclusions file(s) (#{exclusion_error_paths.join(", ")}) - " \
767
803
  "format `rule_name id id id`, blank lines and # comments ignored - then reconstruct " \
768
804
  "any remaining real gap via `maintenance-run --tool rebuild-savepoint --intent <id> " \
@@ -774,7 +810,7 @@ def check_done_signals(scopes: nil)
774
810
  category: "done_signals", name: "savepoint_operational", status: "pass",
775
811
  message: "No terminal intent is missing an operational savepoint.md or its Done echo" \
776
812
  "#{exclusion_suffix}#{dead_suffix}",
777
- details: dead_rows
813
+ details: savepoint_dead
778
814
  )
779
815
  else
780
816
  checks << check(
@@ -782,13 +818,50 @@ def check_done_signals(scopes: nil)
782
818
  message: "#{operational_gaps.size} terminal intent#{operational_gaps.size == 1 ? "" : "s"} " \
783
819
  "missing an operational savepoint.md or its Done echo (reconstructible)" \
784
820
  "#{exclusion_suffix}#{dead_suffix}",
785
- details: operational_gaps + dead_rows, fixable: true,
821
+ details: operational_gaps + savepoint_dead, fixable: true,
786
822
  fix_hint: "Reconstruct the minimal two-line started/Done echo via " \
787
823
  "`maintenance-run --tool rebuild-savepoint --intent <id> --apply` (197-conformant: " \
788
824
  "receipt-before-write via RevisionsWriter, one intent per invocation, owner-approval-gated)."
789
825
  )
790
826
  end
791
827
 
828
+ # backfilled_complete (intent 308): spec.md, plan.md, or a real action file missing on a
829
+ # terminal intent. Same three branches as savepoint_operational: a malformed exclusion
830
+ # file is always loud, a clean set passes with its own exclusion and dead-row counts
831
+ # folded in, a real gap set warns with the backfill verb as the fix.
832
+ backfill_hint = "Write the missing documents from the record via " \
833
+ "`scaffold-intent backfill --store <store> --id <id> --disposition " \
834
+ "<delivered|abandoned>` (never touches real content, one intent per invocation)."
835
+ if exclusion_errors.any?
836
+ checks << check(
837
+ category: "done_signals", name: "backfilled_complete", status: "warn",
838
+ message: "#{unbackfilled.size} terminal intent#{unbackfilled.size == 1 ? "" : "s"} " \
839
+ "missing a backfillable document, and #{exclusion_errors.size} doctor-exclusions " \
840
+ "error#{exclusion_errors.size == 1 ? "" : "s"} (a malformed exclusion file never " \
841
+ "suppresses a finding)#{backfill_exclusion_suffix}#{backfill_dead_suffix}",
842
+ details: unbackfilled + exclusion_errors + backfill_dead, fixable: true,
843
+ fix_hint: "Fix the malformed doctor-exclusions file(s) (#{exclusion_error_paths.join(", ")}), " \
844
+ "then write the missing documents from the record via `scaffold-intent backfill " \
845
+ "--store <store> --id <id> --disposition <delivered|abandoned>`."
846
+ )
847
+ elsif unbackfilled.empty?
848
+ checks << check(
849
+ category: "done_signals", name: "backfilled_complete", status: "pass",
850
+ message: "Every terminal intent carries a real spec.md, plan.md, and action file" \
851
+ "#{backfill_exclusion_suffix}#{backfill_dead_suffix}",
852
+ details: backfill_dead
853
+ )
854
+ else
855
+ checks << check(
856
+ category: "done_signals", name: "backfilled_complete", status: "warn",
857
+ message: "#{unbackfilled.size} terminal intent#{unbackfilled.size == 1 ? "" : "s"} " \
858
+ "missing a backfillable document (spec.md, plan.md, or a real action file)" \
859
+ "#{backfill_exclusion_suffix}#{backfill_dead_suffix}",
860
+ details: unbackfilled + backfill_dead, fixable: true,
861
+ fix_hint: backfill_hint
862
+ )
863
+ end
864
+
792
865
  if stalled.empty?
793
866
  checks << check(
794
867
  category: "done_signals", name: "stalled_completion", status: "pass",
@@ -818,8 +891,8 @@ def check_done_signals(scopes: nil)
818
891
  message: "#{phantoms.size} intent#{phantoms.size == 1 ? "" : "s"} carry savepoint.md " \
819
892
  "line(s) contradicted by disk (advisory; terminal history stays report-only)",
820
893
  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: " \
894
+ fix_hint: "For a live (Active) intent, rebuild the ledger via " \
895
+ "Savepoint.rebuild_savepoint. Terminal (Completed/Abandoned) intents are immutable: " \
823
896
  "a phantom there stays advisory unless an explicit human grant authorizes the " \
824
897
  "124a manual Done-bookend repair."
825
898
  )
@@ -991,9 +1064,9 @@ end
991
1064
  INTENT_END_LIFECYCLE_FILES = %w[spec.md plan.md checklist.md outcome.md].freeze
992
1065
 
993
1066
  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
1067
+ missing = INTENT_END_LIFECYCLE_FILES.select { |f| !Savepoint.stage_file_present?(File.join(intent_dir, f)) }
1068
+ unless Savepoint.stage_file_present?(Savepoint.intent_file(intent_dir))
1069
+ missing = [File.basename(Savepoint.intent_file(intent_dir))] + missing
997
1070
  end
998
1071
 
999
1072
  outcome_note = nil
@@ -1014,7 +1087,7 @@ end
1014
1087
 
1015
1088
  def intent_checklist_complete_check(intent_dir)
1016
1089
  path = File.join(intent_dir, "checklist.md")
1017
- unless Bridge.stage_file_present?(path)
1090
+ unless Savepoint.stage_file_present?(path)
1018
1091
  return check(category: "intent_end", name: "intent_checklist_complete", status: "pass",
1019
1092
  message: "n/a: checklist.md absent or placeholder (flagged above if that is a gap)")
1020
1093
  end
@@ -1074,7 +1147,8 @@ end
1074
1147
  # Terminal-gated (281 D3): done_signal_findings_for_dir only ever produces this finding
1075
1148
  # inside `if terminal`, so honoring the exclusion for a still-Active intent would suppress a
1076
1149
  # 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.
1150
+ # the live, repairable warning scripts/end-intent's structure self-check exists to raise
1151
+ # (a report, never a refusal, since intent 308).
1078
1152
  #
1079
1153
  # Never raises: DoctorExclusions is fail-open by contract (274 D5) and index_sections_by_dir
1080
1154
  # returns an empty map for a missing INDEX.
@@ -1131,9 +1205,9 @@ end
1131
1205
  first_line = File.read(savepoint).each_line.find { |l| !l.strip.empty? }.to_s.strip
1132
1206
  parts = first_line.split(/\s{2,}/)
1133
1207
  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)))
1208
+ expected_pair = Savepoint.savepoint_milestone(intent_dir, File.basename(Savepoint.intent_file(intent_dir)))
1135
1209
 
1136
- phantoms = Bridge.savepoint_phantom_lines(intent_dir)
1210
+ phantoms = Savepoint.savepoint_phantom_lines(intent_dir)
1137
1211
  problems = []
1138
1212
  problems << "born line #{born_pair.inspect} does not match the expected #{expected_pair.inspect}" \
1139
1213
  if born_pair != expected_pair
@@ -1147,7 +1221,7 @@ end
1147
1221
  check(category: "intent_end", name: "intent_savepoint_truthful", status: "warn",
1148
1222
  message: "#{problems.size} savepoint truthfulness issue(s) (advisory, never blocking)",
1149
1223
  details: problems,
1150
- fixable: true, fix_hint: "Run plastic-intent-savepoint to rebuild via Bridge.rebuild_savepoint")
1224
+ fixable: true, fix_hint: "Rebuild the ledger via Savepoint.rebuild_savepoint")
1151
1225
  end
1152
1226
  end
1153
1227
 
@@ -1270,7 +1344,7 @@ end
1270
1344
  checks << graph_finding_check(
1271
1345
  "graph_i4_danglers", i4,
1272
1346
  "Every sources/chain id resolves to a real intent (I4)",
1273
- "Dispatch plastic-store-curating to record the dangling sources/chain edge as a " \
1347
+ "Record the dangling sources/chain edge as a " \
1274
1348
  "broken-source/broken-chain move-and-record entry in the intent's revisions.md (see " \
1275
1349
  "plastic-conventions > references/maintenance-and-revisions.md), or restore the missing intent"
1276
1350
  )
@@ -1297,18 +1371,6 @@ end
1297
1371
 
1298
1372
 
1299
1373
 
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
1374
  # Plastic skills install as ~/.claude/skills/plastic-<name>/SKILL.md. Pass if at
1313
1375
  # least one such skill is present.
1314
1376
 
@@ -1357,7 +1419,7 @@ end
1357
1419
  # dispatcher is an executable script with real top-level side effects (it reads
1358
1420
  # $stdin and may exit), so it can never be required or executed to introspect it,
1359
1421
  # 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
1422
+ # hook names from the STATE_HOOKS %w[] literal, plus the `when
1361
1423
  # "<name>"` labels of the top-level `case gate` statement (stopping at its
1362
1424
  # trailing `else`). Line-shape dependent, not AST-safe, disclosed as such in
1363
1425
  # docs; the healthy-install pass test against the REAL dispatcher is what proves
@@ -1407,7 +1469,7 @@ end
1407
1469
  # Classification per installed agent basename, checked in this order:
1408
1470
  # 1. an `agents.models.<basename>` override IS configured -> sanctioned,
1409
1471
  # pass/informational, LISTED regardless of whether frontmatter matches
1410
- # (that mismatch is the override working, e.g. plastic-brainstorming: fable).
1472
+ # (that mismatch is the override working, e.g. plastic-executor: fable).
1411
1473
  # 2. no override AND basename is a TIER_DEFAULTS key -> compare frontmatter
1412
1474
  # to that default; match is pass (clean), mismatch is real drift (warn).
1413
1475
  # 3. no override AND basename is a CONSULTATION_AGENTS member -> not a
@@ -2178,6 +2240,7 @@ end
2178
2240
  all_checks += check_qmd
2179
2241
  all_checks += check_ruby_runtime
2180
2242
  all_checks += check_done_signals(scopes: ["global"])
2243
+ all_checks += check_session_ledger(scopes: ["global"])
2181
2244
  all_checks += check_skill_lint
2182
2245
  all_checks += check_install_integrity
2183
2246
 
@@ -2228,10 +2291,10 @@ end
2228
2291
  case store
2229
2292
  when :all
2230
2293
  check_global_store + check_project_stores + check_conventions + check_done_signals +
2231
- check_qmd(detector: qmd_detector, runner: qmd_runner)
2294
+ check_session_ledger + check_qmd(detector: qmd_detector, runner: qmd_runner)
2232
2295
  when :global
2233
2296
  check_global_store + check_conventions(scopes: ["global"]) +
2234
- check_done_signals(scopes: ["global"]) +
2297
+ check_done_signals(scopes: ["global"]) + check_session_ledger(scopes: ["global"]) +
2235
2298
  check_qmd(detector: qmd_detector, runner: qmd_runner, collection: "plastic-global")
2236
2299
  else
2237
2300
  all_checks_for_project_slug(store, qmd_detector: qmd_detector, qmd_runner: qmd_runner,
@@ -28,8 +28,12 @@
28
28
  # file that exists while no session identity resolves from ANY source
29
29
  # also refuses (exit 4) rather than ever reaching Lock.takeover with a
30
30
  # blank session, which would delete the lock file and then crash.
31
- # 1. Guard outcome.md (exit 2, authors nothing, on any failure), then stamp
32
- # the intent file's `## Outcome` section with --outcome-summary if given.
31
+ # 1. Backfill (intent 308): write spec.md, plan.md, actions/ACTION_1.md, and
32
+ # outcome.md from the record wherever a file is missing or still the
33
+ # placeholder (BackfillIntent; real content is never touched). Then run
34
+ # doctor's per-intent structure check and OutcomeGuard as a self-check
35
+ # that reports on stderr and proceeds. Then stamp the intent file's
36
+ # `## Outcome` section with --outcome-summary if given.
33
37
  # 2. Move the intent's INDEX.md line from `## Active` into `## Completed`
34
38
  # (delivered) or `## Abandoned` (abandoned), dated today, appending
35
39
  # --index-note (if given). Accepts a real em dash OR a plain hyphen as
@@ -37,23 +41,21 @@
37
41
  # always EMITS the real em dash on write. An id that resolves to neither
38
42
  # `## Active` nor the terminal section is a loud failure (exit 1); an id
39
43
  # already correctly in the terminal section is a quiet, idempotent success.
40
- # 3. Append the savepoint `Done` bookend (Bridge.append_terminal_savepoint).
44
+ # 3. Append the savepoint `Done` bookend (Savepoint.append_terminal_savepoint).
41
45
  # 4. Commit the store repo, unless --no-commit.
42
- # 5. Disarm (intent 188, D2/D16): check the code worktree (resolved from the
43
- # bridge) for uncommitted changes first (exit 5 if dirty, unless
44
- # --discard-worktree-changes); call the disarm seam (default
45
- # Bridge.disarm_auto); verify the durable lock file is actually gone
46
- # afterward. The /tmp bridge is only a cache (AGENTS.md): when no bridge
47
- # resolves at all (a wiped /tmp, a resumed job under a new session id),
48
- # the durable lock file is released directly instead, LOUDLY (a warning
49
- # that a worktree may be orphaned), rather than stranding a committed,
50
- # terminal intent still holding its lock.
46
+ # 5. Disarm (intent 188, D2/D16; intent 307): check the code worktree (derived
47
+ # from projects.yml and the intent id, Arm.worktree_block) for uncommitted
48
+ # changes first (exit 5 if dirty, unless --discard-worktree-changes); call the
49
+ # disarm seam (default Arm.disarm: worktree released, lock cleared, the session
50
+ # pointer back on the day ledger); verify the durable lock file is actually gone
51
+ # afterward, and release it directly when a foreign-keyed disarm left it (the
52
+ # /tmp bridge this step once read was removed in 2.0, intent 307).
51
53
  #
52
54
  # Exit codes:
53
55
  # 0 ok - the intent is closed and no delivery.lock remains.
54
56
  # 1 usage/resolution failure, OR an id that resolves to neither ## Active
55
57
  # nor the terminal section (D11). Deliberately double duty; see plan.md.
56
- # 2 the outcome.md guard refused; authors nothing.
58
+ # 2 retired in 2.0 (intent 308): the outcome.md guard reports and proceeds.
57
59
  # 3 steps 1-4 already committed, but the delivery lock genuinely could not
58
60
  # be cleared (not a bridge cache miss; see step 5 above).
59
61
  # 4 pre-flight refusal: a FRESH foreign lock is held by a live,
@@ -63,19 +65,19 @@
63
65
  # (git status itself failed: corrupt index, git missing, not a repo);
64
66
  # refused before removal; pass --discard-worktree-changes to override
65
67
  # deliberately.
66
- # 6 the doctor per-intent structure gate refused (checklist incomplete,
67
- # links out of projection, intent-file malformed, or a lifecycle artifact
68
- # missing/placeholder); named reasons on stderr; authors nothing
69
- # (intent 222).
68
+ # 6 retired in 2.0 (intent 308): the structure check reports and proceeds.
70
69
 
71
70
  require "fileutils"
72
71
  require "date"
73
72
  require "open3"
74
73
  require "pathname"
75
74
  require_relative "lib/bridge"
75
+ require_relative "lib/arm"
76
+ require_relative "lib/savepoint"
76
77
  require_relative "lib/lock"
77
78
  require_relative "lib/intent_validator"
78
79
  require_relative "lib/outcome_guard"
80
+ require_relative "lib/backfill_intent"
79
81
 
80
82
  DISPOSITIONS = %w[delivered abandoned].freeze
81
83
 
@@ -169,44 +171,41 @@ def resolve_plastic_home_and_scope(store)
169
171
  end
170
172
  end
171
173
 
172
- # Doctor's per-intent structure gate (intent 222), a pre-write step: FAIL refuses the close
173
- # (exit 6, authors nothing), WARN prints an advisory note and proceeds, PASS proceeds
174
- # silently, and an unexpected exception from the gate call itself is rescued and treated as
175
- # a skipped gate (fail-open on a crash: a broken gate must never wedge a close).
176
- #
177
- # `gate:` is an injected seam (mirrors run_disarm's own bridge_reader:/disarm: pattern
178
- # below) defaulting to the real Doctor.new(...).run_intent_check(...) call; tests load this
179
- # script in-process (as test/end_intent_disarm_toctou_test.rb already does for run_disarm)
180
- # and inject a raising stub to prove the fail-open contract, with no eval/ENV/global seam.
174
+ # Backfill (intent 308): write the judgment documents that are missing or still the
175
+ # placeholder from the record (BackfillIntent). Fail-open: a crash here warns and the
176
+ # close proceeds, the same contract as the structure check below.
177
+ def run_backfill(intent_dir, store:, id:, disposition:, summary:)
178
+ result = BackfillIntent.run(intent_dir: intent_dir, store: store, id: id,
179
+ disposition: disposition, summary: summary)
180
+ result[:written].each { |rel| warn "end-intent: backfilled #{rel} from the record" }
181
+ result[:notes].each { |note| warn "end-intent: backfill note: #{note}" }
182
+ rescue StandardError => e
183
+ warn "end-intent: backfill crashed (#{e.message}); proceeding without it"
184
+ end
185
+
186
+ # Doctor's per-intent structure check (intent 222; a self-check since intent 308). Runs
187
+ # after the backfill and REPORTS: every FAIL or WARN check prints its named reason on
188
+ # stderr and the close proceeds. Nothing here exits; a gap end-intent cannot write itself
189
+ # (an unchecked box, a malformed intent file, links out of projection) is the owner's to
190
+ # fix afterward, and doctor --intent keeps reporting it. A crash is rescued the same way.
191
+ # Injected `gate:` seam: tests drive the real Doctor against a hermetic home, or a raising
192
+ # stub to prove the fail-open contract, with no eval/ENV/global seam.
181
193
  #
182
- # Disposition is deliberately NOT passed to the real gate call (intent 222 executor
183
- # decision): the existing outcome-only guard immediately following this call already owns
184
- # disposition-matching (exit 2), and intent_lifecycle_artifacts unconditionally checks
185
- # outcome.md PRESENCE via Bridge.stage_file_present? regardless of disposition, so passing
186
- # it here would make this gate re-check the SAME disposition match the old guard already
187
- # owns, needlessly widening which exit-2 fixtures become exit-6. doctor.rb --intent called
188
- # directly (with --disposition) still exercises the fold-in per D2/acceptance criteria.
189
- def run_structure_gate(id, store:, gate: ->(gate_home, gate_scope) {
194
+ # Disposition is deliberately NOT passed to the real gate call: OutcomeGuard.reason,
195
+ # called right after this in main, already owns disposition-matching, and
196
+ # intent_lifecycle_artifacts checks outcome.md PRESENCE regardless of disposition.
197
+ def run_structure_check(id, store:, gate: ->(gate_home, gate_scope) {
190
198
  require_relative "doctor"
191
199
  Doctor.new(plastic_home: gate_home).run_intent_check(id, store: gate_scope)
192
200
  })
193
201
  gate_home, gate_scope = resolve_plastic_home_and_scope(store)
194
- gate_verdict = gate.call(gate_home, gate_scope)
195
- case gate_verdict[:status]
196
- when "fail"
197
- gate_verdict[:checks].select { |c| c[:status] == "fail" }.each do |c|
198
- detail = Array(c[:details]).any? ? " (#{Array(c[:details]).join("; ")})" : ""
199
- warn "end-intent: structure gate refused: #{c[:name]}: #{c[:message]}#{detail}"
200
- end
201
- exit 6
202
- when "warn"
203
- gate_verdict[:checks].select { |c| c[:status] == "warn" }.each do |c|
204
- detail = Array(c[:details]).any? ? " (#{Array(c[:details]).join("; ")})" : ""
205
- warn "end-intent: structure gate warning (proceeding): #{c[:name]}: #{c[:message]}#{detail}"
206
- end
202
+ verdict = gate.call(gate_home, gate_scope)
203
+ verdict[:checks].select { |c| %w[fail warn].include?(c[:status]) }.each do |c|
204
+ detail = Array(c[:details]).any? ? " (#{Array(c[:details]).join("; ")})" : ""
205
+ warn "end-intent: structure check: #{c[:name]}: #{c[:message]}#{detail}"
207
206
  end
208
207
  rescue StandardError => e
209
- warn "end-intent: structure gate crashed (#{e.message}); proceeding without it"
208
+ warn "end-intent: structure check crashed (#{e.message}); proceeding without it"
210
209
  end
211
210
 
212
211
  # outcome.md guard: see OutcomeGuard.reason (scripts/lib/outcome_guard.rb, extracted intent 222)
@@ -459,21 +458,21 @@ end
459
458
 
460
459
  # --- step 5: disarm (D2/D16, intent 188) ------------------------------------
461
460
  #
462
- # Injected seams (D2): bridge_reader defaults to the real Bridge.read (so the
463
- # dirty-worktree check can resolve the code worktree path without disarming
464
- # first), disarm defaults to the real Bridge.disarm_auto. Tests may inject
461
+ # Injected seams (D2, re-pointed by intent 307): worktree_reader defaults to
462
+ # Arm.worktree_block (so the dirty-worktree check can resolve the code worktree
463
+ # path without disarming first), disarm defaults to Arm.disarm. Tests may inject
465
464
  # fakes for in-process unit coverage of this function; the subprocess-driven
466
465
  # end-to-end tests exercise the real defaults against a hermetic tmp home.
467
466
  #
468
467
  # Returns :ok, :dirty (exit 5), or :lock_remains (exit 3). A blank session
469
468
  # (D3's "no lock at all" fallback case) is a clean no-op: :ok, nothing touched.
470
469
  def run_disarm(intent_dir, id, session, discard_worktree_changes:,
471
- bridge_reader: ->(sess, iid) { Bridge.read(sess, intent_id: iid) },
472
- disarm: ->(sess, iid) { Bridge.disarm_auto(sess, intent_id: iid) })
470
+ worktree_reader: ->(dir) { Arm.worktree_block(intent_dir: dir) },
471
+ disarm: ->(sess, dir) { Arm.disarm(intent_dir: dir, session: sess) })
473
472
  return :ok if Bridge.blank?(session)
474
473
 
475
- data = bridge_reader.call(session, id)
476
- worktree_code = data.is_a?(Hash) ? data.dig("worktree", "code") : nil
474
+ block = worktree_reader.call(intent_dir)
475
+ worktree_code = block.is_a?(Hash) ? block["code"] : nil
477
476
 
478
477
  if worktree_code && Dir.exist?(worktree_code)
479
478
  begin
@@ -505,16 +504,15 @@ def run_disarm(intent_dir, id, session, discard_worktree_changes:,
505
504
  end
506
505
  end
507
506
 
508
- disarm.call(session, id)
507
+ disarm.call(session, intent_dir)
509
508
 
510
- # The /tmp bridge is only a cache; the durable lock file is the truth
511
- # (AGENTS.md). `disarm_auto` no-ops entirely when no bridge resolves (a wiped
512
- # /tmp, a resumed job under a new session id), which would otherwise strand a
513
- # committed, terminal intent still holding its lock: the exact stalled
514
- # completion this intent forbids. Pre-flight has already established that this
515
- # session is the owner, a delegate, or the post-takeover owner, so clearing
516
- # the durable lock directly IS safe, PROVIDED the lock actually present here
517
- # is still ours to clear.
509
+ # The durable lock file is the truth (AGENTS.md). Arm.disarm releases as the
510
+ # lock's recorded owner, so a lock a delegate or a post-takeover owner is
511
+ # closing under a different key can survive it; that would strand a committed,
512
+ # terminal intent still holding its lock, the exact stalled completion this
513
+ # intent forbids. Pre-flight has already established that this session is the
514
+ # owner, a delegate, or the post-takeover owner, so clearing the durable lock
515
+ # directly IS safe, PROVIDED the lock actually present here is still ours to clear.
518
516
  #
519
517
  # TOCTOU fix (post-review hardening, intent 188): the first version of this
520
518
  # fallback read the lock's OWN recorded owner and handed it straight back to
@@ -556,8 +554,8 @@ def run_disarm(intent_dir, id, session, discard_worktree_changes:,
556
554
  end
557
555
 
558
556
  unless File.exist?(Lock.path(intent_dir))
559
- if data.nil?
560
- warn "end-intent: no bridge resolved for session #{session.inspect} (intent #{id}); " \
557
+ if block.nil?
558
+ warn "end-intent: no worktree block could be derived for intent #{id}; " \
561
559
  "the delivery lock was cleared directly from disk, but any worktree this intent " \
562
560
  "provisioned was NOT removed. Run /plastic-doctor to check for an orphaned worktree."
563
561
  end
@@ -621,16 +619,10 @@ def main(argv)
621
619
  exit 4
622
620
  end
623
621
 
624
- run_structure_gate(id, store: store)
625
-
626
- reason = OutcomeGuard.reason(intent_dir, disposition)
627
- if reason
628
- warn "end-intent: #{reason}"
629
- exit 2
630
- end
631
-
632
622
  if opts[:dry_run]
633
623
  puts "end-intent: dry run for #{File.basename(intent_dir)} (#{disposition})"
624
+ targets = BackfillIntent.targets_for(intent_dir)
625
+ puts " would backfill: #{targets.empty? ? "(nothing)" : targets.join(", ")}"
634
626
  puts " would stamp ## Outcome: #{opts[:outcome_summary].inspect}" if opts[:outcome_summary]
635
627
  puts " would move INDEX.md entry from ## Active to #{index_target_heading(disposition)} (#{index_path})"
636
628
  puts " would append index note: #{opts[:index_note].inspect}" if opts[:index_note]
@@ -653,9 +645,18 @@ def main(argv)
653
645
  end
654
646
  end
655
647
 
648
+ # 1. Backfill from the record (intent 308), then the structure self-check and the
649
+ # outcome guard, both report-only since 308.
650
+ run_backfill(intent_dir, store: store, id: id, disposition: disposition,
651
+ summary: opts[:outcome_summary])
652
+ run_structure_check(id, store: store)
653
+
654
+ reason = OutcomeGuard.reason(intent_dir, disposition)
655
+ warn "end-intent: outcome.md: #{reason} (proceeding; doctor --intent #{id} reports it)" if reason
656
+
656
657
  # 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
657
658
  if opts[:outcome_summary]
658
- intent_file = Bridge.intent_file(intent_dir)
659
+ intent_file = Savepoint.intent_file(intent_dir)
659
660
  if File.exist?(intent_file)
660
661
  original = File.read(intent_file)
661
662
  updated = stamp_outcome_summary(original, opts[:outcome_summary])
@@ -682,7 +683,7 @@ def main(argv)
682
683
  end
683
684
 
684
685
  # 3. Savepoint Done bookend (idempotent).
685
- Bridge.append_terminal_savepoint(intent_dir, disposition)
686
+ Savepoint.append_terminal_savepoint(intent_dir, disposition)
686
687
 
687
688
  # 4. Store auto-commit, unless --no-commit. Scoped to this intent's own paths (D17):
688
689
  # never git add -A.