@zalom/plastic 1.1.0 → 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 (54) hide show
  1. package/PLASTIC.md +4 -2
  2. package/README.md +3 -3
  3. package/agents/plastic-enforcer.md +5 -2
  4. package/agents/plastic-future-intent-researcher.md +1 -1
  5. package/hooks/check-update +1 -1
  6. package/hooks/continue +1 -1
  7. package/package.json +1 -1
  8. package/scripts/dashboard.rb +29 -24
  9. package/scripts/doctor.rb +136 -5
  10. package/scripts/hook-continue +3 -3
  11. package/scripts/install.rb +2 -1
  12. package/scripts/lib/bridge.rb +81 -0
  13. package/scripts/lib/dashboard_banner.rb +8 -9
  14. package/scripts/lib/installer_core.rb +10 -3
  15. package/scripts/lib/legacy_bookend_amnesty.rb +35 -0
  16. package/scripts/lib/release_guard.rb +62 -0
  17. package/scripts/lib/roadmap_queue.rb +285 -0
  18. package/scripts/lib/roadmap_savepoint.rb +213 -0
  19. package/scripts/lib/worktree.rb +21 -0
  20. package/scripts/new-intent +1 -0
  21. package/scripts/read-config +3 -3
  22. package/scripts/roadmap-next +44 -0
  23. package/scripts/roadmap-savepoint +64 -0
  24. package/skills/auto/SKILL.md +22 -4
  25. package/skills/continuing/SKILL.md +34 -0
  26. package/skills/continuing/evals/evals.json +91 -0
  27. package/skills/dashboard/SKILL.md +17 -14
  28. package/skills/dashboard/references/classification.md +3 -3
  29. package/skills/dashboard/templates/dashboard-global.md +8 -23
  30. package/skills/dashboard/templates/dashboard-project.md +7 -26
  31. package/skills/doctor/SKILL.md +1 -1
  32. package/skills/install/SKILL.md +10 -10
  33. package/skills/intent-continuing/SKILL.md +26 -68
  34. package/skills/intent-continuing/evals/evals.json +26 -26
  35. package/skills/intent-continuing/references/context-management.md +15 -19
  36. package/skills/intent-savepoint/SKILL.md +12 -0
  37. package/skills/intent-starting/evals/evals.json +1 -1
  38. package/skills/project-continuing/SKILL.md +104 -0
  39. package/skills/project-continuing/evals/evals.json +100 -0
  40. package/skills/project-continuing/references/board-fill.md +33 -0
  41. package/skills/releasing/SKILL.md +48 -0
  42. package/skills/releasing/references/release-lines.md +105 -0
  43. package/skills/roadmap/SKILL.md +7 -1
  44. package/skills/roadmap/references/file-format.md +30 -1
  45. package/skills/roadmap/references/operations.md +26 -6
  46. package/skills/roadmap-continuing/SKILL.md +85 -0
  47. package/skills/roadmap-continuing/evals/evals.json +82 -0
  48. package/skills/roadmap-continuing/references/liveness-ranking.md +56 -0
  49. package/skills/skill-evaluating/evals/evals.json +1 -1
  50. package/skills/tutorial/references/track-2-auto.md +1 -1
  51. package/skills/uninstall/SKILL.md +2 -2
  52. package/skills/update/SKILL.md +2 -2
  53. package/templates/config.yml +2 -1
  54. package/templates/index.md +4 -1
package/PLASTIC.md CHANGED
@@ -167,8 +167,10 @@ and Exec, spins up the team above, and works the dashboard's dispatchable queue.
167
167
  `human_only` (intents that need a person); auto mode consumes the former.
168
168
 
169
169
  **Model contract.** Every agent in `agents/*.md` pins an explicit Claude Code model alias in
170
- its own frontmatter: `opus`, `sonnet`, or `haiku`. Never `inherit`, never Fable. Aliases track
171
- "latest per tier" so no Plastic release is required to advance a tier. The tier by role:
170
+ its own frontmatter: `opus`, `sonnet`, or `haiku`. Never `inherit`, never Fable by default,
171
+ unless an explicit `agents.models.<name>` config override names Fable for that role, in which
172
+ case the override is honored as written. Aliases track "latest per tier" so no Plastic release
173
+ is required to advance a tier. The tier by role:
172
174
  `plastic-enforcer`, `plastic-brainstorming`, `plastic-planner` are `opus`;
