@zalom/plastic 1.0.3 → 1.1.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 (130) hide show
  1. package/PLASTIC-reference.md +11 -11
  2. package/PLASTIC.md +11 -9
  3. package/README.md +48 -1
  4. package/agents/plastic-enforcer.md +8 -5
  5. package/agents/plastic-future-intent-researcher.md +1 -1
  6. package/agents/plastic-intent-curator.md +9 -10
  7. package/agents/plastic-spec-specialist.md +9 -6
  8. package/bin/plastic.js +2 -2
  9. package/hooks/check-update +1 -1
  10. package/hooks/continue +1 -1
  11. package/package.json +1 -1
  12. package/scripts/dashboard.rb +29 -24
  13. package/scripts/doctor.rb +188 -9
  14. package/scripts/end-intent +330 -0
  15. package/scripts/hook-code-gate +1 -1
  16. package/scripts/hook-continue +2 -2
  17. package/scripts/hook-create-gate +2 -2
  18. package/scripts/hook-session-start +1 -1
  19. package/scripts/install.rb +2 -1
  20. package/scripts/lib/bridge.rb +116 -3
  21. package/scripts/lib/dashboard_banner.rb +8 -9
  22. package/scripts/lib/installer_core.rb +17 -8
  23. package/scripts/lib/legacy_bookend_amnesty.rb +35 -0
  24. package/scripts/lib/release_guard.rb +62 -0
  25. package/scripts/lib/roadmap_queue.rb +285 -0
  26. package/scripts/lib/roadmap_savepoint.rb +213 -0
  27. package/scripts/lib/store_provisioning.rb +1 -1
  28. package/scripts/lib/worktree.rb +21 -0
  29. package/scripts/new-intent +2 -1
  30. package/scripts/read-config +3 -3
  31. package/scripts/roadmap-next +44 -0
  32. package/scripts/roadmap-savepoint +64 -0
  33. package/scripts/{versions.rb → rollback.rb} +33 -41
  34. package/scripts/update.rb +1 -1
  35. package/skills/_active-intent-gate.md +1 -1
  36. package/skills/_decision-tables.md +50 -0
  37. package/skills/auto/SKILL.md +69 -68
  38. package/skills/auto/evals/evals.json +4 -4
  39. package/skills/auto/references/agent-architecture.md +2 -2
  40. package/skills/continuing/SKILL.md +23 -130
  41. package/skills/continuing/evals/evals.json +26 -80
  42. package/skills/dashboard/SKILL.md +18 -15
  43. package/skills/dashboard/references/classification.md +3 -3
  44. package/skills/dashboard/templates/dashboard-global.md +8 -23
  45. package/skills/dashboard/templates/dashboard-project.md +7 -26
  46. package/skills/doctor/SKILL.md +3 -3
  47. package/skills/install/SKILL.md +10 -10
  48. package/skills/{brainstorming → intent-brainstorming}/SKILL.md +43 -45
  49. package/skills/intent-brainstorming/evals/evals.json +67 -0
  50. package/skills/{brainstorming → intent-brainstorming}/references/design-principles.md +12 -17
  51. package/skills/intent-continuing/SKILL.md +99 -0
  52. package/skills/intent-continuing/evals/evals.json +145 -0
  53. package/skills/intent-continuing/references/context-management.md +28 -0
  54. package/skills/{creating-intent → intent-creating}/SKILL.md +3 -3
  55. package/skills/{creating-intent → intent-creating}/evals/evals.json +1 -1
  56. package/skills/{intent-discovery → intent-discovering}/SKILL.md +1 -1
  57. package/skills/intent-ending/SKILL.md +151 -0
  58. package/skills/intent-ending/evals/evals.json +74 -0
  59. package/skills/{executing-plan → intent-executing}/SKILL.md +75 -15
  60. package/skills/intent-executing/evals/evals.json +66 -0
  61. package/skills/{brainstorming-grill-me → intent-grilling}/SKILL.md +4 -4
  62. package/skills/{linking-intents → intent-linking}/SKILL.md +1 -1
  63. package/skills/{linking-intents → intent-linking}/evals/evals.json +1 -1
  64. package/skills/{lock → intent-locking}/SKILL.md +1 -1
  65. package/skills/{writing-plans → intent-planning}/SKILL.md +48 -20
  66. package/skills/intent-planning/evals/evals.json +66 -0
  67. package/skills/{writing-plans → intent-planning}/references/plan-format.md +31 -7
  68. package/skills/{research → intent-researching}/SKILL.md +2 -2
  69. package/skills/{research → intent-researching}/evals/evals.json +1 -1
  70. package/skills/{savepoint → intent-savepoint}/SKILL.md +13 -1
  71. package/skills/intent-speccing/SKILL.md +69 -0
  72. package/skills/intent-speccing/evals/evals.json +126 -0
  73. package/skills/intent-speccing/references/per-section-fill-rules.md +92 -0
  74. package/skills/intent-speccing/references/self-verify-checklist.md +38 -0
  75. package/skills/intent-starting/SKILL.md +2 -2
  76. package/skills/intent-starting/evals/evals.json +2 -2
  77. package/skills/project-continuing/SKILL.md +104 -0
  78. package/skills/project-continuing/evals/evals.json +100 -0
  79. package/skills/project-continuing/references/board-fill.md +33 -0
  80. package/skills/{creating-project → project-creating}/SKILL.md +2 -2
  81. package/skills/releasing/SKILL.md +66 -14
  82. package/skills/releasing/references/release-lines.md +105 -0
  83. package/skills/roadmap/SKILL.md +7 -1
  84. package/skills/roadmap/references/file-format.md +30 -1
  85. package/skills/roadmap/references/operations.md +26 -6
  86. package/skills/roadmap-continuing/SKILL.md +85 -0
  87. package/skills/roadmap-continuing/evals/evals.json +82 -0
  88. package/skills/roadmap-continuing/references/liveness-ranking.md +56 -0
  89. package/skills/{versions → rollback}/SKILL.md +30 -20
  90. package/skills/{creating-skills → skill-creating}/SKILL.md +2 -2
  91. package/skills/{creating-skills → skill-creating}/evals/evals.json +5 -5
  92. package/skills/{creating-skills → skill-creating}/references/progressive-disclosure.md +2 -2
  93. package/skills/{evaluating-skills → skill-evaluating}/SKILL.md +1 -1
  94. package/skills/{evaluating-skills → skill-evaluating}/evals/evals.json +3 -3
  95. package/skills/{intent-curator → store-curating}/SKILL.md +6 -7
  96. package/skills/{intent-curator → store-curating}/evals/evals.json +1 -1
  97. package/skills/{managing-index → store-indexing}/SKILL.md +6 -7
  98. package/skills/{managing-index → store-indexing}/evals/evals.json +1 -1
  99. package/skills/{add-project-store → store-provisioning}/SKILL.md +2 -2
  100. package/skills/tutorial/SKILL.md +65 -0
  101. package/skills/tutorial/evals/evals.json +186 -0
  102. package/skills/tutorial/references/track-1-guided.md +123 -0
  103. package/skills/tutorial/references/track-2-auto.md +95 -0
  104. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +110 -0
  105. package/skills/uninstall/SKILL.md +2 -2
  106. package/skills/update/SKILL.md +3 -3
  107. package/templates/agents.md +7 -7
  108. package/templates/config.yml +2 -1
  109. package/templates/index.md +4 -1
  110. package/skills/brainstorming/evals/evals.json +0 -22
  111. package/skills/savepoint/references/context-management.md +0 -32
  112. /package/skills/{creating-intent → intent-creating}/references/lifecycle.md +0 -0
  113. /package/skills/{creating-intent → intent-creating}/references/wikilinks.md +0 -0
  114. /package/skills/{executing-plan → intent-executing}/code-quality-reviewer-prompt.md +0 -0
  115. /package/skills/{executing-plan → intent-executing}/implementer-prompt.md +0 -0
  116. /package/skills/{executing-plan → intent-executing}/spec-reviewer-prompt.md +0 -0
  117. /package/skills/{linking-intents → intent-linking}/references/zettelkasten.md +0 -0
  118. /package/skills/{continuing → intent-savepoint}/references/context-management.md +0 -0
  119. /package/skills/{creating-project → project-creating}/references/hubs-projects.md +0 -0
  120. /package/skills/{creating-project → project-creating}/references/project-scaffolding.md +0 -0
  121. /package/skills/{creating-skills → skill-creating}/references/agents.md +0 -0
  122. /package/skills/{creating-skills → skill-creating}/references/evals.md +0 -0
  123. /package/skills/{creating-skills → skill-creating}/references/hooks.md +0 -0
  124. /package/skills/{creating-skills → skill-creating}/references/scripts.md +0 -0
  125. /package/skills/{creating-skills → skill-creating}/references/skills.md +0 -0
  126. /package/skills/{creating-skills → skill-creating}/scripts/scaffold.rb +0 -0
  127. /package/skills/{evaluating-skills → skill-evaluating}/assets/eval-template.json +0 -0
  128. /package/skills/{evaluating-skills → skill-evaluating}/references/convention-checks.md +0 -0
  129. /package/skills/{evaluating-skills → skill-evaluating}/references/eval-methodology.md +0 -0
  130. /package/skills/{managing-index → store-indexing}/references/zettelkasten-linking.md +0 -0