173
175
  `plastic-spec-specialist`, `plastic-executor`, `plastic-intent-curator`,
174
176
  `plastic-future-intent-researcher`, `plastic-intent-discovery` are `sonnet`.
package/README.md CHANGED
@@ -136,8 +136,8 @@ Walk this once in
136
136
  | Family | Commands |
137
137
  | ------ | -------- |
138
138
  | Mode | `plastic-tutorial`, `plastic-auto` |
139
- | Intent | `plastic-intent-creating`, `plastic-intent-starting`, `plastic-intent-brainstorming`, `plastic-intent-grilling`, `plastic-intent-speccing`, `plastic-intent-planning`, `plastic-intent-executing`, `plastic-intent-ending`, `plastic-intent-continuing`, `plastic-intent-researching` |
140
- | Project and delivery | `plastic-project-creating`, `plastic-roadmap`, `plastic-releasing` |
139
+ | Intent | `plastic-continuing`, `plastic-intent-creating`, `plastic-intent-starting`, `plastic-intent-brainstorming`, `plastic-intent-grilling`, `plastic-intent-speccing`, `plastic-intent-planning`, `plastic-intent-executing`, `plastic-intent-ending`, `plastic-intent-continuing`, `plastic-intent-researching` |
140
+ | Project and delivery | `plastic-project-creating`, `plastic-project-continuing`, `plastic-roadmap`, `plastic-roadmap-continuing`, `plastic-releasing` |
141
141
  | Skill | `plastic-skill-creating`, `plastic-skill-evaluating` |
142
142
  | Product | `plastic-install`, `plastic-update`, `plastic-uninstall`, `plastic-rollback`, `plastic-doctor`, `plastic-humanizer` |
143
143
 
@@ -165,7 +165,7 @@ Plastic is developed through Plastic.
165
165
  The roadmap, intent history, plans, decisions, and outcomes
166
166
  behind releases are part of the repository, not a hidden process.
167
167
 
168
- See the current [roadmap](ROADMAP.md) and [changelog](CHANGELOG.md).
168
+ [changelog](CHANGELOG.md).
169
169
 
170
170
  ## Documentation
171
171
 
@@ -12,7 +12,9 @@ You are the Plastic Enforcer. You ARE the auto orchestrator, spanning the whole
12
12
  orchestrating main session on the best available thinking model (Fable, Opus, or whatever
13
13
  supersedes them) for the sharpest gating and synthesis. This is advice only: it changes no
14
14
  behavior and blocks nothing if ignored. It concerns the human's MAIN session; dispatched
15
- subagents keep their pinned tier and never resolve to Fable.
15
+ subagents keep their pinned tier and never resolve to Fable, unless an explicit
16
+ `agents.models.<name>` config override names Fable for that role, in which case the override
17
+ is honored as written.
16
18
 
17
19
  ## Your Responsibilities
18
20
 
@@ -42,7 +44,8 @@ dispatch also resolve the target agent's model through the config chain (`read-c
42
44
  agents.models.<basename> --project <repo>`: project override, then global, then the shipped
43
45
  tier default) and pass it explicitly as the dispatch call's model parameter, alongside the
44
46
  spawn-preamble live-state injection. Never rely on the dispatched role's frontmatter alone. A
45
- resolved subagent model is never Fable.
47
+ resolved subagent model is never Fable, unless an explicit `agents.models.<name>` config
48
+ override names Fable for that role, in which case the override is honored as written.
46
49
  5. **Gate each handoff** — check each stage deliverable against its exit criteria before handing to the next stage
47
50
  6. **Run the final review** — at the final gate, dispatch an INDEPENDENT reviewer subagent (not a sixth standing role)
48
51
 
@@ -31,4 +31,4 @@ You are the Plastic Future Intent Researcher. Your role is to pick up parked fut
31
31
  - You never modify `## Insights` or `## Outcome` sections — those belong to the worker
32
32
  - You use Read, WebSearch, WebFetch, and Bash (read-only grep/find) for research
33
33
  - You never change status fields — status is convention-derived from INDEX.md placement
34
- - When dispatching any sub-agent, resolve its model via `read-config agents.models.<basename> --project <repo>` and pass it explicitly at dispatch, never relying on inherited frontmatter; a resolved subagent model is never Fable
34
+ - When dispatching any sub-agent, resolve its model via `read-config agents.models.<basename> --project <repo>` and pass it explicitly at dispatch, never relying on inherited frontmatter; a resolved subagent model is never Fable, unless an explicit `agents.models.<name>` config override names Fable for that role, in which case the override is honored as written
@@ -5,7 +5,7 @@
5
5
  # upgrade target instead of blindly trusting the `latest` tag.
6
6
 
7
7
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
- PLASTIC_HOME="$HOME/.plastic"
8
+ PLASTIC_HOME="${PLASTIC_HOME:-$HOME/.plastic}"
9
9
  CACHE_DIR="$PLASTIC_HOME/.cache"
10
10
  CACHE_FILE="$CACHE_DIR/update-check.json"
11
11
  VERSION_FILE="$PLASTIC_HOME/VERSION"
package/hooks/continue CHANGED
@@ -23,7 +23,7 @@ if echo "$MESSAGE" | grep -iq '\bcontinue\b'; then
23
23
  {
24
24
  "hookSpecificOutput": {
25
25
  "hookEventName": "UserPromptSubmit",
26
- "additionalContext": "PLASTIC CONTINUE The user wants to resume previous work.\n\nRead ~/.plastic/INDEX.md and follow the continuing skill workflow."
26
+ "additionalContext": "PLASTIC CONTINUE: The user wants to resume previous work.\n\nRead ~/.plastic/INDEX.md and follow the plastic-continuing skill workflow."
27
27
  }
28
28
  }
29
29
  HOOKJSON
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@ def today
33
33
  end
34
34
 
35
35
  # A generic "about a month" threshold, not tuned to any one store's item count.
36
- # Deliberately independent from plastic-intent-continuing's separate stale_threshold_days
36
+ # Deliberately independent from plastic-project-continuing's separate stale_threshold_days
37
37
  # config: that one is a proactive boot-time triage nudge, this is a board annotation.
38
38
  # Unifying the two is a follow-up, not this intent.
39
39
  STALE_DAYS = 30
@@ -355,10 +355,7 @@ LIFECYCLE_GLYPH = { "what" => "○", "why" => "◔", "how" => "◑", "exec" => "
355
355
  DISPOSITION_GLYPH = { "drive" => "▸", "defer" => "⇢", "research" => "⊙", "triage" => "⚑" }.freeze
356
356
  LEGEND = "legend ○ What ◔ Why ◑ How ◕ Exec ● Done │ ▸drive ⇢defer ⊙research ⚑triage │ ⇡unblocked"
357
357
 
358
- # Markdown board glyphs (intent 37). Quadrant signatures + per-line bullets.
359
- QUADRANT_BULLET = {
360
- "quick_win" => "⚡", "next_big" => "★", "defer" => "→", "triage" => "⚑",
361
- }.freeze
358
+ # Markdown board glyphs (intent 37). Status signature for the project active/future lists.
362
359
  STATUS_GLYPH = { "active" => "◑", "completed" => "●", "future" => "○" }.freeze
363
360
 
364
361
  # ---------------------------------------------------------------------------
@@ -375,10 +372,10 @@ end
375
372
  CELL_CAP = 6
376
373
 
377
374
  # Markdown-board caps (Task 5, D6/D7): the ASCII renderer already caps via CELL_CAP/
378
- # cap_cell, but the Markdown board's matrix_data quadrants and the project board's
375
+ # cap_cell, but the Markdown board's next_work list and the project board's
379
376
  # active/future lists had no cap and no per-line truncation, so a large store printed
380
377
  # hundreds of full-length lines. These two constants fix that on the Markdown side only.
381
- MATRIX_DATA_CAP = 8
378
+ NEXT_WORK_CAP = 8
382
379
  INTENT_LINE_MAX_CHARS = 120
383
380
 
384
381
  def matrix(records, scope_tag: false)
@@ -584,31 +581,39 @@ def intent_line(rec, bullet)
584
581
  scope: rec[:scope], line: "#{bullet} #{rec[:id]} #{text}#{note}".rstrip }
585
582
  end
586
583
 
587
- # Cap a raw record list to MATRIX_DATA_CAP entries, then map to intent_line-shaped
584
+ # Cap a raw record list to NEXT_WORK_CAP entries, then map to intent_line-shaped
588
585
  # hashes, appending a plain "+N more" line (no id, not a real record) when truncated.
589
586
  # Caps the record list first so the "+N more" entry never goes through intent_line.
590
587
  def cap_lines(list, bullet)