package/scripts/doctor.rb CHANGED
@@ -23,6 +23,8 @@ require_relative "lib/links_section"
23
23
  require_relative "lib/hook_registry"
24
24
  require_relative "lib/lock"
25
25
  require_relative "lib/bridge"
26
+ require_relative "lib/agent_models"
27
+ require_relative "lib/legacy_bookend_amnesty"
26
28
 
27
29
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
28
30
  # run it hermetically (no eval, no global-constant rewriting).
@@ -41,6 +43,11 @@ class Doctor
41
43
  # (intent 60). Alias it here so the two can never drift.
42
44
  REQUIRED_FRONTMATTER_FIELDS = IntentValidator::REQUIRED_FIELDS
43
45
 
46
+ # Single source of truth for the pre-161 legacy Done-bookend amnesty list
47
+ # lives in LegacyBookendAmnesty (intent 170a). Alias it here so the two can
48
+ # never drift.
49
+ LEGACY_BOOKEND_AMNESTY = LegacyBookendAmnesty::LIST
50
+
44
51
  CLAUDE_HOOK_SCRIPTS = %w[
45
52
  plastic-session-start
46
53
  plastic-check-update
@@ -66,9 +73,11 @@ class Doctor
66
73
 
67
74
  attr_reader :plastic_home, :agents
68
75
 
69
- def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS)
76
+ def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS,
77
+ bookend_amnesty: LEGACY_BOOKEND_AMNESTY)
70
78
  @plastic_home = plastic_home
71
79
  @agents = agents
80
+ @bookend_amnesty = bookend_amnesty
72
81
  end
73
82
 
74
83
  # --- Flag parsing ---