591
- capped = list.first(MATRIX_DATA_CAP)
588
+ capped = list.first(NEXT_WORK_CAP)
592
589
  lines = capped.map { |r| intent_line(r, bullet) }
593
- if list.size > MATRIX_DATA_CAP
590
+ if list.size > NEXT_WORK_CAP
594
591
  lines << { id: "", intent: "", created: "", bullet: bullet, scope: "",
595
- line: "#{bullet} +#{list.size - MATRIX_DATA_CAP} more" }
592
+ line: "#{bullet} +#{list.size - NEXT_WORK_CAP} more" }
596
593
  end
597
594
  lines
598
595
  end
599
596
 
600
- def matrix_data(records)
601
- cells = { "quick_win" => [], "next_big" => [], "defer" => [], "triage" => [] }
602
- research = []
603
- records.each do |r|
604
- if %w[research exploration].include?(r[:type]) then research << r
605
- else cells[r[:quadrant]] << r end
597
+ # Flat, rank-ordered "most-valuable next work" list (intent 149). Replaces the
598
+ # quadrant-keyed matrix_data with a single list ranked by the same rank_key every
599
+ # other ordering uses, capped at NEXT_WORK_CAP with a trailing "+N more" marker
600
+ # when the pool overflows. No grid glyph on the line; the prose surface supplies
601
+ # its own bullet.
602
+ def next_work(records)
603
+ ranked = records.sort_by { |r| rank_key(r) }
604
+ capped = ranked.first(NEXT_WORK_CAP)
605
+ lines = capped.map do |r|
606
+ text = r[:intent].to_s
607
+ text = "#{text[0, INTENT_LINE_MAX_CHARS]}…" if text.length > INTENT_LINE_MAX_CHARS
608
+ { id: r[:id], intent: r[:intent], scope: r[:scope], lifecycle: r[:lifecycle],
609
+ value: r[:value].to_s, disposition: r[:disposition], flags: r[:flags],
610
+ line: "#{r[:id]} #{text}" }
606
611
  end
607
- by_created_desc = ->(list) { list.sort_by { |r| invert_ts(r[:created]) } }
608
- out = {}
609
- cells.each { |q, list| out[q] = cap_lines(by_created_desc.call(list), QUADRANT_BULLET[q]) }
610
- out["research"] = cap_lines(by_created_desc.call(research), "🔬")
611
- out
612
+ if ranked.size > NEXT_WORK_CAP
613
+ lines << { id: "", intent: "", scope: "", lifecycle: "", value: "", disposition: "",
614
+ flags: [], line: "+#{ranked.size - NEXT_WORK_CAP} more" }
615
+ end
616
+ lines
612
617
  end
613
618
 
614
619
  def short_description(scope)
@@ -654,7 +659,7 @@ def render_data_global(records)
654
659
  { mode: "global", date: today.to_s,
655
660
  store_health: store_health(:global),
656
661
  recently_worked: recently_worked(records),
657
- matrix: matrix_data(matrix_pool),
662
+ next_work: next_work(matrix_pool),
658
663
  counts: counts_of(global),
659
664
  projects: projs,
660
665
  project_totals: {
@@ -671,7 +676,7 @@ def render_data_project(records, slug)
671
676
  store_health: store_health(slug),
672
677
  description: short_description(scope),
673
678
  recently_worked: recently_worked(records, project_scope: scope),
674
- matrix: matrix_data(matrix_pool),
679
+ next_work: next_work(matrix_pool),
675
680
  counts: counts_of(scoped),
676
681
  active: cap_lines(scoped.select { |r| r[:status] == "active" }
677
682
  .sort_by { |r| invert_ts(r[:last_accessed_at]) }, STATUS_GLYPH["active"]),
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 ---
@@ -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
@@ -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
 
@@ -1271,9 +1326,85 @@ class Doctor
1271
1326
  end
1272
1327
  end
1273
1328
 
1329
+ checks += check_agent_model_drift(agent_key)
1330
+
1274
1331
  checks
1275
1332
  end
1276
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
+
1277
1408
  # --- Check category: manifest sync (binary core integrity) ---
1278
1409
 
1279
1410
  # Verify, for BOTH the global manifest and the agent-side manifest, that every
@@ -1801,4 +1932,4 @@ class Doctor
1801
1932
 
1802
1933
  end
1803
1934
 
1804
- 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__
@@ -19,9 +19,9 @@ cockpit, _err, status = Open3.capture3("ruby", dashboard, "continue")
19
19
  exit 0 unless status.success? && !cockpit.strip.empty?
20
20
 
21
21
  context = cockpit.rstrip +
22
- "\n\nFollow the plastic-intent-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."
22
+ "\n\nFollow the plastic-continuing skill workflow to resume: " \
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" => {
@@ -168,4 +168,5 @@ class Install < InstallerCore
168
168
  end
169
169
  end
170
170
 
171
- exit(Install.new(package_root: ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__)).cli(ARGV)) if $PROGRAM_NAME == __FILE__
171
+ exit(Install.new(package_root: ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__),
172
+ plastic_home: ENV.fetch("PLASTIC_HOME") { InstallerCore::DEFAULT_PLASTIC_HOME }).cli(ARGV)) if $PROGRAM_NAME == __FILE__
@@ -640,6 +640,75 @@ module Bridge
640
640
  lines.length
641
641
  end
642
642
 
643
+ # --- Phantom-line detection (intent 134) ------------------------------------
644
+ #
645
+ # A companion to the ledger, not a new writer: pure, disk-only, hermetic (no bridge or
646
+ # session resolution, no writes), matching intent 52's savepoint-decoupling precedent. Under
647
+ # a gate-routing misfire (bug 131) or an out-of-band merge (124a's precedent), a ledger line
648
+ # can go stale or duplicate without the file evidence agreeing. This detects, never repairs;
649
+ # repair is `rebuild_savepoint` (live intents) or the 124a manual Done-bookend recipe
650
+ # (terminal intents, human-granted only).
651
+
652
+ # (stage, milestone) -> basename, for every file-landing milestone this intent_dir could have
653
+ # produced (the intent file plus the four lifecycle artifacts). Reuses savepoint_milestone so
654
+ # the mapping never drifts from the one the writer itself uses.
655
+ def self.savepoint_file_landing_pairs(intent_dir)
656
+ basenames = [File.basename(intent_file(intent_dir)), "spec.md", "plan.md", "checklist.md", "outcome.md"]
657
+ basenames.each_with_object({}) do |basename, map|
658
+ pair = savepoint_milestone(intent_dir, basename)
659
+ map[pair] = basename if pair
660
+ end
661
+ end
662
+
663
+ # A `started` state line's real prerequisite is the PRECEDING stage's artifact, not its own
664
+ # (a `started` line legitimately fires before its own stage's file is real by design). `Exec
665
+ # started` additionally requires plan.md, since Exec cannot start before How produced it too.
666
+ SAVEPOINT_STATE_PREREQUISITES = {
667
+ ["How", "started"] => ["spec.md"],
668
+ ["Exec", "started"] => ["plan.md", "checklist.md"],
669
+ }.freeze
670
+
671
+ # Raw (stripped) ledger lines whose disk evidence contradicts them, each paired with a short
672
+ # reason: [line, reason]. Three phantom classes (D5):
673
+ # - a file-landing milestone whose file is absent or still a sentinel placeholder;
674
+ # - a duplicate (stage, milestone) pair (the later occurrence is the phantom);
675
+ # - a state line (`How started` / `Exec started`) whose stage prerequisites are absent.
676
+ # A clean ledger, or an absent one, returns [].
677
+ def self.savepoint_phantom_lines(intent_dir)
678
+ path = File.join(intent_dir, SAVEPOINT_FILE)
679
+ return [] unless File.exist?(path)
680
+
681
+ landing = savepoint_file_landing_pairs(intent_dir)
682
+ seen = []
683
+ phantoms = []
684
+
685
+ File.read(path).each_line do |raw|
686
+ line = raw.strip
687
+ next if line.empty?
688
+ parts = line.split(/\s{2,}/)
689
+ next if parts.length < 3
690
+ pair = [parts[1], parts[2]]
691
+
692
+ if seen.include?(pair)
693
+ phantoms << [line, "duplicate (stage, milestone) pair"]
694
+ next
695
+ end
696
+ seen << pair
697
+
698
+ if (basename = landing[pair]) && !stage_file_present?(File.join(intent_dir, basename))
699
+ phantoms << [line, "milestone file absent or still a sentinel placeholder"]
700
+ next
701
+ end
702
+
703
+ prereqs = SAVEPOINT_STATE_PREREQUISITES[pair]
704
+ if prereqs && prereqs.any? { |b| !stage_file_present?(File.join(intent_dir, b)) }
705
+ phantoms << [line, "state line prerequisite absent on disk"]
706
+ end
707
+ end
708
+
709
+ phantoms
710
+ end
711
+
643
712
  def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