@@ -476,7 +485,7 @@ class Doctor
476
485
  message: "#{bad_sections.size} intent file(s) have non-sanctioned ## sections",
477
486
  details: bad_sections.map { |b| "#{b[:dir]}: #{b[:issues].join(", ")}" },
478
487
  fixable: true,
479
- fix_hint: "Dispatch plastic-intent-curator to relocate each unsanctioned section into the " \
488
+ fix_hint: "Dispatch plastic-store-curating to relocate each unsanctioned section into the " \
480
489
  "intent's revisions.md via move-and-record (a missing required section is restored " \
481
490
  "or reprojected instead); see PLASTIC.md > Structural maintenance and revisions.md"
482
491
  )
@@ -577,6 +586,7 @@ class Doctor
577
586
  conflicts = [] # canonical INDEX-wins disagreement (fail)
578
587
  gaps = [] # terminal completeness gaps on immutable/legacy history (warn)
579
588
  stalled = [] # terminal but End tail unfinished (warn)
589
+ phantoms = [] # savepoint.md line(s) disk evidence contradicts (warn, never fail; intent 134)
580
590
 
581
591
  done_signal_stores(scopes).each do |store|
582
592
  index_sections_by_dir(store[:index]).each do |dirname, in_sections|
@@ -596,6 +606,24 @@ class Doctor
596
606
  "(INDEX is canonical — move it to its terminal section or revert outcome.md)"
597
607
  end
598
608
 
609
+ # Savepoint truthfulness (advisory, never fail; intent 134): a line the disk contradicts
610
+ # (a phantom milestone, a duplicate pair, or a state line with an absent prerequisite).
611
+ # Live intents auto-rebuild via plastic-intent-savepoint; terminal intents are immutable,
612
+ # so a phantom there is report-only. Composition with intent 170a: a pre-161 legacy intent
613
+ # on the frozen bookend amnesty is grandfathered here too. Its immutable history carries
614
+ # known pre-convention drift (the same class the audit-echo gap forgives), so it must not
615
+ # resurface through this advisory. The suppression is scope-qualified exactly like the
616
+ # audit-echo amnesty; every live intent and every non-amnestied terminal still fires.
617
+ phantom_lines = Bridge.savepoint_phantom_lines(dir)
618
+ phantom_id = dirname.split("--", 2).first
619
+ phantom_amnestied = terminal && @bookend_amnesty.fetch(store[:scope], []).include?(phantom_id)
620
+ if phantom_lines.any? && !phantom_amnestied
621
+ detail = phantom_lines.map { |line, reason| "#{line} (#{reason})" }.join("; ")
622
+ scope_note = terminal ? "terminal in INDEX, report-only (immutable history)" : "live intent, auto-rebuildable"
623
+ phantoms << "#{label}: #{phantom_lines.size} phantom savepoint line(s) contradicted by " \
624
+ "disk, #{scope_note}: #{detail}"
625
+ end
626
+
599
627
  # Completeness gap (warn, not fail): a terminal intent whose outcome.md is
600
628
  # missing/placeholder. Legacy terminal intents predate this convention and
601
629
  # are immutable, so this is advisory, never breakage.
@@ -608,11 +636,18 @@ class Doctor
608
636
  next unless terminal
609
637
 
610
638
  # Audit echo (weakest signal, D1): the savepoint should carry a
611
- # `Done delivered|abandoned` line. Missing on legacy history -> advisory gap.
639
+ # `Done delivered|abandoned` line. Missing on legacy history -> advisory
640
+ # gap, unless the (scope, id) is on the frozen pre-161 amnesty list
641
+ # (intent 170a). The amnesty is scope-qualified: an id on the list for
642
+ # one scope does not grandfather the same id in another scope.
612
643
  savepoint = File.join(dir, "savepoint.md")
613
644
  if File.exist?(savepoint) && File.read(savepoint) !~ /\bDone\b.*\b(delivered|abandoned)\b/
614
- gaps << "#{label}: terminal in INDEX but savepoint.md has no " \
615
- "`Done delivered|abandoned` line (audit echo missing)"
645
+ id = dirname.split("--", 2).first
646
+ amnestied = @bookend_amnesty.fetch(store[:scope], []).include?(id)
647
+ unless amnestied
648
+ gaps << "#{label}: terminal in INDEX but savepoint.md has no " \
649
+ "`Done delivered|abandoned` line (audit echo missing)"
650
+ end
616
651
  end
617
652
 
618
653
  # Stalled completion: the End tail never released the delivery lock, so the
@@ -660,7 +695,7 @@ class Doctor
660
695
  "(outcome.md or the savepoint Done echo is missing; advisory on immutable history)",
661
696
  details: gaps, fixable: true,
662
697
  fix_hint: "For live terminals, author a real outcome.md with the `disposition:` header via the " \
663
- "completion/abandon path (plastic-auto or plastic-intent-curator) and stamp the terminal " \
698
+ "completion/abandon path (plastic-auto or plastic-store-curating) and stamp the terminal " \
664
699
  "savepoint. Legacy terminal intents are immutable, so pre-convention gaps stay advisory."
665
700
  )
666
701
  end
@@ -682,6 +717,26 @@ class Doctor
682
717
  )
683
718
  end
684
719
 