644
713
  stage = derive_stage(intent_dir)
645
714
  has = has_files(intent_dir)
@@ -913,6 +982,18 @@ module Bridge
913
982
  name: name, tmp: tmp)
914
983
  data["build"]["auto"] = auto
915
984
  data["lock"] = lock_cache(lock_data)
985
+
986
+ # Provision the per-intent worktrees so the rebuilt bridge carries
987
+ # worktree.code (intent 136). Without it, cwd/edited-path selection has no
988
+ # key: the repaired intent loses its own code gate and a concurrent sibling
989
+ # wins the tie-break. Idempotent (reuse dir / reattach branch) and fail-open
990
+ # for non-git / global-only, exactly as `arm` does; never break the repair.
991
+ begin
992
+ Worktree.provision(data)
993
+ rescue => e
994
+ $stderr.puts "plastic: worktree provision raised during repair, continuing unprovisioned: #{e.message}"
995
+ end
996
+
916
997
  write(key, data, tmp: tmp)
917
998
  actions << "bridge rebuilt from disk (stage #{data['build']['stage']})"
918
999
 
@@ -26,16 +26,15 @@ module DashboardBanner
26
26
  line
27
27
  end
28
28
 
29
- # The id of the top-ranked next_big candidate, when the payload's matrix carries
30
- # exactly the shape dashboard.rb emits (a "next_big" list of {id, ...} hashes,
31
- # already rank-sorted). Returns nil for any other shape rather than raising.
29
+ # The id of the top-ranked "drive" candidate, when the payload's next_work list
30
+ # carries the shape dashboard.rb emits (an Array of {id, disposition, ...}
31
+ # hashes, already rank-sorted). Returns nil for any other shape rather than
32
+ # raising.
32
33
  def next_big_thing_id(payload)
33
- matrix = payload["matrix"]
34
- return nil unless matrix.is_a?(Hash)
35
- list = matrix["next_big"]
36
- return nil unless list.is_a?(Array) && !list.empty?
37
- top = list.first
38
- return nil unless top.is_a?(Hash)
34
+ list = payload["next_work"]
35
+ return nil unless list.is_a?(Array)
36
+ top = list.find { |entry| entry.is_a?(Hash) && entry["disposition"] == "drive" }
37
+ return nil unless top
39
38
  id = top["id"].to_s
40
39
  id.empty? ? nil : id
41
40
  end
@@ -12,7 +12,7 @@ require_relative "agent_models"
12
12
  # Shared installer machinery, instantiable with injected package root / store / agent
13
13
  # map so the verb scripts (install/update/uninstall/rollback) and their tests can run
14
14
  # hermetically (no eval, no global-constant rewriting). Mirrors the DI recipe proven in
15
- # doctor.rb / install.rb (intents 30a, 30a1). Library only no CLI, no $PROGRAM_NAME guard.
15
+ # doctor.rb / install.rb (intents 30a, 30a1). Library only - no CLI, no $PROGRAM_NAME guard.
16
16
  class InstallerCore
17
17
  DEFAULT_PLASTIC_HOME = File.join(Dir.home, ".plastic")
18
18
 
@@ -54,7 +54,7 @@ class InstallerCore
54
54
  STABILITY[ch] || 2
55
55
  end
56
56
 
57
- # --- Semver (§11) parse/compare, shared by update + rollback ---
57
+ # --- Semver (§11) - parse/compare, shared by update + rollback ---
58
58
 
59
59
  def semver_parse(version)
60
60
  m = /\A(\d+)\.(\d+)\.(\d+)(?:-(.+))?\z/.match(version.to_s.strip)
@@ -95,7 +95,7 @@ class InstallerCore
95
95
  semver_compare(a, b) == 1
96
96
  end
97
97
 
98
- # --- versions.json ledger (append-only JSONL one object per line) ---
98
+ # --- versions.json ledger (append-only JSONL - one object per line) ---
99
99
 
100
100
  def ledger_path
101
101
  File.join(plastic_home, "versions.json")
@@ -242,6 +242,7 @@ class InstallerCore
242
242
  "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
243
243
  "scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
244
244
  "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
245
+ "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
245
246
  "scripts/hook-code-gate" => "scripts/hook-code-gate",
246
247
  "scripts/hook-lock-gate" => "scripts/hook-lock-gate",
247
248
  "scripts/hook-bash-gate" => "scripts/hook-bash-gate",
@@ -260,6 +261,10 @@ class InstallerCore
260
261
  "scripts/lib/dashboard_banner.rb" => "scripts/lib/dashboard_banner.rb",
261
262
  "scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
262
263
  "scripts/qmd-sync" => "scripts/qmd-sync",
264
+ "scripts/lib/roadmap_savepoint.rb" => "scripts/lib/roadmap_savepoint.rb",
265
+ "scripts/roadmap-savepoint" => "scripts/roadmap-savepoint",
266
+ "scripts/lib/roadmap_queue.rb" => "scripts/lib/roadmap_queue.rb",
267
+ "scripts/roadmap-next" => "scripts/roadmap-next",
263
268
  "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
264
269
  "scripts/lib/graph_rebuild.rb" => "scripts/lib/graph_rebuild.rb",
265
270
  "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
@@ -271,6 +276,7 @@ class InstallerCore
271
276
  "scripts/rebuild-graph" => "scripts/rebuild-graph",
272
277
  "scripts/validate-intent" => "scripts/validate-intent",
273
278
  "scripts/new-intent" => "scripts/new-intent",
279
+ "scripts/end-intent" => "scripts/end-intent",
274
280
  "scripts/hook-create-gate" => "scripts/hook-create-gate",
275
281
  "templates/intent.md" => "templates/intent.md",
276
282
  "templates/spec.md" => "templates/spec.md",
@@ -287,6 +293,7 @@ class InstallerCore
287
293
  "scripts/update.rb" => "scripts/update.rb",
288
294
  "scripts/uninstall.rb" => "scripts/uninstall.rb",
289
295
  "scripts/rollback.rb" => "scripts/rollback.rb",
296
+ "scripts/lib/legacy_bookend_amnesty.rb" => "scripts/lib/legacy_bookend_amnesty.rb",
290
297
  "scripts/doctor.rb" => "scripts/doctor.rb",
291
298
  "scripts/dashboard.rb" => "scripts/dashboard.rb",
292
299
  }
@@ -0,0 +1,35 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Intent 170a - A2 cutoff amnesty for the legacy savepoint Done-bookend gap.
5
+ #
6
+ # Frozen 2026-07-10. Pre-161 terminal intents predate
7
+ # Bridge.append_terminal_savepoint, so their savepoint.md never got a
8
+ # `Done delivered|abandoned` line. This list grandfathers exactly those,
9
+ # keyed by store scope plus intent id, so doctor's signals_complete check
10
+ # stops counting them as gaps. Every intent NOT on this list still warns if
11
+ # its savepoint lacks the Done bookend, including any new terminal intent
12
+ # going forward. This is a frozen historical snapshot: it must never grow.
13
+ # Regenerating it requires re-running the exact predicate below against the
14
+ # real store and reviewing the diff, never appending ad hoc.
15
+ #
16
+ # Predicate used to build this list (2026-07-10): for each store in
17
+ # Doctor#done_signal_stores(nil), each dir in index_sections_by_dir(index),
18
+ # terminal = the dir's INDEX section is Completed or Abandoned, gap =
19
+ # savepoint.md exists AND does not match
20
+ # /\bDone\b.*\b(delivered|abandoned)\b/.
21
+ #
22
+ # This does NOT grandfather the separate outcome.md completeness gap
23
+ # (checked independently at scripts/doctor.rb:603-607); some of these ids
24
+ # may still warn on that axis.
25
+ module LegacyBookendAmnesty
26
+ LIST = {
27
+ "global" => %w[1a 1a2 23].freeze,
28
+ "project:plastic" => %w[
29
+ 1 11 121a 124 128 13 13b 15 158 158a 159 160 163 1a 1b1a3 22 30a1a 34
30
+ 36a 36a1 37 38 39 45 45a 49 4a 4a1 4a1c1 50 52 54 55 56 58 59 60b 65
31
+ 66 66a 66b 66c 66c1 67 68 71 72 73b 73c 73c1 73c2 73c3 74 77 79 80 83
32
+ 84 85a 9
33
+ ].freeze,
34
+ }.freeze
35
+ end