720
+ # savepoint_truthful: advisory only, never fails the run (intent 134). A phantom line on a
721
+ # live intent auto-rebuilds; on terminal (immutable) history it stays report-only.
722
+ if phantoms.empty?
723
+ checks << check(
724
+ category: "done_signals", name: "savepoint_truthful", status: "pass",
725
+ message: "No savepoint.md lines are contradicted by disk (phantom-line check clean)"
726
+ )
727
+ else
728
+ checks << check(
729
+ category: "done_signals", name: "savepoint_truthful", status: "warn",
730
+ message: "#{phantoms.size} intent#{phantoms.size == 1 ? "" : "s"} carry savepoint.md " \
731
+ "line(s) contradicted by disk (advisory; terminal history stays report-only)",
732
+ details: phantoms, fixable: true,
733
+ fix_hint: "For a live (Active) intent, run plastic-intent-savepoint to rebuild via " \
734
+ "Bridge.rebuild_savepoint. Terminal (Completed/Abandoned) intents are immutable: " \
735
+ "a phantom there stays advisory unless an explicit human grant authorizes the " \
736
+ "124a manual Done-bookend repair."
737
+ )
738
+ end
739
+
685
740
  checks
686
741
  end
687
742
 
@@ -882,7 +937,7 @@ class Doctor
882
937
  checks << graph_finding_check(
883
938
  "graph_i4_danglers", i4,
884
939
  "Every sources/chain id resolves to a real intent (I4)",
885
- "Dispatch plastic-intent-curator to record the dangling sources/chain edge as a " \
940
+ "Dispatch plastic-store-curating to record the dangling sources/chain edge as a " \
886
941
  "broken-source/broken-chain move-and-record entry in the intent's revisions.md (see " \
887
942
  "PLASTIC.md > Structural maintenance and revisions.md), or restore the missing intent"
888
943
  )
@@ -1052,6 +1107,11 @@ class Doctor
1052
1107
  # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
1053
1108
  checks << flat_skills_check(agent_dir, "--claude")
1054
1109
 
1110
+ # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
1111
+ # e.g. an old-name copy after a rename; intent 158a AC15)
1112
+ stray_check = stray_skills_check(agent_dir, "--claude", File.join(agent_dir, "plastic", "manifest.json"))
1113
+ checks << stray_check if stray_check
1114
+
1055
1115
  # agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
1056
1116
  checks << flat_agents_check(agent_dir, "--claude")
1057
1117
 
@@ -1078,6 +1138,43 @@ class Doctor
1078
1138
  end
1079
1139
  end
1080
1140
 
1141
+ # Backstop for a rename or removal (intent 158a, AC15): an installed plastic-*
1142
+ # skill directory under agent_dir/skills that has NO corresponding entry in the
1143
+ # current install manifest is a stray (e.g. a leftover old-name copy the
1144
+ # install/update prune should have removed, or one it never saw because the
1145
+ # manifest predates it). Complements flat_skills_check (which only confirms at
1146
+ # least one skill exists). Defers to the manifest check when the manifest itself
1147
+ # is missing or malformed, so the two checks never double-report the same gap.
1148
+ def stray_skills_check(agent_dir, installer_flag, manifest_path)
1149
+ skills_root = File.join(agent_dir, "skills")
1150
+ manifest = read_json_safe(manifest_path)
1151
+ files = manifest.is_a?(Hash) ? manifest["files"] : nil
1152
+ return nil unless files.is_a?(Hash)
1153
+
1154
+ installed = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
1155
+ .map { |f| File.basename(File.dirname(f)) }
1156
+ tracked = files.keys
1157
+ .select { |p| p.start_with?("#{skills_root}/") }
1158
+ .map { |p| p.sub("#{skills_root}/", "").split("/").first }
1159
+ .uniq
1160
+
1161
+ strays = (installed - tracked).sort
1162
+
1163
+ if strays.empty?
1164
+ check(
1165
+ category: "agent_registration", name: "stray_skills", status: "pass",
1166
+ message: "No stray plastic-* skill directories in #{tilde(skills_root)}"
1167
+ )
1168
+ else
1169
+ check(
1170
+ category: "agent_registration", name: "stray_skills", status: "warn",
1171
+ message: "#{strays.size} installed skill dir(s) not in the current manifest (stray, e.g. a leftover old-name copy)",
1172
+ details: strays,
1173
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
1174
+ )
1175
+ end
1176
+ end
1177
+
1081
1178
  # Auto-mode role agents install as <dir>/agents/plastic-*.md. Pass if at least
1082
1179
  # one such role file is present (the installer syncs agents/ on every install).
1083
1180
  def flat_agents_check(agent_dir, installer_flag)
@@ -1104,6 +1201,12 @@ class Doctor
1104
1201
 
1105
1202
  # For codex/hermes: just check skills exist (no settings.json hooks)
1106
1203
  checks << flat_skills_check(agent_dir, "--#{agent_key}")
1204
+
1205
+ # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
1206
+ # e.g. an old-name copy after a rename; intent 158a AC15)
1207
+ stray_check = stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic-manifest.json"))
1208
+ checks << stray_check if stray_check
1209
+
1107
1210
  checks << flat_agents_check(agent_dir, "--#{agent_key}")
1108
1211
 
1109
1212
  checks
@@ -1223,9 +1326,85 @@ class Doctor
1223
1326
  end
1224
1327
  end
1225
1328
 
1329
+ checks += check_agent_model_drift(agent_key)
1330
+
1226
1331
  checks
1227
1332
  end
1228
1333
 
1334
+ # agent_model_drift - advisory (never fail) config-vs-frontmatter comparison
1335
+ # for every installed <agent_dir>/agents/plastic-*.md file (intent 170, D1).
1336
+ # Resolution mirrors read-config's own precedence (project override -> global
1337
+ # override -> shipped AgentModels::TIER_DEFAULTS), reusing AgentModels'
1338
+ # `override_map` directly so this stays hermetically DI-testable (no
1339
+ # shell-out to `read-config`, no ENV/global seam). There is no live project
1340
+ # scope at doctor-run time for a globally-installed agent file, so the
1341
+ # project layer is empty here; the resolver still supports one, matching the
1342
+ # same `override_map(project_config:, global_config:)` shape the installer
1343
+ # already calls.
1344
+ #
1345
+ # Classification per installed agent basename:
1346
+ # - no `agents.models.<basename>` override AND frontmatter == resolved
1347
+ # default -> pass (clean)
1348
+ # - no override AND frontmatter != resolved default -> warn (real drift)
1349
+ # - an override IS configured -> pass/informational, LISTED as a
1350
+ # sanctioned override, regardless of whether frontmatter matches (that
1351
+ # mismatch is the override working, e.g. `plastic-brainstorming: fable`)
1352
+ # - zero installed agent files -> pass (nothing to check)
1353
+ # This check never returns "fail".
1354
+ def check_agent_model_drift(agent_key)
1355
+ agent_config = agents[agent_key]
1356
+ return [] unless agent_config
1357
+
1358
+ agents_dir = File.join(agent_config[:dir], "agents")
1359
+ installed = Dir.glob(File.join(agents_dir, "plastic-*.md")).sort
1360
+
1361
+ if installed.empty?
1362
+ return [check(
1363
+ category: "core_files", name: "agent_model_drift", status: "pass",
1364
+ message: "No installed plastic-* agent files to check for model drift"
1365
+ )]
1366
+ end
1367
+
1368
+ global_config = load_yaml_safe(File.join(plastic_home, "config.yml")) || {}
1369
+ overrides = AgentModels.override_map(project_config: {}, global_config: global_config)
1370
+
1371
+ drifted = []
1372
+ sanctioned = []
1373
+
1374
+ installed.each do |path|
1375
+ basename = File.basename(path, ".md")
1376
+ installed_model = (parse_frontmatter(path) || {})["model"]
1377
+ resolved_default = AgentModels::TIER_DEFAULTS[basename]
1378
+ override = overrides[basename]
1379
+
1380
+ if override
1381
+ sanctioned << "#{basename}: frontmatter=#{installed_model.inspect}, sanctioned override=#{override.inspect}"
1382
+ elsif installed_model != resolved_default
1383
+ drifted << "#{basename}: frontmatter=#{installed_model.inspect}, resolved default=#{resolved_default.inspect}"
1384
+ end
1385
+ end
1386
+
1387
+ if drifted.empty?
1388
+ message = if sanctioned.empty?
1389
+ "No agent-model drift (#{installed.size} installed agent(s) match the resolved default)"
1390
+ else
1391
+ "No unsanctioned agent-model drift; #{sanctioned.size} sanctioned override(s) in effect"
1392
+ end
1393
+ [check(
1394
+ category: "core_files", name: "agent_model_drift", status: "pass",
1395
+ message: message,
1396
+ details: sanctioned
1397
+ )]
1398
+ else
1399
+ [check(
1400
+ category: "core_files", name: "agent_model_drift", status: "warn",
1401
+ message: "#{drifted.size} installed agent(s) have unsanctioned model drift vs the config-resolved default",
1402
+ details: drifted + sanctioned,
1403
+ fixable: false
1404
+ )]
1405
+ end
1406
+ end
1407
+
1229
1408
  # --- Check category: manifest sync (binary core integrity) ---
1230
1409
 
1231
1410
  # Verify, for BOTH the global manifest and the agent-side manifest, that every
@@ -1405,7 +1584,7 @@ class Doctor
1405
1584
  checks << check(
1406
1585
  category: "project_stores", name: "project_yml_exists", status: "warn",
1407
1586
  message: "project.yml missing for project '#{slug}'",
1408
- fixable: true, fix_hint: "Create project.yml from template — see plastic-creating-project"
1587
+ fixable: true, fix_hint: "Create project.yml from template — see plastic-project-creating"
1409
1588
  )
1410
1589
  end
1411
1590
 
@@ -1753,4 +1932,4 @@ class Doctor
1753
1932
 
1754
1933
  end
1755
1934
 
1756
- Doctor.new.cli(ARGV) if $PROGRAM_NAME == __FILE__
1935
+ Doctor.new(plastic_home: ENV.fetch("PLASTIC_HOME") { Doctor::DEFAULT_PLASTIC_HOME }).cli(ARGV) if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # end-intent (intent 161) - the mechanical core of the single owned Done
6
+ # procedure (D2 steps 1-4). Pure with respect to worktrees, locks, and QMD:
7
+ # those stay in the plastic-intent-ending skill body (steps 0 and 5-7), never
8
+ # duplicated here. Pairs new-intent's shape: one callable script instead of
9
+ # raw one-liners repeated in skill prose (the failure mode this intent fixes).
10
+ #
11
+ # Usage:
12
+ # end-intent --store <store_path> --id <intent_id> --disposition delivered|abandoned \
13
+ # [--index <path>] [--outcome-summary <text>] [--index-note <text>] \
14
+ # [--no-commit] [--dry-run]
15
+ #
16
+ # Steps performed (in order), all idempotent:
17
+ # 1. Guard outcome.md (exit 2, authors nothing, on any failure), then stamp
18
+ # the intent file's `## Outcome` section with --outcome-summary if given.
19
+ # 2. Move the intent's INDEX.md line from `## Active` into `## Completed`
20
+ # (delivered) or `## Abandoned` (abandoned), dated today, appending
21
+ # --index-note (if given) after the date so the terminal entry stays
22
+ # the store's existing rich Completed/Abandoned shape rather than
23
+ # thinning to bare id+date.
24
+ # 3. Append the savepoint `Done` bookend (Bridge.append_terminal_savepoint).
25
+ # 4. Commit the store repo, unless --no-commit.
26
+ #
27
+ # Exit codes: 0 (ok), 1 (usage/resolution failure), 2 (outcome.md guard refused).
28
+
29
+ require "fileutils"
30
+ require "date"
31
+ require "open3"
32
+ require_relative "lib/bridge"
33
+ require_relative "lib/intent_validator"
34
+
35
+ DISPOSITIONS = %w[delivered abandoned].freeze
36
+
37
+ # The store-line separator this script must reproduce is a real em dash
38
+ # (U+2014), the existing INDEX.md convention (store files are exempt from the
39
+ # no-em-dash shipped-file rule). Built from the codepoint, not a literal byte
40
+ # in this source file, so the script's own source stays em-dash free.
41
+ EM_DASH = "\u2014"
42
+
43
+ # --- Explicit flag parsing (no eval, no global injection) -------------------
44
+
45
+ def parse_args(argv)
46
+ opts = {
47
+ store: nil, id: nil, disposition: nil, index: nil,
48
+ outcome_summary: nil, index_note: nil, no_commit: false, dry_run: false,
49
+ }
50
+ i = 0
51
+ while i < argv.length
52
+ arg = argv[i]
53
+ case arg
54
+ when "--store" then opts[:store] = argv[i += 1]
55
+ when "--id" then opts[:id] = argv[i += 1]
56
+ when "--disposition" then opts[:disposition] = argv[i += 1]
57
+ when "--index" then opts[:index] = argv[i += 1]
58
+ when "--outcome-summary" then opts[:outcome_summary] = argv[i += 1]
59
+ when "--index-note" then opts[:index_note] = argv[i += 1]
60
+ when "--no-commit" then opts[:no_commit] = true
61
+ when "--dry-run" then opts[:dry_run] = true
62
+ else
63
+ usage_abort("unknown argument #{arg.inspect}")
64
+ end
65
+ i += 1
66
+ end
67
+ opts
68
+ end
69
+
70
+ def usage
71
+ "usage: end-intent --store <store_path> --id <intent_id> " \
72
+ "--disposition delivered|abandoned [--index <path>] " \
73
+ "[--outcome-summary <text>] [--index-note <text>] [--no-commit] [--dry-run]"
74
+ end
75
+
76
+ def usage_abort(message)
77
+ warn "end-intent: #{message}"
78
+ warn usage
79
+ exit 1
80
+ end
81
+
82
+ def expand(path)
83
+ File.expand_path(path.to_s.sub(/\A~/, Dir.home))
84
+ end
85
+
86
+ # Resolve the single "<store>/<id>--*" directory, or abort (usage error: exit 1).
87
+ def resolve_intent_dir(store, id)
88
+ matches = Dir.glob(File.join(store, "#{id}--*")).select { |d| File.directory?(d) }
89
+ usage_abort("no intent directory matches #{id}--* under #{store}") if matches.empty?
90
+ if matches.length > 1
91
+ usage_abort("ambiguous id #{id.inspect}: #{matches.length} matching directories under #{store}")
92
+ end
93
+ matches.first
94
+ end
95
+
96
+ # --- outcome.md guard (D2 step 1a) ------------------------------------------
97
+ #
98
+ # Returns nil when the guard PASSES, or a reason String to refuse with exit 2.
99
+ # Read-only: authors nothing regardless of outcome. Refuses a missing file, a
100
+ # still-placeholder file (first line is the sentinel), or a disposition that
101
+ # does not exactly equal the requested one (this is what naturally refuses a
102
+ # freshly scaffolded outcome.md, whose disposition is the literal
103
+ # "delivered|abandoned" string, not one of the two real values).
104
+ def outcome_guard_reason(intent_dir, disposition)
105
+ path = File.join(intent_dir, "outcome.md")
106
+ return "outcome.md is missing at #{path}" unless File.exist?(path)
107
+
108
+ content = File.read(path)
109
+ first_line = content.each_line.first.to_s.chomp
110
+ if first_line == Bridge::PLACEHOLDER_SENTINEL
111
+ return "outcome.md is still the scaffold placeholder (first line is the sentinel)"
112
+ end
113
+
114
+ fm = IntentValidator.parse_frontmatter_text(content)
115
+ actual = fm.is_a?(Hash) ? fm["disposition"] : nil
116
+ unless DISPOSITIONS.include?(actual) && actual == disposition
117
+ return "outcome.md frontmatter disposition is #{actual.inspect}, expected #{disposition.inspect}"
118
+ end
119
+
120
+ nil
121
+ end
122
+
123
+ # --- intent-file `## Outcome` summary stamp (D2 step 1b) --------------------
124
+ #
125
+ # Replace the body of the intent file's `## Outcome` section with `summary`,
126
+ # leaving every other section and the frontmatter untouched. No-op (returns
127
+ # content unchanged) when summary is blank or the section is not found.
128
+ def stamp_outcome_summary(content, summary)
129
+ return content if summary.nil? || summary.to_s.strip.empty?
130
+
131
+ lines = content.lines
132
+ start = lines.index { |l| l.rstrip == "## Outcome" }
133
+ return content if start.nil?
134
+
135
+ stop = start + 1
136
+ stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
137
+
138
+ body = "#{summary.to_s.strip}\n"
139
+ replacement = (stop < lines.length) ? [body, "\n"] : [body]
140
+ (lines[0..start] + replacement + lines[stop..]).join
141
+ end
142
+
143
+ # --- INDEX.md terminal move (D2 step 2) -------------------------------------
144
+
145
+ ACTIVE_LINE_RE = /\A- \[(\S+)\s+#{Regexp.escape(EM_DASH)}\s+(.*?)\]\(([^)]+)\)/.freeze
146
+
147
+ def index_target_heading(disposition)
148
+ disposition == "delivered" ? "## Completed" : "## Abandoned"
149
+ end
150
+
151
+ # Index of the first `## ` heading at or after heading_idx + 1, or lines.length
152
+ # at EOF (the section's exclusive end bound).
153
+ def section_stop(lines, heading_idx)
154
+ stop = heading_idx + 1
155
+ stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
156
+ stop
157
+ end
158
+
159
+ def active_line_id(line)
160
+ m = line.match(ACTIVE_LINE_RE)
161
+ m && m[1]
162
+ end
163
+
164
+ def section_contains_id?(lines, heading_idx, id)
165
+ stop = section_stop(lines, heading_idx)
166
+ (heading_idx + 1...stop).any? { |i| active_line_id(lines[i]) == id }
167
+ end
168
+
169
+ # Remove the entry at entry_idx from the `## Active` section. When no real
170
+ # entries remain, install the canonical "_(none)_" placeholder (the same
171
+ # empty-state convention INDEX.md already uses).
172
+ def remove_active_entry(lines, active_start, entry_idx)
173
+ new_lines = lines.dup
174
+ new_lines.delete_at(entry_idx)
175
+ stop = section_stop(new_lines, active_start)
176
+ remaining = (active_start + 1...stop).reject { |i| new_lines[i].strip.empty? }
177
+ if remaining.empty?
178
+ # Keep the blank-line separator before the next heading (if any); only
179
+ # collapse the section body to the placeholder, never the spacing.
180
+ placeholder = (stop < new_lines.length) ? ["_(none)_\n", "\n"] : ["_(none)_\n"]
181
+ new_lines[(active_start + 1)...stop] = placeholder
182
+ end
183
+ new_lines
184
+ end
185
+
186
+ # Insert new_entry into the target terminal section: replace the "_(none)_"
187
+ # placeholder if that is the ONLY non-blank content the section holds (a
188
+ # trailing blank line before the next heading is common and must not
189
+ # defeat this check), otherwise prepend (newest-first, matching the
190
+ # existing Completed/Abandoned convention).
191
+ def insert_terminal_entry(lines, target_start, new_entry)
192
+ new_lines = lines.dup
193
+ stop = section_stop(new_lines, target_start)
194
+ body_range = (target_start + 1...stop)
195
+ placeholder_idx = body_range.find { |i| new_lines[i].strip == "_(none)_" }
196
+ non_blank = body_range.reject { |i| new_lines[i].strip.empty? }
197
+ if placeholder_idx && non_blank == [placeholder_idx]
198
+ new_lines[placeholder_idx] = new_entry
199
+ else
200
+ new_lines.insert(target_start + 1, new_entry)
201
+ end
202
+ new_lines
203
+ end
204
+
205
+ # Move the intent's `## Active` line into the terminal section. Returns the
206
+ # new INDEX.md content, or the original content unchanged when there is
207
+ # nothing to do (idempotent: a second run finds no Active entry and the
208
+ # terminal section already carries it, so it is a clean no-op). `index_note`
209
+ # (D2 "rich entry", review F3), when given, is appended verbatim after the
210
+ # date with a single leading space: content authoring (mode/tier, what
211
+ # shipped, suite result, and so on) stays with the caller, this only places
212
+ # the text where the store's existing Completed/Abandoned lines carry it.
213
+ def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
214
+ target_heading = index_target_heading(disposition)
215
+ lines = content.lines
216
+
217
+ active_start = lines.index { |l| l.rstrip == "## Active" }
218
+ target_start = lines.index { |l| l.rstrip == target_heading }
219
+ return content if active_start.nil? || target_start.nil?
220
+
221
+ already_in_target = section_contains_id?(lines, target_start, id)
222
+ active_stop = section_stop(lines, active_start)
223
+ entry_idx = (active_start + 1...active_stop).find { |i| active_line_id(lines[i]) == id }
224
+
225
+ return content if entry_idx.nil? # already moved (or never present): no-op either way
226
+
227
+ m = lines[entry_idx].match(ACTIVE_LINE_RE)
228
+ title, link = m[2], m[3]
229
+ note_suffix = (index_note && !index_note.to_s.strip.empty?) ? " #{index_note.to_s.strip}" : ""
230
+ new_entry = "- [#{id} #{EM_DASH} #{title}](#{link}) #{EM_DASH} #{today}#{note_suffix}\n"
231
+
232
+ lines = remove_active_entry(lines, active_start, entry_idx)
233
+ target_start = lines.index { |l| l.rstrip == target_heading }
234
+ lines = insert_terminal_entry(lines, target_start, new_entry) unless already_in_target
235
+
236
+ lines.join
237
+ end
238
+
239
+ # --- store auto-commit (D2 step 4) ------------------------------------------
240
+
241
+ def git_toplevel(dir)
242
+ out, _err, status = Open3.capture3("git", "-C", dir, "rev-parse", "--show-toplevel")
243
+ return nil unless status.success?
244
+ top = out.strip
245
+ top.empty? ? nil : top
246
+ end
247
+
248
+ # Best-effort store commit: never raises, never blocks the mechanical close.
249
+ # Returns true when a commit was created, false otherwise (not a git repo,
250
+ # nothing to commit, or the commit failed). Pins a local committer identity so
251
+ # the commit succeeds even with no ambient git config (hermetic).
252
+ def store_commit(store, id, disposition)
253
+ root = git_toplevel(store)
254
+ return false if root.nil?
255
+
256
+ Open3.capture3("git", "-C", root, "add", "-A")
257
+ _out, _err, status = Open3.capture3(
258
+ "git", "-C", root,
259
+ "-c", "user.name=Plastic", "-c", "user.email=plastic@localhost",
260
+ "commit", "--quiet", "-m", "chore: complete intent #{id} (#{disposition})"
261
+ )
262
+ status.success?
263
+ end
264
+
265
+ # --- main --------------------------------------------------------------------
266
+
267
+ def main(argv)
268
+ opts = parse_args(argv)
269
+ usage_abort("--store is required") if opts[:store].nil? || opts[:store].empty?
270
+ usage_abort("--id is required") if opts[:id].nil? || opts[:id].empty?
271
+ unless DISPOSITIONS.include?(opts[:disposition])
272
+ usage_abort("--disposition must be one of #{DISPOSITIONS.join('|')}")
273
+ end
274
+
275
+ store = expand(opts[:store])
276
+ usage_abort("store dir does not exist: #{store}") unless Dir.exist?(store)
277
+
278
+ id = opts[:id]
279
+ disposition = opts[:disposition]
280
+ intent_dir = resolve_intent_dir(store, id)
281
+ index_path = opts[:index] ? expand(opts[:index]) : File.join(File.dirname(store), "INDEX.md")
282
+ today = Date.today.iso8601
283
+
284
+ reason = outcome_guard_reason(intent_dir, disposition)
285
+ if reason
286
+ warn "end-intent: #{reason}"
287
+ exit 2
288
+ end
289
+
290
+ if opts[:dry_run]
291
+ puts "end-intent: dry run for #{File.basename(intent_dir)} (#{disposition})"
292
+ puts " would stamp ## Outcome: #{opts[:outcome_summary].inspect}" if opts[:outcome_summary]
293
+ puts " would move INDEX.md entry from ## Active to #{index_target_heading(disposition)} (#{index_path})"
294
+ puts " would append index note: #{opts[:index_note].inspect}" if opts[:index_note]
295
+ puts " would append savepoint bookend: Done #{disposition}"
296
+ puts(opts[:no_commit] ? " would skip store commit (--no-commit)" : " would commit the store repo")
297
+ exit 0
298
+ end
299
+
300
+ # 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
301
+ if opts[:outcome_summary]
302
+ intent_file = Bridge.intent_file(intent_dir)
303
+ if File.exist?(intent_file)
304
+ original = File.read(intent_file)
305
+ updated = stamp_outcome_summary(original, opts[:outcome_summary])
306
+ File.write(intent_file, updated) if updated != original
307
+ end
308
+ end
309
+
310
+ # 2. INDEX.md terminal move.
311
+ if File.exist?(index_path)
312
+ original_index = File.read(index_path)
313
+ updated_index = move_index_to_terminal(original_index, id, disposition, today: today,
314
+ index_note: opts[:index_note])
315
+ File.write(index_path, updated_index) if updated_index != original_index
316
+ else
317
+ warn "end-intent: INDEX.md not found at #{index_path}, skipping the terminal move"
318
+ end
319
+
320
+ # 3. Savepoint Done bookend (idempotent).
321
+ Bridge.append_terminal_savepoint(intent_dir, disposition)
322
+
323
+ # 4. Store auto-commit, unless --no-commit.
324
+ store_commit(store, id, disposition) unless opts[:no_commit]
325
+
326
+ puts intent_dir
327
+ exit 0
328
+ end
329
+
330
+ main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -39,7 +39,7 @@ if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
39
39
  end
40
40
 
41
41
  # --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
42
- bridge_data = Bridge.discover_bridge(session: session, cwd: file_path)
42
+ bridge_data = Bridge.discover_bridge(session: session, cwd: file_path, edited_path: file_path)
43
43
  exit 0 unless bridge_data
44
44
 
45
45
  reason = Bridge.code_gate_decision(bridge_data, file_path) ||
@@ -20,8 +20,8 @@ exit 0 unless status.success? && !cockpit.strip.empty?
20
20
 
21
21
  context = cockpit.rstrip +
22
22
  "\n\nFollow the plastic-continuing skill workflow to resume: " \
23
- "if active intents exist, read their state and resume; otherwise offer the " \
24
- "Value×Effort matrix items above, surfacing any stale (Nd) / triage intents."
23
+ "if active intents exist, read their state and resume; otherwise surface the " \
24
+ "most-valuable next work and any stale or triage intents from the board above."
25
25
 
26
26
  payload = {
27
27
  "hookSpecificOutput" => {
@@ -67,7 +67,7 @@ result =
67
67
  # the validator.
68
68
  unless File.exist?(abs)
69
69
  block!("PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
70
- "create intents via new-intent / plastic-creating-intent.")
70
+ "create intents via new-intent / plastic-intent-creating.")
71
71
  end
72
72
  current = File.read(abs)
73
73
  unless current.include?(old_string)
@@ -91,5 +91,5 @@ exit 0 if result[:ok]
91
91
 
92
92
  $stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
93
93
  result[:errors].each { |e| $stderr.puts " #{e}" }
94
- $stderr.puts "Create intents via new-intent / plastic-creating-intent; do not hand-author them."
94
+ $stderr.puts "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
95
95
  exit 2